Carousel#
슬라이드 형태로 콘텐츠를 순환하며 보여주는 컴포넌트입니다. 자동 재생, 탐색 버튼, 인디케이터를 지원합니다.
Live Preview#
class CarouselDefaultExample extends StatefulComponent {
const CarouselDefaultExample({super.key});
@override
State<CarouselDefaultExample> createState() =>
_CarouselDefaultExampleState();
}
class _CarouselDefaultExampleState extends State<CarouselDefaultExample> {
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)',
}),
),
],
);
}
}
class CarouselDefaultExample extends StatefulWidget {
const CarouselDefaultExample({super.key});
@override
State<CarouselDefaultExample> createState() =>
_CarouselDefaultExampleState();
}
class _CarouselDefaultExampleState extends State<CarouselDefaultExample> {
@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)),
),
],
);
}
}
class CarouselBannerExample extends StatefulComponent {
const CarouselBannerExample({super.key});
@override
State<CarouselBannerExample> createState() => _CarouselBannerExampleState();
}
class _CarouselBannerExampleState extends State<CarouselBannerExample> {
int _index = 0;
@override
Component build(BuildContext context) {
return Carousel(
currentIndex: _index,
onIndexChanged: (idx) => setState(() => _index = idx),
variant: CoreCarouselVariant.banner,
items: [
for (final (title, body) in const [
('New this week', 'Three titles just landed'),
('Reading challenge', 'Finish two books in April'),
('Members save', 'Bundles at 20% off'),
])
CarouselBanner(
thumbnail: const Icon(LucideIcons.image),
title: Text(title),
description: Text(body),
),
],
);
}
}
class CarouselBannerExample extends StatelessWidget {
const CarouselBannerExample({super.key});
@override
Widget build(BuildContext context) {
return Carousel(
transition: CarouselTransition.sliding(),
variant: CoreCarouselVariant.banner,
items: [
for (final (title, body) in const [
('New this week', 'Three titles just landed'),
('Reading challenge', 'Finish two books in April'),
('Members save', 'Bundles at 20% off'),
])
CarouselBanner(
thumbnail: const Icon(LucideIcons.image),
title: Text(title),
description: Text(body),
),
],
);
}
}
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;
}
}
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;
}
}
사용 시기 (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 원판 두 개가 들어갈 자리가 없다. 배너 슬라이드는
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) |
wrap | bool | true | 무한 순환 |
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: 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)#
역할 (Semantics)#
두 플랫폼의 노출 지점이 서로 다릅니다.
-
Web: 루트
<div>가role="region"과aria-label(CouiLocalizations.carouselLabel) 을 emit 합니다. 두 값은 callerattributes뒤에 쓰이므로, 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
덕분에 키보드 포커스에서 실제로 드러납니다. 포커스 트랩과 복원은 양쪽 모두 없습니다.
스크린 리더#
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 를 줍니다).
| 항목 | Flutter | Web |
|---|---|---|
| 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 |
| 무한 순환 | CarouselController 연속 값 | 가상 무한 트랙 (clone + instant jump) |
| 프로그래매틱 제어 | controller |
무상태 DOM (currentIndex + onIndexChanged) |
관련 컴포넌트 (Related Components)#
- HoverGallery: 호버 기반 이미지 전환
- DotIndicator: 페이지 위치 표시