Files
HMDesign/docs/components/carousel.md
T

8.6 KiB
Raw Blame History

Carousel

현재 HM 구현의 기준 문서. 공통 원칙은 docs/guidelines/프로젝트-지침.md(§6, §7), Token 구조 원칙은 테마-토큰-가이드.md를 따른다. 이 문서는 Carousel 고유의 API·Token·상태·결정만 기록한다.

1. 상태와 성격

항목 내용
성격 Interactive layout component (slide 탐색). 단순 가로 scroll이 아니다
구현 상태 구현 완료 (현재 HM 규격)
기준일 2026-10-03
기반 shadcn/ui Carousel = Embla (embla-carousel-react 8.6.0)
대표 Story Components/Data & Display/Carousel → All (이름 "Carousel")
보류 아래 §12

2. 적용 근거

  • 참고 근거: 외부 ui_viz Reference (ZIP 미포함)의 기존 테스트 규격에는 Carousel이 없다. 참고할 수치가 없었다.
  • 현재 HM 규격: 아래 §3~§4. 현재 HM Semantic/Primitive로 새로 정했다. Button/Scroll Area Token은 참조하지 않는다.
  • 구현 방식: Embla가 drag/swipe, snap, loop, align, plugin, scroll 계산을 소유한다. Carousel은 이전/다음 control, 방향키, carousel 접근성 속성을 더하고 Embla API를 setApi로 노출한다. 데이터 fetching이나 paging은 소유하지 않는다.
  • 보류: §12.

3. 기본 규격 (현재 HM 규격)

항목 값
Slide 폭 기본 basis-full(한 개). 여러 개는 소비자가 CarouselItem에 basis-1/2 등을 준다. 카드 폭을 컴포넌트가 강제하지 않는다
Gap 16px (Track이 -16px만큼 당겨지고 각 Slide가 16px padding, 가로는 left / 세로는 top)
Control 32 × 32px 원형 버튼, 흰 면 + 1px --hm-sem-stroke-input-default + --hm-sem-fg-primary 화살표 16px
Control 위치 carousel 가장자리 안쪽 8px(가로: 좌우 가운데, 세로: 위/아래 가운데이며 화살표를 90° 회전). shadcn은 바깥(-48px)이라 좁은 부모에서 잘리는 문제가 있어 안쪽으로 정했다
Content 커서 grab / 누르는 동안 grabbing

4. 상태 규격

상태 값
Initial Previous disabled(loop가 아니면), Next enabled
Disabled (경계) opacity 0.6(이 컴포넌트 값, 전역 Disabled 규칙으로 승격 안 함), cursor not-allowed. loop일 때는 비활성되지 않는다
Hover Control 배경 --accent
Focus (:focus-visible) Control에 ring 모양 0 0 0 3px ring 50%
Dragging Embla pointer drag, 커서 grabbing
Active slide 컴포넌트 속성은 없다. setApi로 selectedScrollSnap()을 읽어 소비자가 표시한다(Story의 slide n / total)
Vertical orientation="vertical", Control이 위/아래로 이동하고 화살표가 회전

5. API

shadcn 구조 그대로. export: Carousel, CarouselContent, CarouselItem, CarouselPrevious, CarouselNext, useCarousel, type CarouselApi.

  • Carousel: orientation "horizontal" | "vertical", opts(Embla options: loop, align, dragFree, startIndex…), plugins, setApi. 출력 data-orientation.
  • CarouselContent: Embla viewport(overflow-hidden)와 track을 렌더한다(data-slot="carousel-content", 안쪽 carousel-track). className은 track에 적용된다.
  • CarouselItem: slide. className으로 basis-* 지정.
  • CarouselPrevious/CarouselNext: Carousel 전용 button(Button 컴포넌트가 아님). sr-only 텍스트 "Previous slide" / "Next slide".
  • variant, size, cardType 등은 만들지 않았다.
  • 접근성: Root role="region" + aria-roledescription="carousel"(이름은 소비자의 aria-label), Slide role="group" + aria-roledescription="slide", Control은 disabled일 때 native disabled, ArrowLeft/ArrowRight가 이전/다음(캡처 단계에서 처리).
  • Root min-w-0: grid/flex 부모의 min-width:auto가 Embla track의 intrinsic width로 커져 페이지가 밀리는 것을 막기 위해 Root에 min-w-0을 둔다. 한 단계 더 감싼 wrapper(grid item)에는 소비자가 min-w-0을 줘야 한다(Story가 그렇게 한다).

6. Dependency

  • embla-carousel-react ^8.6.0 (packages/ui에 추가, shadcn Carousel의 기반). 사용: Carousel. CENTER 패키지에는 추가하지 않았다.

7. Token

Component Mapping 위치: packages/ui/src/theme/carousel.css (--hm-carousel-*).

gap, control-size, control-inset, control-icon-size, control-background, control-text, control-border-width|color, control-hover-background, disabled-opacity. Semantic과 Primitive(--hm-warm-neutral-pale)만 참조한다. 신규 Primitive와 Semantic 없음. 모든 규칙이 @layer components에 있어 소비자의 utility(className: gap, control 위치와 크기)가 이긴다.

8. Embla에서 유지한 항목

pointer drag/swipe와 snap, loop·align·dragFree·skipSnaps 등 options, plugins, canScrollPrev/Next, reInit/select 이벤트, scrollPrev/Next 구현.

9. Storybook

  • 대표 Story All("Carousel"): Basic(한 개 + setApi로 slide n / 5 표시), Multiple items(basis-1/2), Loop(3개), Vertical(세로), Media(AspectRatio 조합)를 한 페이지에 둔다. Card와 AspectRatio는 Story 조합이며 Carousel은 둘을 import하지 않는다.
  • verification: Basic, Multiple Items, Loop, Vertical.

10. 소비자 확장성

  • Slide 폭: className="basis-1/2"이 그대로 적용된다(확인).
  • Gap/Control 위치·크기: CSS가 @layer components라 className의 utility가 이긴다(예: CarouselPrevious className="-left-12"로 바깥 배치 가능).
  • Track 높이(세로): CarouselContent style/className으로 높이를 준다.

11. 사용자 확정 결정

  • 2026-10-03: ui-viz는 참고 자료이며 최종 기준은 현재 HM 구현과 이 문서다(작업 지시). Control은 Button 조합이 아니라 Carousel 전용 control로 둔다(Button Token과 분리).

12. 미확정 / 보류

  • 수치(32px control, 8px inset, 16px gap)는 현재 HM 규격이며 디자인 확정 시 재검토.
  • Control이 slide 위에 겹치므로 카드 안쪽 콘텐츠를 가릴 수 있다(특히 세로 carousel의 첫/끝 항목). 바깥 배치는 className으로 선택한다.
  • 인디케이터(dot), autoplay, 썸네일 동기화는 만들지 않았다(Embla plugin과 setApi로 소비자가 조합).
  • 반응형(breakpoint별 basis)은 소비자 className(md:basis-1/3)이다. 자동 responsive carousel은 만들지 않았다.
  • 포인터가 컨트롤 위일 때 drag 시작 동작, 스크린리더가 slide 변경을 알리는 live region은 정의하지 않았다.

13. 관련 파일

packages/ui/src/components/ui/carousel.tsx
packages/ui/src/theme/carousel.css
packages/ui/package.json             (dependency: embla-carousel-react)
packages/ui/src/theme.css            (import)
packages/ui/src/index.ts             (export)
src/components/carousel.stories.tsx

14. 검증 상태

  • npm run typecheck, npm run build: 통과(2026-10-03).
  • 실제 DOM(Browser pane): Root role=region, aria-roledescription=carousel, aria-label, Slide role=group/slide, 5개 slide, 초기 Previous disabled, Control 32px·radius 9999px·흰 면, setApi readout이 slide 1 / 5.
  • navigation: Next 클릭으로 slide 2 / 5와 Previous 활성화, Previous 클릭으로 slide 1 / 5와 Previous 비활성화. 버튼에 focus를 둔 채 ArrowRight/ArrowLeft로 3 → 2. 끝(5/5)에서 Next disabled.
  • pointer: 왼쪽 drag로 slide 2 → 3, 오른쪽 drag로 3 → 2.
  • loop: 첫 slide에서 Previous가 활성이고 클릭하면 3 / 3, Next로 1 / 3, 계속 눌러도 Next가 비활성되지 않음.
  • vertical: data-orientation=vertical, Previous top: 8px·rotate: 90deg, Next bottom: 8px, Next 클릭 후 Previous 활성화, slide가 위아래로 쌓임.
  • 레이아웃: Slide 폭이 Root 폭(basis-full), basis-1/2이면 절반(390px에서 187px), gap 16px(Track -16px, Item padding 16px).
  • Responsive(390×844): 모든 carousel이 358px 안(좌 16px)에 들어가고 Control이 안쪽에 있어 잘리지 않으며 페이지 가로 overflow 없음. (처음에는 grid wrapper의 min-width:auto 때문에 페이지가 436px로 밀려 Root에 min-w-0과 Story wrapper의 minWidth:0을 추가해 해결했다.)
  • 참고: 이 검증 환경은 페이지가 화면에 그려질 때만 animation frame이 진행되어, scroll 위치(translate)는 screenshot으로 frame을 만든 뒤에야 최종값에 도달했다. 위 검증은 Embla의 selectedScrollSnap(readout)과 버튼 상태 기준이다.
  • 미확인: 터치 기기에서의 swipe(마우스 drag로 대체), dragFree·plugin(autoplay), 스크린리더 실제 읽기, 소비자의 md:basis-* breakpoint 동작.