Scrollbar | CoUI
LogoCoUI

Scrollbar

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

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: 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 필드#

필드타입설명
thumbColorCoreColor?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 를 붙이지 않습니다. 안쪽에 포커스 가능한 요소가 없는 영역을 키보드로 확실히 스크롤시키려면 소비자가 attributestabindex="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 · 고대비 등 컴포넌트를 가로지르는 축은 전역 접근성 축에서 다룹니다.