Scrollbar Styles
Six presets that re-skin the browser's own scrollbar in pure CSS — no JavaScript, no drawn thumb, and one capability-tiered sheet that keeps the webkit pseudo-elements and the standard properties from cancelling each other out.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/scrollbar-styles.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "ScrollbarStyles" component that restyles
the browser's own scrollbar with nothing but CSS — no JavaScript at runtime, no
drawn thumb, no wrapper element. The deliverable is really a stylesheet; the
React parts are a thin way to ship and mount it.
Pick this over a drawn-scrollbar component (a ScrollArea that hides the native
bar and renders its own thumb from scrollTop/scrollHeight) when: you only want a
different-looking bar; you need to restyle the page/html scrollbar, which no
React component owns; you can't afford a measurement loop per panel; or the
platform's own bar behaviour (overlay fade on macOS/iOS, drag, click-to-page,
autoscroll) is exactly what you want to keep. Pick the drawn one instead when
you need a bar that truly overlays without reserving width on every platform, an
identical look everywhere including Firefox, scripted show/hide timing,
edge-fade masks, or an onScrollEnd callback.
Contract
- export const SCROLLBAR_PRESETS = ["pill","track","ghost","thin","brand",
"hidden"] as const; export type ScrollbarPreset = typeof
SCROLLBAR_PRESETS[number].
- export const scrollbarStylesCss: string — the entire stylesheet, every rule
keyed on [data-scrollbar="<preset>"] so the same sheet styles a div you render
and the page bar alike. Consumers may skip the components and paste it into
their global stylesheet.
- export function ScrollbarStyleSheet() — renders
<style href="…" precedence="default">{scrollbarStylesCss}</style>. React 19
hoists that into <head> and dedupes it by href, so N containers still ship one
<style> (verified: 9 instances on a page → 1 tag in head). On React 18 it
renders inline and duplicates; harmless, but then prefer pasting the CSS into
a global stylesheet instead.
- export const ScrollbarStyles = forwardRef<HTMLDivElement, ScrollbarStylesProps>
where ScrollbarStylesProps extends React.HTMLAttributes<HTMLDivElement> with
preset?: ScrollbarPreset (default "pill") and axis?: "y" | "x" | "both"
(default "y"). It renders the sheet plus exactly one div: the scroller itself,
carrying data-scrollbar={preset}, the axis overflow classes
(overflow-y-auto overflow-x-hidden / overflow-x-auto overflow-y-hidden /
overflow-auto), cn()-merged className and the spread rest props. No wrapper,
no state, no effects, no listeners.
- No "use client": nothing in it touches hooks, events or browser APIs.
Behavior — the one rule the whole file is shaped around
- Chromium >=121 ignores every ::-webkit-scrollbar rule on an element as soon as
that element also has scrollbar-width OR scrollbar-color set to anything but
auto. Measured in Edge 150 on macOS, one preset, one added line:
pseudo rules alone -> 10px gutter, thumb rgb(198,198,198), rounded
+ scrollbar-width: thin -> 11px gutter, thumb rgb(120,120,120) on a
rgb(250,250,250) track (the plain platform
bar; every custom rule gone)
+ scrollbar-color: <same> -> 15px gutter, 7px square thumb, no inset, no
radius (standard-property painting; the
pseudo geometry is gone even though the
colour happens to match)
+ scrollbar-width: auto -> identical to pseudo rules alone
So "write both to be safe" silently deletes the half you worked on, and only
on Blink — Firefox keeps showing the standard-property version, which is what
makes the mistake so easy to ship. Never emit both unguarded for one preset.
- Capability tiering. Each preset picks a tier and declares the other one behind
an @supports query that is false wherever the first tier already works:
pseudo tier -> @supports not selector(::-webkit-scrollbar) { standard … }
true only in Firefox (Blink and WebKit both parse that
selector, so the block never applies there).
standard tier -> @supports not (scrollbar-width: thin) { pseudo … }
true only where the standard properties don't exist:
Safari <18.2 and Chromium <121.
The two tiers can then never both apply to the same element in the same
engine, by construction rather than by hope.
- hidden is the single preset allowed to write both (scrollbar-width: none plus
::-webkit-scrollbar { display: none }): the two mechanisms agree on the
outcome, so whichever wins paints the same nothing.
- Presets. All four knobs are custom properties declared on [data-scrollbar]:
--scrollbar-size 10px, --scrollbar-radius 999px, --scrollbar-inset 3px,
--scrollbar-track transparent, --scrollbar-thumb, --scrollbar-thumb-hover.
pill (pseudo) 10px gutter, transparent track, thumb painted with
background-clip: content-box behind a transparent border of
--scrollbar-inset, so a 4px pill floats inside a 10px gutter;
::-webkit-scrollbar-thumb:hover darkens it.
track (pseudo) 12px, track painted with --muted, 2px inset — the bar reads
as a control rather than an overlay.
ghost (pseudo) same geometry as pill, thumb transparent until the container
is hovered or holds focus.
thin (standard) scrollbar-width: thin + scrollbar-color: thumb track. Two
flat colours is the entire API — no radius, no inset, no hover state.
This is the portable preset, and what Firefox effectively gets for
every other preset too.
brand (pseudo) pill tinted with --primary.
hidden no bar; wheel, drag, keys and programmatic scrolling all still work.
- ghost's reveal must be driven by flipping an inherited custom property, not by
a state selector on the pseudo-element. Measured in Edge 150: a rule of the
form .x:hover::-webkit-scrollbar-thumb { background: … } computes correctly
and never repaints — with :hover confirmed true on the element the thumb
stayed transparent — and the identical rule starts working the moment anything
else about the element also changes on hover. Flipping a custom property
(.x { --shown: transparent } .x:hover { --shown: var(--thumb) }, with the
thumb using var(--shown)) invalidates the scrollbar with it and paints every
time. The thumb's own :hover (::-webkit-scrollbar-thumb:hover) does repaint
reliably, which is why the other presets use that form for their hover state.
- Layout is a real side effect, not a cosmetic one: the preset decides how much
width the bar reserves. Measured on the same 200px box in Edge 150/macOS
(classic, space-taking bars): unstyled 15px, pill/ghost/brand 10px, track
12px, thin 11px, hidden 0px. On platforms that draw overlay bars (macOS set to
"show scroll bars when scrolling", iOS, Android) giving ::-webkit-scrollbar an
explicit width is documented to turn the bar into a space-taking one — that
conversion could NOT be reproduced on the test machine, whose Edge already
used classic bars, so treat it as unverified and check it on a trackpad-only
Mac before shipping a width-critical layout.
- Page-level use: put the attribute on the root element — <html
data-scrollbar="pill"> in your layout — and render ScrollbarStyleSheet once.
Measured asymmetry: pseudo-tier rules reach the viewport bar from either html
or body (both gave a 12px painted track), but the standard properties only
propagate from html — body[data-scrollbar="hidden"] left the page bar at its
full 15px while html[data-scrollbar="hidden"] removed it. Use html.
- hidden disables nothing: measured scrollTop 0 -> 150 on a wheel of 150, -> the
maximum on PageDown while focused, -> the maximum when assigned directly;
page-level, scrollY 0 -> 400 on wheel and -> 3400 (the maximum) on End. What
it does remove is the only cue a mouse user has that the region scrolls, so
pay that back — an edge fade (mask-image), arrow buttons, a peeking next item.
Don't use it where scrollability isn't obvious from the content itself.
- A page-level scrollbar-gutter: stable does not leave an empty strip behind a
hidden bar: stable reserves the width the bar would have, which is 0 once it
is none. Measured 0px of gutter on a page keeping that declaration, with and
without a reset. No reset needed.
- forced-colors: everything except hidden hands the bar back to the OS
(scrollbar-color: auto, and background-color/border-color: revert on the
pseudo-elements). Verified under emulated forced colors: pill and track showed
the system black-on-white bar, thin fell back to the platform bar. hidden
stays hidden on purpose — un-hiding it would re-narrow a carousel that was
built around a bar-less box; drop it from the :not() if you'd rather it come
back.
- Motion: the sheet contains no transition and no animation at all, so there is
nothing for prefers-reduced-motion to switch off and hover states are instant.
(Chromium applies its own subtle thumb darkening to scrollbar-color; that is
the UA's, not ours.)
Rendering & styling
- Semantic tokens only, no hex/rgb/oklch: thumbs are
color-mix(in oklab, var(--muted-foreground) 40%/55%, transparent) with a 70%
hover, the track uses var(--muted), brand uses var(--primary). Both themes
come out of the same declarations — measured thumb rgb(198,198,198) light /
rgb(70,70,70) dark for pill, rgb(128,128,128) / rgb(131,131,131) for brand.
- Custom properties do reach the ::-webkit-scrollbar pseudo-elements and stay
overridable per element: setting --scrollbar-size: 14px and a --scrollbar-thumb
of var(--destructive) inline produced a 14px gutter with a rgb(230,115,115)
thumb out of the same sheet.
- Accessibility: the component changes nothing about focus or semantics — it is
the same overflow container you would have written. A scroll container is not
focusable by default (measured tabIndex -1); add tabIndex={0} plus a name if
you need guaranteed keyboard scrolling, exactly as you would without this
component.
Customization levers
- The four knobs are the intended edit surface: --scrollbar-size (6–8px reads as
a hairline, 14–16px as a desktop bar), --scrollbar-inset (0 fills the gutter,
3–4px floats a pill inside it), --scrollbar-radius (999px pill, var(--radius)
to tie it to the theme, 0 for a square industrial bar), plus --scrollbar-track
/ --scrollbar-thumb / --scrollbar-thumb-hover. Set them inline on one
container or in a scoped rule for a whole section.
- Each preset is a self-contained block: delete the ones you don't ship and the
sheet shrinks to what you use. Adding a preset means copying one block and
keeping its @supports guard — that guard is the invariant, not the colours.
- Tint by token: swap --muted-foreground for --primary / --accent /
--destructive in the thumb mixes; raise the mix percentage for a heavier bar
(40% is quiet, 55–70% is assertive).
- Reach for the standard tier deliberately: if Firefox parity matters more than
rounded corners, make thin your default preset — it is the only one that looks
the same in all three engines.
- ghost's reveal trigger is one selector list; add :active or drop
:focus-within, but keep the custom-property indirection or it stops painting.Concepts
- Capability tiering — a preset declares which of the two scrollbar APIs it needs and puts the other one behind an
@supportsquery that is false wherever the first already works, so the two can never both apply and never fight. - Mutual exclusion — in Chromium 121+ a non-
autoscrollbar-widthorscrollbar-colordeletes the whole::-webkit-scrollbarcascade for that element; the failure is invisible on Firefox, which is what makes "write both to be safe" such a reliable way to ship a broken bar. - Expressiveness gap — the standard properties can say thin and two colours and nothing else: no radius, no padding, no hover state. A rounded floating pill is a pseudo-element feature, so on Firefox the same preset honestly degrades to a flat two-colour bar instead of pretending.
- Custom-property repaint — scrollbar pseudo-elements don't repaint for a
:hoverthat changes nothing else on the element, but they do follow an inherited custom property; driving the reveal through--scrollbar-thumb-shownis what makes the ghost preset actually paint. - Gutter is layout — a bar's width is reserved out of the content box, so choosing a preset changes how wide the content is (measured 15px unstyled → 10px pill → 0px hidden on the same box); it's a layout decision wearing a cosmetic costume.
- Hidden is not disabled —
scrollbar-width: noneremoves the bar and nothing else: wheel, drag, keyboard and programmatic scrolling all still work, which is exactly why the missing cue has to be paid back with an edge fade or arrows. - Root propagation — the page's bar is styled from the root element: pseudo-element rules reach it from
htmlorbody, but the standard properties only propagate fromhtml, so that is where the attribute belongs.
Keyboard Map
A keyboard layout diagram that paints your app's bound shortcuts onto the keys — platform-correct glyphs and modifier order, whole-chord highlighting, and a readable binding list.
Feature Flag List
A per-environment feature flag table — production toggles gated behind a confirmation that repeats the flag key, rollout percentage kept orthogonal to on/off, optimistic switches that roll back one at a time, and expandable targeting rules.