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

Scrollbar

스크롤 영역에 테마된 스크롤바를 입히는 래퍼

목차

스크롤 가능한 child 를 감싸 테마된 스크롤바를 입히는 래퍼입니다. Flutter / Web 동일 API 로 thumb 색·두께·반경을 scrollbarStyle 단일 슬롯에서 제어합니다.

Live Preview#

사용법#

// 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를 뜻하는 합성 슬롯 타입입니다.

파라미터타입기본값설명
childW필수스크롤 가능한 콘텐츠
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)#

항목FlutterWeb
바 구현 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)이라 브라우저가 뒤집음