Scrollbar#
스크롤 가능한 child 를 감싸 테마된 스크롤바를 입히는 래퍼입니다. Flutter / Web 동일 API 로 thumb 색·두께·반경을
scrollbarStyle 단일 슬롯에서 제어합니다.
Live Preview#
class ScrollbarDefaultExample extends StatelessComponent {
const ScrollbarDefaultExample({super.key});
@override
Component build(BuildContext context) {
return div(
styles: Styles(raw: {'width': '240px', 'height': '160px'}),
[
Scrollbar(
child: div(
classes: 'flex flex-col gap-${CoreSpace.scale.space8}',
[
for (var i = 1; i <= 20; i += 1)
Text('스크롤 항목 $i').bodyMedium,
],
),
),
],
);
}
}
class ScrollbarDefaultExample extends StatefulWidget {
const ScrollbarDefaultExample({super.key});
@override
State<ScrollbarDefaultExample> createState() =>
_ScrollbarDefaultExampleState();
}
class _ScrollbarDefaultExampleState extends State<ScrollbarDefaultExample> {
final ScrollController _controller = ScrollController();
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return SizedBox(
width: 240,
height: 160,
child: Scrollbar(
controller: _controller,
child: SingleChildScrollView(
controller: _controller,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: CoreSpace.space8,
children: [
for (var i = 1; i <= 20; i += 1) Text('스크롤 항목 $i').bodyMedium,
],
),
),
),
);
}
}
class ScrollbarChainExample extends StatelessComponent {
const ScrollbarChainExample({super.key});
@override
Component build(BuildContext context) {
return div(
styles: Styles(raw: {'width': '240px', 'height': '160px'}),
[
Scrollbar(
child: div(
classes: 'flex flex-col gap-${CoreSpace.scale.space8}',
[
for (var i = 1; i <= 20; i += 1) Text('스크롤 항목 $i').bodyMedium,
],
),
).withStyle(
const CoreScrollbarStyle(
thumbColor: CoreColor.token(CoreColors.primary),
thumbRadius: CoreBorderRadius.all(CoreRadius.radius9999),
thickness: CoreStrokeWidth.stroke8,
trackColor: CoreColor.token(CoreColors.surfaceContainerHigh),
),
),
],
);
}
}
class ScrollbarChainExample extends StatefulWidget {
const ScrollbarChainExample({super.key});
@override
State<ScrollbarChainExample> createState() => _ScrollbarChainExampleState();
}
class _ScrollbarChainExampleState extends State<ScrollbarChainExample> {
final ScrollController _controller = ScrollController();
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return SizedBox(
width: 240,
height: 160,
child:
Scrollbar(
controller: _controller,
child: SingleChildScrollView(
controller: _controller,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: CoreSpace.space8,
children: [
for (var i = 1; i <= 20; i += 1) Text('스크롤 항목 $i').bodyMedium,
],
),
),
).withStyle(
const CoreScrollbarStyle(
thumbColor: CoreColor.token(CoreColors.primary),
thumbRadius: CoreBorderRadius.all(CoreRadius.radius9999),
thickness: CoreStrokeWidth.stroke8,
trackColor: CoreColor.token(CoreColors.surfaceContainerHigh),
),
),
);
}
}
사용법#
// Flutter — 스크롤 컨트롤러 공유
final controller = ScrollController();
Scrollbar(
controller: controller,
child: SingleChildScrollView(controller: controller, child: /* ... */),
)
// Web — 스크롤 영역을 감싸면 overflow 를 소유
Scrollbar(child: div(/* tall content */))
빠른 오버라이드 (Chain)#
이미 만든 Scrollbar 인스턴스에 스타일을 빠르게 덧붙이고 싶다면 withStyle 체인을 쓸 수 있습니다. 생성자의 style 슬롯 인자와 동일하게 동작하지만, 이미 구성된 위젯 위에서 바로 이어 쓸 수 있습니다.
class ScrollbarChainExample extends StatefulWidget {
const ScrollbarChainExample({super.key});
@override
State<ScrollbarChainExample> createState() => _ScrollbarChainExampleState();
}
class _ScrollbarChainExampleState extends State<ScrollbarChainExample> {
final ScrollController _controller = ScrollController();
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return SizedBox(
width: 240,
height: 160,
child:
Scrollbar(
controller: _controller,
child: SingleChildScrollView(
controller: _controller,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: CoreSpace.space8,
children: [
for (var i = 1; i <= 20; i += 1) Text('스크롤 항목 $i').bodyMedium,
],
),
),
).withStyle(
const CoreScrollbarStyle(
thumbColor: CoreColor.token(CoreColors.primary),
thumbRadius: CoreBorderRadius.all(CoreRadius.radius9999),
thickness: CoreStrokeWidth.stroke8,
trackColor: CoreColor.token(CoreColors.surfaceContainerHigh),
),
),
);
}
}
class ScrollbarChainExample extends StatelessComponent {
const ScrollbarChainExample({super.key});
@override
Component build(BuildContext context) {
return div(
styles: Styles(raw: {'width': '240px', 'height': '160px'}),
[
Scrollbar(
child: div(
classes: 'flex flex-col gap-${CoreSpace.scale.space8}',
[
for (var i = 1; i <= 20; i += 1) Text('스크롤 항목 $i').bodyMedium,
],
),
).withStyle(
const CoreScrollbarStyle(
thumbColor: CoreColor.token(CoreColors.primary),
thumbRadius: CoreBorderRadius.all(CoreRadius.radius9999),
thickness: CoreStrokeWidth.stroke8,
trackColor: CoreColor.token(CoreColors.surfaceContainerHigh),
),
),
],
);
}
}
Props#
| 파라미터 | 타입 | 기본값 | 설명 |
|---|---|---|---|
child |
Widget / Component |
required | 스크롤 가능한 콘텐츠 |
axis |
CoreScrollbarAxis |
defaultAxis |
스크롤 축 |
visibility |
CoreScrollbarVisibility |
defaultVisibility |
표시 정책 —
auto
/
thin
은 스크롤 시 300ms fade-in, idle 600ms 뒤 300ms fade-out(양 플랫폼 동일 타이밍 — Web 은 pseudo transition 미지원이라 8-스텝 근사),
always
상시,
hidden
숨김
|
interactive |
bool |
true |
thumb 드래그 가능 여부 |
scrollbarStyle |
CoreScrollbarStyle? |
null |
chrome 단일 진입점 (아래 필드 표) |
controller |
ScrollController? |
null (Flutter 전용 런타임 인프라) |
스크롤 컨트롤러 공유. Web 은 브라우저 native overflow 로 동작 |
CoreScrollbarStyle 필드#
| 필드 | 타입 | 설명 |
|---|---|---|
thumbColor | CoreColor? | Thumb colour override. |
thumbRadius |
CoreBorderRadius? |
Thumb corner radius override. |
thickness |
double? |
Thumb thickness (logical px, pre-scaling). |
minThumbLength |
double? |
Minimum thumb length (logical px, pre-scaling).
null
→ token default ([defaultMinThumbLength]).
|
trackColor |
CoreColor? |
Track colour override.
null
→ [defaultTrackColor] (transparent — the thumb floats over the content with no visible gutter).
|
Resolve chain#
design system default (CoreScrollbarStyle.defaultX)
→ CoreScrollbarTheme.style // 프로젝트 공통
→ widget.scrollbarStyle // 인스턴스별
ScrollArea 처럼 Scrollbar 를 합성하는 부모는 자기
CoreScrollAreaStyle.scrollbarStyle 슬롯을 이 scrollbarStyle
로 그대로
넘깁니다.
사용 가이드라인 (Usage Guidelines)#
✅ Do#
Scrollbar 와 스크롤 뷰가 같은 ScrollController 를 공유
final controller = ScrollController();
Scrollbar(
controller: controller,
child: SingleChildScrollView(controller: controller, child: content),
)
Scrollbar 는 넘겨받은 컨트롤러를 내부 RawScrollbar 에 그대로 연결합니다 — 스크롤 뷰가 다른 컨트롤러를 쓰면 thumb 이 실제 스크롤 위치를 따라가지 못합니다.
❌ Don't#
interactive: false 를 포인터에서까지 동일 동작으로 가정하지 않기
// ❌ Web 에서는 native thumb 이 여전히 드래그 가능
Scrollbar(interactive: false, child: content)
Flutter 는 자기 thumb 을 그리므로 드래그 자체를 막지만, Web 은 브라우저 native 스크롤바를 스킨한 것이라 CSS 로 드래그를 막을 수 없습니다. 보조기술에 대해서는 양쪽이 같은 말을 합니다(Flutter 는 increase/decrease 를 광고하지 않고, Web 은 aria-disabled) — 갈리는 것은 마우스뿐입니다.
접근성 (Accessibility)#
역할 (Semantics)#
양 플랫폼 모두 스크롤 위치를 보고하는 팬텀 노드를 내보냅니다. 그려지는 thumb 과는 별개의 형제 노드이고, 픽셀을 전혀 차지하지 않습니다 — 붙일 자리가 없기 때문입니다. Web 의 thumb 은 브라우저 native 스크롤바를 CSS 로 스킨한 것이라 역할을 얹을 DOM 노드가 없고, Flutter 의 thumb 은 렌더 레이어에 오버레이로 그려지는 사각형이라 위젯이 아닙니다.
| 무엇을 내보내나 | |
|---|---|
| Web |
sr-only
<div>
에
role="scrollbar"
+
aria-orientation
·
aria-valuenow
·
aria-valuemin
·
aria-valuemax
·
aria-controls
. 이 바가 보고하는 축마다 하나 (
axis: both
면 두 개)
|
| Flutter |
slider
로 표시된
Semantics
노드에
value: 'N%'
. Flutter 에는 scrollbar 역할이 없고, "범위 위의 값을 보고한다" 는 같은 성질을 가진
slider
가 가장 가까운 관용구입니다
|
aria-controls 는 스크롤되는 루트 자신을 가리킵니다 — 별도의 thumb 요소가 없으므로
움직이는 대상과 보고하는 노드를 이 속성으로 잇습니다.
키보드#
스크롤바 자체는 tab 정지점이 아닙니다 (양 플랫폼 모두 tabindex / FocusNode 없음).
팬텀 노드에 tabindex 를 주지 않는 것은 의도입니다 — 픽셀이 없어 포커스 링을 그릴 자리가
없습니다. onKeyDown / onKeyUp 은 호출자가 넘긴 핸들러를 루트에 연결하는 통과 슬롯입니다.
키보드 스크롤은 스크롤되는 영역 쪽이 제공합니다. Flutter 는 감싼 스크롤러 자신의 스크롤
시맨틱으로 제공하고, Web 은 overflow: auto 루트를 브라우저가 스크롤해 주는 만큼입니다 —
엔진에 달려 있습니다. Chromium(focusable scrollers) 과 Firefox 는 스크롤 컨테이너를
스스로 포커스 가능하게 만들지만 WebKit 은 그러지 않고, 이 컴포넌트는 자기 tabindex 를
붙이지 않습니다. 안쪽에 포커스 가능한 요소가 없는 영역을 키보드로 확실히 스크롤시키려면
소비자가 attributes 로 tabindex="0" (필요하면 role="region"
· aria-label) 을 직접
넣어야 합니다 — ScrollArea 에도 같은 제약이 적혀 있습니다.
포커스#
포커스를 받지 않습니다. 팬텀 노드는 픽셀이 없어 포커스 링을 그릴 자리가 없고, 그래서 tab 정지점이 아닌 것이 의도입니다 — 보이지 않는 정지점은 포커스가 사라진 것처럼 보입니다 (WCAG 2.4.7).
스크린 리더#
스크롤 위치를 0–100 퍼센트로 보고하고, 스크롤할 때마다 값이 갱신됩니다.
interactive: true 인 Flutter 에서는 increase / decrease 동작을 함께 광고해 보조기술이
바를 통해 한 번에 10% 씩 움직일 수 있습니다. Web 의 팬텀 노드에는 대응하는 동작이 없습니다 —
그 자리를 브라우저의 키보드 스크롤이 대신하며, 위 "키보드" 의 엔진 조건이 그대로 붙습니다.
interactive: false 면 Flutter 는 그 동작을 광고하지 않고, Web 은 aria-disabled="true"
로 같은 말을 합니다 — 다만 Flutter 는 실제로 있던 동작을 거두는 것이고, Web 은 애초에 동작이
없던 어포던스에 대한 진술입니다. 위치 보고는 어느 쪽에서도 사라지지 않습니다 —
interactive 는 바를 조작하는 것을 막을 뿐 읽는 것을 막지 않습니다.
Flutter 는 조건이 하나 더 있습니다: 콘텐츠가 다 들어가서 갈 곳이 없으면 increase / decrease 를 광고하지 않습니다. Web 에는 대응하는 조건이 없습니다.
visibility: hidden 은 픽셀만 감춥니다. 위 노드는 양 플랫폼 모두 그대로 남습니다 —
눈으로 볼 thumb 이 없는 그 모드야말로 위치를 말로 알려주는 것이 가장 필요한 자리입니다.
알려진 제약#
-
interactive: false가 포인터에서는 갈립니다. Flutter 는 자기 thumb 을 그리므로 hit-test 를 끄지만, Web 은 브라우저 native 스크롤바를 스킨한 것이라 어떤 CSS 로도 드래그를 막을 수 없습니다. 보조기술에 보이는 표면은 위처럼 양쪽이 같지만, 마우스로 만질 수 있는지는 다릅니다. -
interactive: false는 Web 에서overscroll-behavior: contain도 함께 내보냅니다 — 안쪽 스크롤이 끝에 닿아도 조상으로 이어지지 않습니다. thumb 드래그와는 무관하고 Flutter 에 대응이 없는, 이 플래그의 부산물입니다. -
보조기술이 스크롤 위치를 직접 움직이는 경로가 Web 에서는 엔진에 달려 있습니다. Flutter 는
interactive: true일 때 increase / decrease 를 제공하지만, Web 의 대응물은 브라우저가 스크롤 루트를 키보드로 스크롤해 주는 것이고 그러려면 그 루트가 포커스를 받아야 합니다 — 위 "키보드" 참고. controller는 Flutter 전용이라 크로스 플랫폼 프로그래매틱 스크롤 경로가 없습니다.
reduced motion · 고대비 등 컴포넌트를 가로지르는 축은 전역 접근성 축에서 다룹니다.