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: Theme.of(context).geometry.space(CoreSpace.space8),
children: [
for (var i = 1; i <= 20; i += 1) Text('스크롤 항목 $i').bodyMedium,
],
),
),
),
);
}
}
사용법#
// 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: Theme.of(context).geometry.space(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 / Parameters#
W는 Flutter의 Widget, Web의 Component를 뜻하는 합성 슬롯 타입입니다.
| 파라미터 | 타입 | 기본값 | 설명 |
|---|---|---|---|
child | W | 필수 | 스크롤 가능한 콘텐츠 |
axis |
CoreScrollbarAxis |
defaultAxis |
스크롤 축 |
visibility |
CoreScrollbarVisibility |
defaultVisibility |
표시 정책 —
auto
/
thin
은 스크롤 시 300ms fade-in, idle 600ms 뒤 300ms fade-out(양 플랫폼 동일 타이밍),
always
상시,
hidden
숨김
|
interactive |
bool |
true |
thumb 드래그 · 트랙 탭으로 한 페이지 이동 가능 여부 (양 플랫폼 동일) |
scrollbarStyle |
CoreScrollbarStyle? |
null |
chrome 단일 진입점 (아래 필드 표) |
controller |
ScrollController? |
null (Flutter 전용 런타임 인프라) |
스크롤 컨트롤러 공유. Web 은 브라우저 native overflow 로 동작 |
CoreScrollbarStyle 필드#
| 필드 | 타입 | 설명 |
|---|---|---|
thumbColor |
CoreColor? |
Thumb colour override. The alpha lives in the colour itself, so one field carries both the ink and how strongly it shows. |
hoverThumbColor |
CoreColor? |
Thumb colour while the pointer is on the thumb or it is being dragged.
null
→ [defaultHoverThumbColor].
|
thumbRadius |
CoreBorderRadius? |
Thumb corner radius override. |
thickness |
double? |
Thumb thickness (logical px, pre-scaling). |
hoverThickness |
double? |
Thumb thickness while the pointer is on the thumb or it is being dragged (logical px, pre-scaling).
null
→ [defaultHoverThickness]. The bar's footprint does not change: the thumb grows into the gap it keeps from the edge ([thumbInset]), and is never wider than the bar's footprint (see
CoreScrollbarThumbGeometry
).
|
thumbInset |
double? |
Gap between the thumb and the edge it hugs, on every side (logical px, pre-scaling).
null
→ [defaultThumbInset]. The bar's footprint is the thumb plus this gap on both sides.
|
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
로 그대로
넘깁니다.
동작 스펙 (Behavior)#
모양#
thumb 은 바탕 글자색(on-surface)을 50% 불투명도로 덮은 알약(radius9999)이고, 두께 6(size6)으로 가장자리에서 사방 2(space2) 떨어져 그려집니다. 바의 폭(footprint)은 thumb + 여백 × 2 = 10 입니다. 이 폭은 내용 위에 겹쳐 놓이는 영역이지 내용 옆에 따로 비워 두는 자리가 아닙니다 — 아래 "레이아웃" 참고. 글자색을 덮기 때문에 흰 면 · 회색 면 · 다크 어디서나 바탕보다 한 단계 진하게 보이고, 사용자가 끌어서 조작하는 컨트롤이라 비텍스트 대비 3:1(WCAG 1.4.11)을 지킵니다.
마우스를 올리거나 thumb 을 끌면 70% 불투명도와 두께 8(size8)로 진하고 굵어집니다. 바의 폭은 그대로라서 굵어진 만큼 여백이 2 → 1 로 줄어들고, 바 옆의 콘텐츠는 밀리지 않습니다.
visibility: thin 은 두 두께를 모두 절반으로 줄이고(3 · hover 4) 여백은 그대로 둡니다 — 이때 바의 폭은 7 입니다.
여백은 반경이 큰 컨테이너에서 thumb 이 모서리에 닿지 않게 하려는 값이지만 모서리를 완전히 피하지는 못합니다. 스크롤 영역이 둥근 컨테이너의 클립 가장자리에 맞닿으면 thumb 의 끝이 모서리 곡선에 걸립니다(반경 12 에서 약 5px, 반경 16 에서 약 8px). 스크롤 영역을 컨테이너 가장자리에서 3px 이상 안쪽에 두면 반경 16 에서도 걸리지 않습니다.
레이아웃#
스크롤바는 내용의 폭을 차지하지 않습니다. 양 플랫폼 모두 thumb 이 내용 위에 겹쳐 그려지므로 스크롤 영역 안쪽 내용의 폭은 스크롤바가 있든 없든 컨테이너 폭과 같습니다.
Flutter 는 RawScrollbar 가 오버레이로 칠합니다. Web 은 브라우저의 스크롤바를 숨기고(scrollbar-width: none
· ::-webkit-scrollbar { display: none }) thumb 을 <div> 로 직접 그립니다 — 브라우저 스크롤바는 CSS 만으로는 내용 위에 뜨게 할 방법이 없기 때문입니다. thumb 은 스크롤러 안의
position: sticky · 높이 0 레이어에 담겨 레이아웃에 자리를 만들지 않으면서, 내용이 스크롤되는 동안 보이는 영역의 모서리에 머뭅니다. thumb 의 길이와 위치는
CoreScrollbarThumbLayout 한 곳의 산식이고, Flutter ScrollbarPainter 가 같은 표(scrollbar_thumb_vectors.json)로 검증됩니다.
바의 영역을 누르면 뷰포트의 80% 만큼 그쪽으로 한 페이지 이동합니다(100ms 보간). thumb 위에서는 끌기가 시작됩니다. 터치는 트랙을 누르지 못하고 thumb 만 끌 수 있습니다.
표시 정책#
axis는 보고·표시할 스크롤 축을 선택합니다. visibility: always는 상시 표시, hidden은 thumb만 숨기고 스크롤과 위치 보고를 유지합니다.
auto/thin은 스크롤 후 600ms 동안 머물고 300ms에 걸쳐 사라지며, 바 위에 포인터가 있거나 thumb 을 잡고 있는 동안은 사라지지 않습니다.
interactive는 thumb 및 보조기술 조작 가능성을 정하며 콘텐츠 자체를 비활성화하지 않습니다. 위치 안내는 0–100% 단위이고 자체 콘텐츠 빌더나 선택 상태는 없습니다.
사용 가이드라인 (Usage Guidelines)#
Flutter의 기존 스크롤러에 바를 붙이거나 바의 정책을 직접 제어할 때 사용합니다. 콘텐츠·높이 제약·스크롤 엔진까지 한 번에 구성하려면 ScrollArea, 2축 오프셋 기반 렌더링에는 ScrollableClient를 사용합니다.
✅ Do#
Scrollbar 와 스크롤 뷰가 같은 ScrollController 를 공유
final controller = ScrollController();
Scrollbar(
controller: controller,
child: SingleChildScrollView(controller: controller, child: content),
)
Scrollbar 는 넘겨받은 컨트롤러를 내부 RawScrollbar 에 그대로 연결합니다 — 스크롤 뷰가 다른 컨트롤러를 쓰면 thumb 이 실제 스크롤 위치를 따라가지 못합니다.
❌ Don't#
interactive: false 로 스크롤 자체를 막으려 하지 않기
// ❌ 휠 · 터치 · 키보드는 그대로 스크롤된다
Scrollbar(interactive: false, child: content)
interactive 는 바를 조작하는 것 — thumb 을 끌거나 트랙을 눌러 페이지를 넘기는 것 — 만 막습니다. 내용은 여전히 스크롤됩니다. 양 플랫폼이 같습니다: thumb 을 직접 그리므로 포인터를 받지 않고 hover 모양도 생기지 않습니다. 보조기술에 대해서도 같은 말을 합니다(Flutter 는 increase/decrease 를 광고하지 않고, Web 은 aria-disabled).
접근성 (Accessibility)#
역할#
양 플랫폼 모두 스크롤 위치를 보고하는 팬텀 노드를 내보냅니다. 그려지는 thumb 과는
별개의 형제 노드이고, 픽셀을 전혀 차지하지 않습니다 — 붙일 자리가 없기 때문입니다. Web 의
thumb 은 장식용 <div>(aria-hidden)이고, 그것이 움직이는 스크롤러에 역할을 얹으면 안쪽
콘텐츠의 의미를 덮어쓰게 됩니다. 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 이 없는 그 모드야말로 위치를 말로 알려주는 것이 가장 필요한 자리입니다.
알려진 제약#
-
색 강제 모드(
forced-colors: active)에서는 Web 이 브라우저 스크롤바로 되돌아갑니다. 이 모드는<div>의background-color를 페이지 바탕색으로 다시 칠해 직접 그린 thumb 을 지우는 반면, 브라우저 스크롤바는 시스템 색으로 다시 칠해져 읽을 수 있습니다. 그래서 이 모드에서만 오버레이를 그리지 않고 네이티브 바를 그대로 두며, 그 바는 내용 옆에 폭을 차지합니다. 보이지 않는 바보다는 내용을 조금 미는 바가 낫다는 판단입니다. -
Web 의 thumb 길이와 위치는 브라우저가 잰 CSS px 로 계산됩니다. 내용이 커지거나 줄면 바뀐 크기를
관찰해 갱신하지만, 내용 크기가 그대로인데 안쪽 요소만 넘치는 드문 경우는 다음 스크롤에서 맞춰집니다.
thumb 의 두께와 여백은
rem이라 브라우저 글자 크기를 따르지만, 길이와 위치를 계산하는 여백 · 최소 길이는 px 그대로이므로 글자 크기가 기본(16px)이 아니면 그 차이만큼 아주 작게 어긋납니다. -
interactive: false는 Web 에서overscroll-behavior: contain도 함께 내보냅니다 — 안쪽 스크롤이 끝에 닿아도 조상으로 이어지지 않습니다. thumb 드래그와는 무관하고 Flutter 에 대응이 없는, 이 플래그의 부산물입니다. -
보조기술이 스크롤 위치를 직접 움직이는 경로가 Web 에서는 엔진에 달려 있습니다. Flutter 는
interactive: true일 때 increase / decrease 를 제공하지만, Web 의 대응물은 브라우저가 스크롤 루트를 키보드로 스크롤해 주는 것이고 그러려면 그 루트가 포커스를 받아야 합니다 — 위 "키보드" 참고. controller는 Flutter 전용이라 크로스 플랫폼 프로그래매틱 스크롤 경로가 없습니다.
reduced motion · 고대비 등 컴포넌트를 가로지르는 축은 전역 접근성 축에서 다룹니다.
크로스 플랫폼 차이점 (Platform Differences)#
| 항목 | Flutter | Web |
|---|---|---|
| 바 구현 | RawScrollbar overlay paint |
브라우저 스크롤바를 숨기고 <div> thumb 을 내용 위에 직접 그림 |
| 위치 제어 | ScrollController 또는 감싼 스크롤러의 notification |
native DOM scroll |
interactive: false 포인터 |
thumb 드래그 · 트랙 탭 차단 | 같음 — thumb 이 포인터를 받지 않음 |
| 위치 시맨틱 | slider-shaped Semantics와 증가/감소 액션 | 축별 role="scrollbar" 노드; 직접 액션 없음 |
| fade | 프레임워크 애니메이션 | CSS transition (opacity, 300ms) |
| 차지하는 공간 | 내용 위에 겹쳐 그림(오버레이) | 같음 — 높이 0 sticky 레이어에 담겨 내용 폭을 줄이지 않음. 단 색 강제 모드는 브라우저 스크롤바(폭 10, thin 은 7) |
| hover · 끌기 | thumb 이 200ms 에 걸쳐 진하고 굵어짐(동작 줄이기에서는 즉시). 포인터가 바 위에 있으면 시작 | 같음 — CSS transition 200ms, 포인터가 thumb 이나 바 위에 있으면 시작 |
| 트랙 탭 | 마우스로 누르면 뷰포트의 80% 를 100ms 에 걸쳐 이동 | 같음 (터치는 트랙을 누르지 못함) |
가로 rtl |
세로 바가 왼쪽 | 같음 — 논리 속성(inset-inline-end)이라 브라우저가 뒤집음 |