8.6 KiB
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), Sliderole="group"+aria-roledescription="slide", Control은 disabled일 때 nativedisabled, 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, Sliderole=group/slide, 5개 slide, 초기 Previousdisabled, Control 32px·radius 9999px·흰 면,setApireadout이slide 1 / 5. - navigation: Next 클릭으로
slide 2 / 5와 Previous 활성화, Previous 클릭으로slide 1 / 5와 Previous 비활성화. 버튼에 focus를 둔 채 ArrowRight/ArrowLeft로 3 → 2. 끝(5/5)에서 Nextdisabled. - pointer: 왼쪽 drag로
slide 2 → 3, 오른쪽 drag로3 → 2. - loop: 첫 slide에서 Previous가 활성이고 클릭하면
3 / 3, Next로1 / 3, 계속 눌러도 Next가 비활성되지 않음. - vertical:
data-orientation=vertical, Previoustop: 8px·rotate: 90deg, Nextbottom: 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 동작.