컴포넌트
Carousel
여러 항목을 가로로 탐색합니다.
설치
pnpm dlx ply-ui add carousel icon typography공통 설치 설정을 먼저 적용합니다.
사용법
items의 고유 ID, 제목, 설명, 미디어를 가로 목록으로 표시합니다. onItemClick은 선택한 ID를 전달합니다. 앞뒤 이동 버튼과 직접 스크롤을 사용할 수 있습니다.
onItemClick을 지정하면 항목이 버튼으로 동작합니다. 생략하면 내용을 읽는 일반 항목으로 표시하고 클릭 동작이나 버튼 의미를 부여하지 않습니다.
링크 항목과 제목 연결
다른 화면으로 가는 항목은 href를 지정해 링크로 만듭니다. 링크여야 새 탭에서 열기, 주소 복사, 가운데 클릭이 됩니다. onItemClick을 함께 지정하면 링크를 누를 때도 불립니다.
화면에 제목이 이미 있으면 label 대신 aria-labelledby로 그 제목을 연결합니다. 이때 Carousel 바깥을 같은 제목의 section으로 다시 감싸지 않습니다. 같은 이름의 영역이 두 번 생기기 때문입니다. label과 aria-labelledby 중 하나는 꼭 지정합니다.
항목이 버튼이나 링크이면 스크롤 영역 자체는 Tab 정지점이 되지 않습니다. 항목에 포커스가 가면 스크롤이 따라갑니다.
이동 버튼
이동 버튼의 기본 이름은 "이전 카드"·"다음 카드"이며 previousLabel·nextLabel로 바꿉니다. 끝에 도달한 버튼은 비활성 상태가 되지만 키보드 포커스는 유지합니다.
보조기기는 영역의 종류를 "캐러셀"로 읽습니다. 다른 언어나 표현이 필요하면 roleDescription으로 바꿉니다.
구현 코드
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;
/** 항목을 이 주소로 가는 링크로 만듭니다. 상세 화면 이동처럼 다른 페이지로 가는 항목에 씁니다. */
href?: string;
};
type CarouselName =
| {
/** 캐러셀 영역의 접근 가능한 이름입니다. */
label: string;
"aria-labelledby"?: never;
}
| {
label?: never;
/** 화면에 보이는 제목의 id입니다. 제목이 이미 있으면 같은 이름을 또 쓰지 않고 연결합니다. */
"aria-labelledby": string;
};
/** Base UI ScrollArea + Button 조합. 카드의 280/160/12px 규칙은 Account Status에서 측정했습니다. */
export function Carousel({
label,
"aria-labelledby": labelledBy,
items,
onItemClick,
previousLabel = "이전 카드",
nextLabel = "다음 카드",
roleDescription = "캐러셀",
className,
}: CarouselName & {
items: CollectionItem[];
onItemClick?: (id: string) => void;
/** 이전 카드로 이동하는 버튼의 이름입니다. @defaultValue "이전 카드" */
previousLabel?: string;
/** 다음 카드로 이동하는 버튼의 이름입니다. @defaultValue "다음 카드" */
nextLabel?: string;
/** 보조기기가 읽는 영역의 종류입니다. @defaultValue "캐러셀" */
roleDescription?: string;
className?: string;
}) {
// Informational cards must not advertise an action that does not exist.
const Action = onItemClick ? Button : "div";
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";
const card = content.current?.firstElementChild;
const gap = content.current
? parseFloat(getComputedStyle(content.current).columnGap) || 0
: 0;
const distance =
(card?.getBoundingClientRect().width ?? el.clientWidth) + gap;
el.scrollBy({
left: direction * (rtl ? -distance : distance),
behavior: matchMedia("(prefers-reduced-motion: reduce)").matches
? "instant"
: "smooth",
});
}
return (
<section
className={cx("rbx-carousel", className)}
aria-label={label}
aria-labelledby={labelledBy}
aria-roledescription={roleDescription}
>
<ScrollArea.Root>
<ScrollArea.Viewport
className="rbx-carousel-viewport"
ref={viewport}
onScroll={updateEdges}
// 항목에 포커스가 가면 스크롤도 따라가므로, 뷰포트가 이름 없는 Tab 정지점이 되지 않게 합니다.
tabIndex={
onItemClick || items.some((item) => item.href) ? -1 : undefined
}
>
<ScrollArea.Content className="rbx-carousel-content" ref={content}>
{items.map((item) => {
const body = (
<>
<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>
</>
);
const onClick = onItemClick
? () => onItemClick(item.id)
: undefined;
// 이동은 링크로 둬야 새 탭 열기·주소 복사가 됩니다. onItemClick은 링크에서도 함께 불립니다.
return item.href ? (
<a
key={item.id}
href={item.href}
className="rbx-carousel-item"
onClick={onClick}
>
{body}
</a>
) : (
<Action
key={item.id}
className="rbx-carousel-item"
onClick={onClick}
>
{body}
</Action>
);
})}
</ScrollArea.Content>
</ScrollArea.Viewport>
</ScrollArea.Root>
<div className="rbx-carousel-controls">
<IconButton
icon="icon-regular-chevron-large-left"
aria-label={previousLabel}
size="sm"
variant="utility"
circular
// 끝에 도달해도 키보드 포커스가 body로 사라지지 않게 합니다.
focusableWhenDisabled
disabled={edges.start}
onClick={() => scroll(-1)}
/>
<IconButton
icon="icon-regular-chevron-large-right"
aria-label={nextLabel}
size="sm"
variant="utility"
circular
focusableWhenDisabled
disabled={edges.end}
onClick={() => scroll(1)}
/>
</div>
</section>
);
}
components/ui/carousel.css
@layer theme, base, components, utilities;
@layer components.ply-base, components.ply;
@layer components.ply {
.rbx-carousel {
width: 100%;
min-width: 0;
container-type: inline-size;
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: min(280px, 100cqw);
overflow-wrap: anywhere;
padding: 0;
border: 0;
background: transparent;
color: var(--rbx-color-content-emphasis);
text-align: start;
scroll-snap-align: start;
}
:is(button, a).rbx-carousel-item {
cursor: pointer;
}
a.rbx-carousel-item {
text-decoration: none;
}
.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;
}
.rbx-carousel-controls:dir(rtl) .rbx-icon {
transform: rotate(180deg);
}
}