Carousel | CoUI
LogoCoUI

Carousel

슬라이드 형태로 콘텐츠를 순환하며 보여주는 컴포넌트

Carousel#

슬라이드 형태로 콘텐츠를 순환하며 보여주는 컴포넌트입니다. 자동 재생, 탐색 버튼, 인디케이터를 지원합니다.

Live Preview#

사용 시기 (When to Use)#

이 컴포넌트를 사용하세요:

  • 여러 이미지나 콘텐츠를 슬라이드쇼로 표시할 때
  • 프로모션 배너를 순환 표시할 때

대신 다른 컴포넌트를 사용하세요:

  • HoverGallery: 호버 기반 이미지 전환

기본 사용법 (Basic Usage)#

Carousel(
  transition: CarouselTransition.sliding(),
  items: [
    Container(color: Colors.blue, child: Text('Slide 1')),
    Container(color: Colors.green, child: Text('Slide 2')),
    Container(color: Colors.orange, child: Text('Slide 3')),
  ],
  autoPlay: true,
  showIndicators: true,
  showNavigation: true,
)
Carousel(
  currentIndex: _index,
  onIndexChanged: (i) => setState(() => _index = i),
  items: [
    div([const Text('Slide 1').bodyMedium.onSurface]),
    div([const Text('Slide 2').bodyMedium.onSurface]),
    div([const Text('Slide 3').bodyMedium.onSurface]),
  ],
  autoPlay: true,
  showIndicators: true,
  showNavigation: true,
)

빠른 오버라이드 (Chain)#

이미 만든 Carousel 인스턴스에 스타일을 빠르게 덧붙이고 싶다면 withStyle 체인을 쓸 수 있습니다. 생성자의 style 슬롯 인자와 동일하게 동작하지만, 이미 구성된 위젯 위에서 바로 이어 쓸 수 있습니다. .radius4처럼 Core 토큰 상수 이름과 똑같은 이름의 getter도 있습니다 — withStyle을 한 번 더 줄인 sugar로, 이름이 곧 값이라(radius4 == CoreRadius.radius4) 어느 컴포넌트에서 써도 뜻이 갈리지 않습니다.

class CarouselChainExample extends StatefulWidget {
  const CarouselChainExample({super.key});

  @override
  State<CarouselChainExample> createState() => _CarouselChainExampleState();
}

class _CarouselChainExampleState extends State<CarouselChainExample> {
  @override
  Widget build(BuildContext context) {
    return Carousel(
          transition: CarouselTransition.sliding(),
          items: [
            Container(
              alignment: Alignment.center,
              color: const Color(0xFF3B82F6),
              padding: const EdgeInsets.all(CoreSpace.space64),
              child: const Text(
                'Slide 1',
                style: TextStyle(color: Colors.white, fontSize: 18),
              ),
            ),
            Container(
              alignment: Alignment.center,
              color: const Color(0xFF22C55E),
              padding: const EdgeInsets.all(CoreSpace.space64),
              child: const Text(
                'Slide 2',
                style: TextStyle(color: Colors.white, fontSize: 18),
              ),
            ),
            Container(
              alignment: Alignment.center,
              color: const Color(0xFFF97316),
              padding: const EdgeInsets.all(CoreSpace.space64),
              child: const Text(
                'Slide 3',
                style: TextStyle(color: Colors.white, fontSize: 18),
              ),
            ),
          ],
        )
        .withStyle(
          const CoreCarouselStyle(
            indicatorActiveColor: CoreColor.token(CoreColors.tertiary),
            indicatorSize: CoreSpace.space12,
            indicatorSpacing: CoreSpace.space12,
          ),
        )
        .radius16;
  }
}
class CarouselChainExample extends StatefulComponent {
  const CarouselChainExample({super.key});

  @override
  State<CarouselChainExample> createState() => _CarouselChainExampleState();
}

class _CarouselChainExampleState extends State<CarouselChainExample> {
  int _index = 0;

  @override
  Component build(BuildContext context) {
    return Carousel(
          currentIndex: _index,
          onIndexChanged: (idx) => setState(() => _index = idx),
          items: [
            div(
              [Text('Slide 1')],
              styles: Styles(
                raw: {
                  'background': '#3B82F6',
                  'color': 'white',
                  'font-size': '18px',
                  'display': 'flex',
                  'align-items': 'center',
                  'justify-content': 'center',
                  'padding': 'var(--coui-space-64)',
                },
              ),
            ),
            div(
              [Text('Slide 2')],
              styles: Styles(
                raw: {
                  'background': '#22C55E',
                  'color': 'white',
                  'font-size': '18px',
                  'display': 'flex',
                  'align-items': 'center',
                  'justify-content': 'center',
                  'padding': 'var(--coui-space-64)',
                },
              ),
            ),
            div(
              [Text('Slide 3')],
              styles: Styles(
                raw: {
                  'background': '#F97316',
                  'color': 'white',
                  'font-size': '18px',
                  'display': 'flex',
                  'align-items': 'center',
                  'justify-content': 'center',
                  'padding': 'var(--coui-space-64)',
                },
              ),
            ),
          ],
        )
        .withStyle(
          const CoreCarouselStyle(
            indicatorActiveColor: CoreColor.token(CoreColors.tertiary),
            indicatorSize: CoreSpace.space12,
            indicatorSpacing: CoreSpace.space12,
          ),
        )
        .radius16;
  }
}

Props / Parameters#

공통 (Flutter · Web)#

속성타입기본값설명
items List<Widget>? / List<Component>? null 슬라이드 목록 (eager 경로)
itemBuilder CarouselItemBuilder? null lazy 슬라이드 빌더 ( items 대신). (Object context, int index) — 순환 캐러셀에서 index 는 범위를 벗어날 수 있습니다
itemCount int? null 아이템 수 (wrap: false 이면 items 또는 itemCount 필수)
transition CoreCarouselTransition Flutter 필수 / Web CarouselTransition.sliding() 전환 모드 (sliding / fading) + 슬라이드 간 gap
curve CoreCubicBezier CoreEasing.easeInOut 전환 easing 토큰 (Flutter Curve / Web cubic-bezier())
speed Duration CoreCarouselStyle.defaultTransitionSpeed 전환 애니메이션 속도
duration Duration? null 자동 재생 간격. null 이면 CoreCarouselStyle.defaultAutoplayInterval (양 플랫폼 동일)
durationBuilder CarouselDurationBuilder? null 인덱스별 자동 재생 간격 (duration 오버라이드)
autoplaySpeed Duration? null 자동 재생 전환 속도. 설정 시 그만큼 자동 재생 간격도 늘어납니다
autoPlay bool? null → 간격 유무로 유도 자동 재생 on/off. 생략 시 duration·durationBuilder 존재로 결정
autoplayReverse bool false 자동 재생을 역방향으로
waitOnStart bool false 첫 자동 전환 전에 한 주기 대기
showIndicators bool true 인디케이터 표시
showNavigation bool? null 좌우 버튼 표시. null 이면 variant 가 답한다 — slide 는 보이고 banner 는 숨는다
variant CoreCarouselVariant .slide 무엇을 순환하는가 — slide 는 점을 뷰포트 위에 indicatorInset 만큼 안쪽으로 띄우고 좌우 버튼을 보인다. banner 는 점을 뷰포트 아래 indicatorBelowGapStyle 간격으로 흐름에 놓고 좌우 버튼을 기본으로 끈다 — 88 높이 띠에는 32 원판 두 개가 들어갈 자리가 없다. 배너 슬라이드는 itemsCarouselBanner 를 넣는다
size CoreCarouselSize .md 안쪽 부품이 쓰는 수치 묶음 — 오늘은 인디케이터 점 크기. 캐러셀 자신의 크기는 부모가 정한다
currentIndex int 0 현재 슬라이드 인덱스
onIndexChanged ValueChanged<int>? null 인덱스 변경 콜백
alignment CoreCarouselAlignment .center 뷰포트 안 활성 슬라이드 정렬 (슬라이드가 뷰포트보다 작을 때만 관측 가능)
direction CoreAxis .horizontal 캐러셀 축
reverse bool false 아이템 배치 순서 역전
sizeConstraint CoreCarouselSizeConstraint CoreCarouselSizeConstraint.fractional(1) 슬라이드의 축 방향 크기 (fractional / fixed)
wrapbooltrue무한 순환
pauseOnHover bool true 호버 시 자동 재생 일시 정지
draggable bool true 드래그 / 스와이프 가능 여부
disableDraggingVelocity bool false 드래그 관성(velocity) 비활성화
disableOverheadScrolling bool true 한 제스처당 한 아이템으로 제한
carouselStyle CoreCarouselStyle? null chrome + nested 슬롯 오버라이드 (아래 표 참고)

Flutter 전용#

속성타입기본값설명
controller CarouselController? null 프로그래매틱 제어용 외부 컨트롤러 (Flutter 런타임 인프라 — Web 은 무상태 DOM 이 그 자리를 대신합니다)

Flutter 는 CarouselTransition / CarouselAlignment / CarouselSizeConstraint / CarouselFixedConstraint / CarouselFractionalConstraint / CarouselItemBuilder / CarouselDurationBuilder 라는 짧은 별칭을 노출하고, Web 도 CarouselTransition / CarouselItemBuilder / CarouselDurationBuilder 를 같은 이름으로 노출합니다 — 그래서 양 플랫폼 호출부 코드가 글자까지 같습니다. Flutter CarouselTransition 은 그 위에 자기 슬라이드 배치 렌더러(layout)를 얹은 서브클래스입니다.

스타일 시스템 (Style System)#

Carousel 의 모든 chrome / dimensional / nested-slot 오버라이드는 CoreCarouselStyle 단일 슬롯으로 흐릅니다. behaviour (autoPlay / wrap / pauseOnHover / showIndicators / showNavigation) 는 위젯 파라미터로 직접 전달합니다.

시맨틱 vs 스타일#

  • behaviour: 위젯 파라미터로 직접 (autoPlay, wrap, pauseOnHover, showIndicators, showNavigation, currentIndex, onIndexChanged)
  • chrome / dimensional / 슬롯 스타일: CoreCarouselStyle 한 곳으로 (borderRadius / indicatorActiveColor / indicatorInactiveColor / indicatorSize / indicatorSpacing / indicatorInset / navInset / controlOverlayColor / navFadeDuration / prevNextButtonStyle)

Resolve chain#

design system default for carousel
  → CoreCarouselTheme.style                    // 프로젝트 공통
  → parent component slot override
  → widget.carouselStyle                       // 인스턴스별

각 nested 슬롯 스타일 (prevNextButtonStyle) 은 자기 컴포넌트의 자체 resolve chain 으로 다시 한 번 머지됩니다.

CoreCarouselStyle 필드#

필드타입설명
borderRadius CoreBorderRadius? Outer carousel viewport corner radius. Falls back to [defaultBorderRadius].
indicatorActiveColor CoreColor? Active indicator dot fill colour.
indicatorInactiveColor CoreColor? Inactive indicator dot fill colour.
indicatorSize double? Indicator dot diameter (logical px). No paired default* : this is a partial forward into the nested CoreDotIndicatorStyle ( dotSize ), so null lets DotIndicator 's own resolver supply its per-size default ( defaultsBySize[size] ). A default here would copy the child's default into the parent — what style-contract.md forbids ("자식 default 에 reach 하지 마라") — and the two would diverge the first time the child's moves. Deliberately has no default* — absence is the design. Partial forward into the nested CoreDotIndicatorStyle ( dotSize: merged.indicatorSize , raw on both platforms) so null lets DotIndicator's own resolver supply its per-size default. A default here copies the child's default into the parent — the ?? CoreYStyle.defaultX reach style-contract.md forbids — and the two diverge the first time the child's dot size moves.
indicatorSpacing double? Native Row.spacing between indicator dots — forwarded to CoreDotIndicatorStyle.spacing , applied as inter-dot Flex spacing (not a Gap widget) in logical px.
indicatorInset CoreEdgeInsets? Indicator-row inset from the viewport edge — typically only the bottom side is non-zero (default CoreEdgeInsets.only(bottom: CoreSpace.space16) ); the resolver reads the resolved insets per-side at the paint site so callers can override any axis (e.g. push the row up or pad horizontally).
navInset CoreEdgeInsets? Prev / next navigation button inset from the viewport edge — typically symmetric horizontal (default CoreEdgeInsets.symmetric(horizontal: CoreSpace.space16) ); the resolver materialises it straight to the nav Padding so callers can pad any axis.
controlOverlayColor CoreColor? Surface (background) colour applied behind the prev / next nav controls. null → [defaultControlOverlayColor].
navFadeDuration Duration? Fade duration for the prev / next nav buttons revealing on carousel hover (hidden until the pointer is over the viewport). null → [defaultNavFadeDuration].
indicatorBelowGapStyle CoreGapStyle? Spacer between the viewport and the indicator row when the widget's variant is banner — forwarded straight to Gap(gapStyle: …) . Unused by the overlay placement, whose row is anchored by [indicatorInset] instead. null → [defaultIndicatorBelowGapStyle].
prevNextButtonStyle CoreButtonStyle? Previous / next button chrome.

동작 스펙 (Behavior)#

  • 좌우 버튼으로 슬라이드 전환 — 포인터 기기에서는 캐러셀 hover 시에만 페이드 인 (터치 기기는 스와이프 + 인디케이터가 기본 어포던스)
  • 인디케이터 dot 클릭으로 직접 이동
  • autoPlay: true 시 자동 순환 — 간격은 duration(또는 durationBuilder)이고, 주지 않으면 디자인 시스템 기본 간격을 씁니다. autoPlay: falseduration 이 있어도 끕니다
  • autoPlay생략하면 간격 유무로 유도합니다 — autoPlay ?? (durationBuilder != null || duration != null). duration 만 준 코드는 그대로 순환하고, 둘 다 없으면 정지합니다
  • waitOnStart: false(기본) 이면 마운트 직후 첫 전환이 일어나고, true 면 한 주기 기다립니다
  • pauseOnHover: true 시 호버 시 일시 정지
  • wrap: true 시 마지막에서 첫 번째로 순환
  • 드래그를 놓으면 관성이 감쇠하며 가장 가까운 슬라이드로 스냅합니다. disableOverheadScrolling: true(기본) 이면 한 제스처가 최대 한 슬라이드만 넘깁니다
  • direction: .vertical부모가 높이를 정해줘야 합니다 — 쌓인 슬라이드에서 뷰포트 높이를 역산할 수 없기 때문이며, Flutter 도 주축이 bounded 여야 하는 것과 같은 제약입니다

사용 가이드라인 (Usage Guidelines)#

✅ Do#

direction: .vertical은 부모가 높이를 정해줬을 때만 사용

SizedBox(
  height: 400,
  child: Carousel(
    direction: CoreAxis.vertical,
    transition: CarouselTransition.sliding(),
    items: slides,
  ),
)

캐러셀은 쌓인 슬라이드에서 뷰포트 높이를 역산할 수 없습니다 — Flutter의 주축이 bounded여야 하는 제약과 같아서, 세로 방향은 부모가 명시적 높이를 줘야만 정상 렌더됩니다.


❌ Don't#

wrap: false일 때 items/itemCount 생략 금지

// ❌ wrap:false인데 items/itemCount 없음 — assert 실패
Carousel(
  wrap: false,
  itemBuilder: (context, index) => buildSlide(index),
)

// ✅ itemCount로 경계를 명시
Carousel(
  wrap: false,
  itemCount: slides.length,
  itemBuilder: (context, index) => buildSlide(index),
)

wrap: false는 순환하지 않는 유한 캐러셀을 의미하므로, 마지막 인덱스가 어디인지 알려주는 items 또는 itemCount가 반드시 있어야 합니다.

접근성 (Accessibility)#

역할 (Semantics)#

두 플랫폼의 노출 지점이 서로 다릅니다.

  • Web: 루트 <div>role="region"aria-label(CouiLocalizations.carouselLabel) 을 emit 합니다. 두 값은 caller attributes 뒤에 쓰이므로, caller 가 같은 키를 넘겨도 컴포넌트 값이 이깁니다. 좌우 nav 버튼은 aria-labelCouiLocalizations.carouselPreviousSlide / carouselNextSlide 를 답니다. 무한 순환용 클론 슬라이드에는 inert 가, fading 모드의 보이지 않는 높이 앵커 슬라이드에는 inert + aria-hidden="true" 가 붙고, 인디케이터는 DotIndicator 에 위임되어 role="tablist" + dot 마다 role="tab" / aria-label / aria-current 를 얻습니다.
  • Flutter: 루트·트랙·슬라이드 어디에도 role 이 없습니다. semantics 주석은 두 곳뿐입니다 — 좌우 nav 버튼을 감싼 Semantics(button: true, label: …)(레이블은 CoUILocalizations.carouselPreviousSlide / carouselNextSlide), 그리고 intrinsic 사이징용 보이지 않는 ghost 슬라이드를 가리는 ExcludeSemantics. nav 버튼을 감싼 AnimatedOpacityalwaysIncludeSemantics: true 라, 버튼이 완전히 투명한 동안에도 그 두 레이블이 semantics 트리에 남습니다.

키보드#

캐러셀 루트가 처리하는 키는 양 플랫폼 모두 없습니다. 슬라이드를 옮기는 화살표·Home/End 가 루트에 없고 드래그는 포인터 전용입니다. 실제로 동작하는 키는 전부 합성된 자식에서 옵니다.

포커스 위치동작플랫폼
Enter / Space 좌우 nav 버튼 이전 / 다음 슬라이드 Flutter · Web
(세로 방향이면 ) 인디케이터(DotIndicator) 루트 dot 이동 → 해당 슬라이드로 전환 Flutter · Web

DotIndicator 는 양쪽 모두 그룹이 단일 탭 스톱인 roving 모델입니다 — Web 은 루트 tabindex="0" + dot 마다 tabindex="-1", Flutter 는 FocusableActionDetector 의 shortcuts + dot 마다 ExcludeFocus. 화살표는 양쪽 다 양 끝에서 clamp 되어 순환하지 않고, Home/End 는 어느 쪽에도 없습니다. 다만 Flutter DotIndicator 에는 semantics 주석이 없어 tablist/tab 으로 읽히는 것은 Web 뿐입니다.

포커스#

Flutter 캐러셀은 자체 FocusNode / FocusScope 가 없고, nav 버튼만 ButtonClickable 의 FocusNode 와 포커스 링으로 포커스를 받습니다. 그 버튼들은 AnimatedOpacity(opacity: _hovered ? 1 : 0) 안에 있습니다 — Opacity 는 포커스 순회를 막지 않으므로 완전히 투명한 상태에서도 Tab 이 닿고 Enter 로 눌립니다. 포커스 시 드러내는 처리가 없어 포커스된 버튼이 화면에 보이지 않습니다.

Web 은 루트에 tabindex 가 없고 nav <button> 은 네이티브로 포커스를 받으며, group-focus-within:opacity-100 덕분에 키보드 포커스에서 실제로 드러납니다. 포커스 트랩과 복원은 양쪽 모두 없습니다.

스크린 리더#

Web: "Carousel" 로 이름 붙은 landmark region 으로 읽히고, 좌우 nav 버튼은 "이전 슬라이드" / "다음 슬라이드" 로 읽히며, 클론 슬라이드는 inert 로 중복 낭독이 억제되고, dot 은 tablist/tab 으로 읽힙니다. 슬라이드 개수나 현재 위치를 알리는 안내는 없습니다.

Flutter: nav 버튼과 그 "이전/다음 슬라이드" 레이블은 포인터가 캐러셀 위에 있든 없든 semantics 트리에 있습니다 — hover 가 존재하지 않는 터치 기기에서도 같습니다. 슬라이드 자체는 평범한 콘텐츠로 읽히며 region 레이블도, 슬라이드 개수나 현재 위치 안내도 없습니다.

알려진 제약#

  • Flutter: region role 과 레이블이 없습니다(carouselLabel 은 정의만 되어 있고 Web 에서만 참조됩니다). 포커스로 nav 버튼을 드러내는 처리가 없어 포커스된 버튼이 보이지 않습니다. DotIndicator 가 tablist/tab semantics 를 노출하지 않아 dot 이 역할 없이 읽힙니다.
  • Web: attributes: {'aria-label': …} 로 캐러셀 이름을 바꾸려 해도 컴포넌트가 나중에 덮어씁니다.
  • 양쪽 공통: 현재 슬라이드가 아닌 실제 슬라이드도 보조 기술에서 숨겨지지 않습니다 — 숨김이 걸린 것은 Web 의 클론·높이 앵커와 Flutter 의 ghost 슬라이드뿐입니다. aria-roledescription="carousel" 없음, 슬라이드 전환을 알리는 live region 없음, 캐러셀 루트의 화살표/Home/End 내비게이션 없음, autoplay 정지가 hover 전용이라 키보드 포커스로는 멈출 수 없고 별도 정지 컨트롤도 없음, 드래그는 포인터 전용.

모션 감소 · 고대비 · 최소 터치 타깃처럼 모든 컴포넌트에 공통으로 적용되는 축은 전역 접근성 축 에 정리되어 있습니다.

크로스 플랫폼 차이점 (Platform Differences)#

같은 파라미터를 각 렌더 엔진의 관용구로 구현하며, 어느 쪽에만 있는 기능은 없습니다 (controller 는 Flutter 런타임 인프라라 제외 — Web 은 브라우저가 그 capability 를 줍니다).

항목FlutterWeb
sliding 전환 슬라이드를 Position 으로 배치 flex 라인 + CSS translateX/translateY
fading 전환 Opacity + 진행도 절대 배치 슬라이드 + CSS opacity transition
easing CoreCubicBezierCubic CoreCubicBeziercubic-bezier()
드래그 GestureDetector pointerdown + document pointer 리스너
자동 재생Ticker 기반체인 Timer 기반
호버 감지 MouseRegion mouseenter/mouseleave
무한 순환CarouselController 연속 값가상 무한 트랙 (clone + instant jump)
프로그래매틱 제어 controller 무상태 DOM (currentIndex + onIndexChanged)