컴포넌트

Carousel

여러 항목을 가로로 탐색합니다.

설치

pnpm dlx ply-ui add carousel icon

공통 설치 설정을 먼저 적용합니다.

사용법

items의 고유 ID, 제목, 설명, 미디어를 가로 목록으로 표시합니다. onItemClick은 선택한 ID를 전달합니다. 앞뒤 이동 버튼과 직접 스크롤을 사용할 수 있습니다.

구현 코드

components/ui/carousel.tsx
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
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;
}

목차