Display

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.

Preview in your theme

Loading preview…

import * as React from "react"
import { cn } from "@/lib/utils"

/**
 * `pill`   — 10px gutter, invisible track, floating rounded thumb (needs pseudo-elements).
 * `track`  — classic groove: painted track + inset thumb (needs pseudo-elements).
 * `ghost`  — gutter is always reserved, the thumb only paints on hover/focus (needs pseudo-elements).
 * `thin`   — the portable one: standard `scrollbar-width` + `scrollbar-color`, two flat colors.
 * `brand`  — `pill` tinted with `--primary` (needs pseudo-elements).
 * `hidden` — no bar at all; wheel, drag, keys and programmatic scrolling all keep working.
 */
export const SCROLLBAR_PRESETS = ["pill", "track", "ghost", "thin", "brand", "hidden"] as const

export type ScrollbarPreset = (typeof SCROLLBAR_PRESETS)[number]

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/scrollbar-styles.json

Prompt

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 @supports query 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-auto scrollbar-width or scrollbar-color deletes the whole ::-webkit-scrollbar cascade 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 :hover that changes nothing else on the element, but they do follow an inherited custom property; driving the reveal through --scrollbar-thumb-shown is 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 disabledscrollbar-width: none removes 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 html or body, but the standard properties only propagate from html, so that is where the attribute belongs.

On This Page