Carousel | CoUI
본문으로 건너뛰기
LogoCoUI

Carousel

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

목차

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

기본 사용법 (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 CoreEdgeInsets.all(CoreSpace.space64).toValue(geometry: Theme.of(context).geometry),
              child: const Text(
                'Slide 1',
                style: TextStyle(color: Colors.white, fontSize: 18),
              ),
            ),
            Container(
              alignment: Alignment.center,
              color: const Color(0xFF22C55E),
              padding: const CoreEdgeInsets.all(CoreSpace.space64).toValue(geometry: Theme.of(context).geometry),
              child: const Text(
                'Slide 2',
                style: TextStyle(color: Colors.white, fontSize: 18),
              ),
            ),
            Container(
              alignment: Alignment.center,
              color: const Color(0xFFF97316),
              padding: const CoreEdgeInsets.all(CoreSpace.space64).toValue(geometry: Theme.of(context).geometry),
              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;
  }
}

Live Preview#

사용 시기 (When to Use)#

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

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

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

  • HoverGallery: 시간·이전/다음 탐색 대신 포인터가 놓인 영역에 따라 이미지만 전환할 때

Props / Parameters#

W 는 Flutter의 Widget, Web의 Jaspr Component 입니다. List<W> 는 콘텐츠 목록입니다.

공통 (Flutter · Web)#

속성타입기본값설명
items List<W>? null 슬라이드 목록 (eager 경로)
itemBuilder CarouselItemBuilder? null lazy 슬라이드 빌더 ( items 대신). (Object context, int index) — 순환 캐러셀에서 index 는 범위를 벗어날 수 있습니다
itemCount int? null 아이템 수 (wrap: false 이면 items 또는 itemCount 필수)
transition CoreCarouselTransition 명시¹ 전환 모드 (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 원판 두 개가 들어갈 자리가 없다. 배너 슬라이드는 items 에 CarouselBanner 를 넣는다
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 런타임의 명령형 제어용 외부 컨트롤러

¹ Flutter는 transition 을 요구하고 Web은 생략 시 sliding을 쓰는 soft requiredness 완화를 유지합니다. 공통 호출에는 전환 값을 명시하세요. Flutter는 현재 currentIndex 를 선언하지만 렌더 위치에 적용하지 않아, 이 값에 기대는 공통 제어에는 배선 결손이 있습니다.

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 / navGapStyle / 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].
navGapStyle CoreGapStyle? Spacer between the previous and the next button when the widget's variant is banner — forwarded straight to Gap(gapStyle: …) . The banner variant gathers the pair at the trailing end of the strip (the strip is too short to leave a button at each end), so the two sit side by side and this is the space between them. The slide variant puts one button at each end of the viewport, where there is nothing to space, so the slot is unused there. null → [defaultNavGapStyle].
prevNextButtonStyle CoreButtonStyle? Previous / next button chrome. Merged on top of [defaultPrevNextButtonStyle].
overlayIndicatorFocusOutlineStyle CoreFocusOutlineStyle? Focus-ring chrome for the indicator dots when they overlay the slide (the slide variant) — forwarded as the indicator row's CoreDotIndicatorStyle.focusOutlineStyle . Merged on top of [defaultOverlayIndicatorFocusOutlineStyle]. The banner variant lays the dots out below the strip, where the dot indicator's own focus-ring default applies, so this slot does not reach it.

동작 스펙 (Behavior)#

  • 좌우 버튼은 떠 있는 판입니다 — 불투명한 surfaceRaised 채움, 안쪽으로 그리는 1px outlineVariant 테두리, CoreShadow.sm 그림자 (prevNextButtonStyle 의 기본값이라 슬라이드가 밝든 어둡든 경계가 보이고, 같은 필드로 덮어씁니다)
  • 버튼 배치는 variant 가 정합니다 — slide 는 뷰포트 양 끝에 하나씩, banner 는 띠가 짧아 끝에 모을 수 없어 한 쌍을 오른쪽(끝) 가장자리에 모으고 navGapStyle(기본 4) 만큼 띄웁니다. slide 에서는 navGapStyle 을 읽지 않습니다
  • 인디케이터는 활성 점이 primary, 비활성 점이 outline 입니다. 단독 DotIndicator 의 기본(onSurface / outlineVariant)과 다른 것은 캐러셀이 직접 진술한 값이라서이고, indicatorActiveColor / indicatorInactiveColor 로 덮어씁니다. 활성 점도 원형 그대로입니다
  • 좌우 버튼으로 슬라이드 전환 — 포인터 기기에서는 캐러셀 hover 시에만 페이드 인 (터치 기기는 스와이프 + 인디케이터가 기본 어포던스). hover 영역은 캐러셀 전체라, 점이 뷰포트 아래에 놓이는 banner 에서는 점 위에 포인터가 있어도 버튼이 드러납니다 (Flutter · Web 동일)
  • 인디케이터 dot 클릭으로 직접 이동
  • autoPlay: true 시 자동 순환 — 간격은 duration(또는 durationBuilder)이고, 주지 않으면 디자인 시스템 기본 간격을 씁니다. autoPlay: false 는 duration 이 있어도 끕니다
  • 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)#

역할#

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

  • Web: 루트 <div> 가 role="region" 과 aria-label(CouiLocalizations.carouselLabel) 을 emit 합니다. 두 값은 caller attributes 뒤에 쓰이므로, caller 가 같은 키를 넘겨도 컴포넌트 값이 이깁니다. 좌우 nav 버튼은 aria-label 로 CouiLocalizations.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 버튼을 감싼 AnimatedOpacity 는 alwaysIncludeSemantics: 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 버튼만 Button → Clickable 의 FocusNode 와 포커스 링으로 포커스를 받습니다. 그 버튼들은 AnimatedOpacity(opacity: _hovered ? 1 : 0) 안에 있습니다 — Opacity 는 포커스 순회를 막지 않으므로 완전히 투명한 상태에서도 Tab 이 닿고 Enter 로 눌립니다. 포커스 시 드러내는 처리가 없어 포커스된 버튼이 화면에 보이지 않습니다.

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

포커스 표시: 이전/다음 버튼은 슬라이드 위에 놓여 뒤의 색을 알 수 없으므로, 링과 버튼 사이 2px 간격을 surface 밴드로 칠합니다(CoreCarouselStyle.defaultPrevNextButtonStyle). 점 인디케이터는 슬라이드 위에 겹치는 slide variant 에서만 같은 밴드를 켜고(defaultOverlayIndicatorFocusOutlineStyle, 인스턴스는 overlayIndicatorFocusOutlineStyle 슬롯으로 덮어씁니다), 점이 띠 아래 페이지 위에 놓이는 banner variant 에서는 다른 컨트롤과 같이 간격이 투명합니다.

스크린 리더#

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)#

자동 재생·드래그·슬라이드 전환은 양쪽에 구현돼 있지만 제어 표면이 모두 동등한 것은 아닙니다. 위 API 결손과 다음 렌더 메커니즘을 구분하세요.

항목FlutterWeb
sliding 전환 슬라이드를 Position 으로 배치 flex 라인 + CSS translateX/translateY
fading 전환 Opacity + 진행도 절대 배치 슬라이드 + CSS opacity transition
easing CoreCubicBezier → Cubic CoreCubicBezier → cubic-bezier()
드래그 GestureDetector pointerdown + document pointer 리스너
자동 재생Ticker 기반체인 Timer 기반
호버 감지 MouseRegion mouseenter/mouseleave
좌우 버튼 배치 (banner) Row(mainAxisAlignment: .end) 에 Gap(navGapStyle) 로 한 쌍을 끝에 모음 한 쌍이 뒤쪽(오른쪽) 앵커 <div> 하나를 같이 쓰고 그 안에서 Gap(navGapStyle) 로 띄움 (가로 방향. 세로 방향은 버튼마다 앵커가 따로)
좌우 버튼 호버 노출 루트 MouseRegion 의 _hovered 가 AnimatedOpacity 를 구동 — navFadeDuration · CoreEasing.move 루트 group + 각 버튼을 감싼 <div> 의 opacity-0 group-hover:opacity-100 — 같은 시간·곡선. 페이드는 Button 이 아니라 감싸는 <div> 에 있다 ( Button 의 공유 transition 이 !important 이고 opacity 를 이름 짓지 않는다)
무한 순환CarouselController 연속 값가상 무한 트랙 (clone + instant jump)
프로그래매틱 제어 CarouselController StatefulComponent의 currentIndex 갱신