컴포넌트
Carousel
여러 항목을 가로로 탐색합니다.
설치
pnpm dlx ply-ui add carousel icon공통 설치 설정을 먼저 적용합니다.
사용법
items의 고유 ID, 제목, 설명, 미디어를 가로 목록으로 표시합니다. onItemClick은 선택한 ID를 전달합니다. 앞뒤 이동 버튼과 직접 스크롤을 사용할 수 있습니다.
구현 코드
components/ui/carousel.tsx
"use client";
import { useEffect, useRef, useState, type ReactNode } from "react";
import { ScrollArea } from "@base-ui/react/scroll-area";
import { Button } from "@base-ui/react/button";
import { IconButton } from "./icon-button";
import { cx } from "../../lib/cx";
import "./carousel.css";
export type CollectionItem = {
id: string;
title: string;
description?: string;
media: ReactNode;
};
/** Base UI ScrollArea + Button 조합. 카드의 280/160/12px 규칙은 Account Status에서 측정했습니다. */
export function Carousel({
label,
items,
onItemClick,
className,
}: {
label: string;
items: CollectionItem[];
onItemClick?: (id: string) => void;
className?: string;
}) {
const viewport = useRef<HTMLDivElement>(null);
const content = useRef<HTMLDivElement>(null);
const [edges, setEdges] = useState({ start: true, end: true });
function updateEdges() {
const el = viewport.current;
if (el)
setEdges({
start: Math.abs(el.scrollLeft) < 1,
end: Math.abs(el.scrollLeft) + el.clientWidth >= el.scrollWidth - 1,
});
}
useEffect(() => {
const observer = new ResizeObserver(updateEdges);
if (viewport.current) observer.observe(viewport.current);
if (content.current) observer.observe(content.current);
updateEdges();
return () => observer.disconnect();
}, [items.length]);
function scroll(direction: number) {
const el = viewport.current;
if (!el) return;
const rtl = getComputedStyle(el).direction === "rtl";
el.scrollBy({
left: direction * (rtl ? -292 : 292),
behavior: matchMedia("(prefers-reduced-motion: reduce)").matches
? "instant"
: "smooth",
});
}
return (
<section
className={cx("rbx-carousel", className)}
aria-label={label}
aria-roledescription="carousel"
>
<ScrollArea.Root>
<ScrollArea.Viewport
className="rbx-carousel-viewport"
ref={viewport}
onScroll={updateEdges}
>
<ScrollArea.Content className="rbx-carousel-content" ref={content}>
{items.map((item) => (
<Button
key={item.id}
className="rbx-carousel-item"
onClick={() => onItemClick?.(item.id)}
>
<span className="rbx-carousel-media">{item.media}</span>
<span className="rbx-carousel-copy">
<span className="rbx-carousel-title">{item.title}</span>
{item.description && (
<span className="rbx-carousel-description">
{item.description}
</span>
)}
</span>
</Button>
))}
</ScrollArea.Content>
</ScrollArea.Viewport>
</ScrollArea.Root>
<div className="rbx-carousel-controls">
<IconButton
icon="icon-regular-chevron-large-left"
aria-label="Previous cards"
size="sm"
variant="utility"
circular
disabled={edges.start}
onClick={() => scroll(-1)}
/>
<IconButton
icon="icon-regular-chevron-large-right"
aria-label="Next cards"
size="sm"
variant="utility"
circular
disabled={edges.end}
onClick={() => scroll(1)}
/>
</div>
</section>
);
}
components/ui/carousel.css
.rbx-carousel {
width: 100%;
min-width: 0;
padding-bottom: 4px;
}
.rbx-carousel-viewport {
overflow-x: auto;
scrollbar-width: none;
scroll-snap-type: x proximity;
}
.rbx-carousel-content {
display: flex;
gap: 12px;
width: max-content;
}
.rbx-carousel-item {
display: flex;
flex-direction: column;
flex-shrink: 0;
gap: 8px;
width: 280px;
padding: 0;
border: 0;
background: transparent;
color: var(--rbx-color-content-emphasis);
text-align: start;
scroll-snap-align: start;
cursor: pointer;
}
.rbx-carousel-media {
width: 100%;
height: 160px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
background: var(--rbx-color-shift-200);
}
.rbx-carousel-copy {
display: flex;
width: 100%;
flex-direction: column;
align-items: flex-start;
}
.rbx-carousel-title {
font: var(--rbx-typography-title-medium-font);
}
.rbx-carousel-description {
font: var(--rbx-typography-body-medium-font);
color: var(--rbx-color-content-default);
}
.rbx-carousel-controls {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 8px;
}