Custom Scrollbar
@themvora/core
Replace the browser's default scrollbar with a theme-aware overlay that matches your design system. Works on every framework — zero layout shift, no flash of native chrome. Import the scrollbar CSS once, then mountThemeScrollbar anywhere in your app.
1Pick your framework
2Quick start
import { ThemeProvider, ThemeScrollbar } from "@themvora/react";
import { ArrowUp, ArrowDown } from "lucide-react";
export default function Layout({ children }) {
return (
<ThemeProvider themes={themes} defaultTheme="light">
<ThemeScrollbar
behavior={{ autoHide: true }}
appearance={{ thickness: 8, radius: 999 }}
icons={{ up: <ArrowUp />, down: <ArrowDown /> }}
/>
{children}
</ThemeProvider>
);
}@import "@themvora/core/scrollbar.css"; to your global stylesheet (globals.css, app.css, or style.css). This brings in the pre-paint native-bar hiding and the overlay strip base styles.@import "@themvora/core/scrollbar.css";3Recommended setup
Place ThemeScrollbar in the root layout
Mount ThemeScrollbar once in your root layout — not inside individual pages or components. The engine discovers every scrollable container on the page (including ones added later by client-side navigation), so a single mount covers the entire app.
Import the scrollbar CSS in your global stylesheet
Add the import to your global CSS file so the pre-paint native-bar hiding and overlay strip base styles are available before the first paint. This prevents a flash of native scrollbars on initial load.
Recommended configuration
autoHide — fades the strip after idle. Set to false if you want scrollbars always visible (e.g. on kiosk or accessibility-first setups).
autoHideDelay — idle time in ms before a revealed strip fades out. Default 900. Only takes effect when autoHide is true.
thickness — resting thumb size. Increase for touch-friendly targets.
radius — set to 999 for a pill-shaped thumb that matches most design systems.
arrows — show arrow buttons at the ends of the track. Default true.
hoverExpand — grow the thumb on hover for easier targeting. Default false.
How it integrates with your theme
The thumb is the only surface that takes a theme color, and you never set it. On mount the engine tries primary, then accent, then foreground, keeps the first that contrasts with background, and writes it to --tv-scrollbar-thumb; hover and active are derived from it. The track is transparent and the arrows inherit currentColor, so switching themes only ever moves the thumb — no re-mount, and nothing else to configure.
Best practices
- Scope the overlay with
include/exclude— CSS selectors for inner scrollables; the document is always tracked. The framework components group the same pair underscope, so there it isscope={{ include: [".panel"] }}rather than a top-level prop. - Pass custom arrow icons via the
iconsgroup for framework-owned rendering (JSX, VNodes, etc.).
Lifecycle and reactivity
The overlay owns the native bar for exactly as long as it is mounted, and every option is live.
- Every prop is reactive. Changing one — on the component, or by setting the matching attribute on
themvora-scrollbardirectly — rebuilds the overlay in place. The replacement is created before the incumbent is destroyed, so the page never passes through a frame with the native bar back and nothing drawing. - Unmounting restores the native scrollbar. The overlay, its observers and its DOM nodes go away with the component, and so does the native-bar hiding — the platform scrollbar returns. Removing the element is therefore a supported way to turn the feature off, not a way to break the page.
- The SSR pre-paint hiding is provisional. A server-rendered
tv-scrollbarclass only hides the native bar; the overlay is what replaces it. If a page enables pre-paint hiding but renders no overlay element — the component was omitted, or commented out — the class and the hiding stylesheet are removed at the end of<body>and the native scrollbar comes back. A mismatch degrades to a native scrollbar, never to no scrollbar at all.
4Zero-flash (SSR)
scrollbar: true to themvoraVitePlugin and it injects the blocking pre-paint script for you; otherwise add the script yourself (see Framework-agnostic) or serve the page from an SSR framework (Next.js / Nuxt / Remix).tv-scrollbar class during SSR (no blocking script, no hydration mismatch).ThemeScrollbar (or calling createOverlayScrollbar) automatically applies the tv-scrollbar class + hiding CSS if none is present. The engine is self-sufficient; you only need the pre-paint script when you want to prevent the initial flash on a server-rendered page.5Options — grouped props API
<ThemeScrollbar
behavior={{
autoHide: true,
autoHideDelay: 900,
hoverExpand: false,
draggable: true,
clickToJump: true,
smooth: false,
overscroll: true,
axes: ["x", "y"],
touch: false,
dir: "ltr",
}}
appearance={{
arrows: true,
thickness: 8,
hoverThickness: 12,
radius: 999,
minThumbSize: 40,
offset: 4,
trackOpacity: 0.2,
thumbOpacity: 0.7,
zIndex: 9999,
duration: 180,
animationDuration: 180,
thumbColor: "#ff6b6b",
trackColor: "#2d2d2d",
activeThumbColor: "#ff4444",
thumbHoverColor: "#ff8888",
}}
scope={{ include: [".panel"], exclude: [".no-scroll"] }}
icons={{
arrow: <ScrollIcon />,
up: <ArrowUp />,
down: <ArrowDown />,
left: <ArrowLeft />,
right: <ArrowRight />,
size: 12,
}}
/>:prop; Svelte uses snippet attributes; Angular binds[themvoraScrollbarOptions]; Web Components use individual attributes; Vanilla JS uses a single options object oncreateThemeScrollbar.6Thumb appearance
All color options accept any valid CSS color string (hex, rgb, hsl, named colors, CSS variables, etc.).
thumbColor— base thumb color. Default: theme-derived.trackColor— track background color. Default: transparent (theme-derived wash).thumbHoverColor— thumb color on hover. Default: same asthumbColor.activeThumbColor— thumb color while dragging. Default: same asthumbColor.
You can also theme via CSS custom properties — this is useful for dark/light mode pairs or global design tokens:
:root {
--tv-scrollbar-thumb: #ff6b6b;
--tv-scrollbar-track: #2d2d2d;
--tv-scrollbar-thumb-hover: #ff8888;
--tv-scrollbar-thumb-active: #ff4444;
}
/* Dark mode override */
.dark {
--tv-scrollbar-thumb: #7c3aed;
--tv-scrollbar-track: #1e1e1e;
--tv-scrollbar-thumb-hover: #a855f7;
--tv-scrollbar-thumb-active: #d946ef;
}The CSS variables are defined on the overlay host element ([data-themvora-host]) with theme-derived defaults, so the scrollbar automatically follows your active theme. Custom values override the defaults.
7Arrow icons
::before pseudo-elements. Pass icons (or flat arrowUpIcon / arrowDownIcon) to replace them with your own SVG / HTML.icons.size sets the size of all four glyphs in CSS px — clamped to the arrow button's usable area (floored at 8px while space allows), so a large value can never overflow a thin scrollbar.import { ThemeScrollbar } from "@themvora/react";
import { ChevronUp, ChevronDown } from "lucide-react";
// Pass any ReactNode as arrow icons via the icons group
<ThemeScrollbar
icons={{
up: <ChevronUp />,
down: <ChevronDown />,
}}
/>8Container scrollbars
import { ThemeScrollbar } from "@themvora/react";
// Scrollbar scoped to a specific container
<ThemeScrollbar
scope={{ include: [".panel"] }}
/>
<div className="panel" style={{ overflow: "auto", height: 400 }}>
<LongContent />
</div>9Framework-agnostic (vanilla JS)
Programmatic mount
Create a theme store and pass it to createThemeScrollbar (alias for createOverlayScrollbar). Returns an OverlayScrollbarHandle or null on SSR/coarse-pointer devices.
import {
createThemeRuntime,
createThemeScrollbar,
} from "@themvora/core";
import "@themvora/core/scrollbar.css";
const runtime = createThemeRuntime({
defaultTheme: "light",
});
// One call mounts the overlay and discovers every scrollable container
// on the page. Returns an OverlayScrollbarHandle (or null on SSR /
// coarse-pointer devices).
const handle = createThemeScrollbar(runtime.store, {
autoHide: true,
arrows: true,
thickness: 8,
radius: 999,
});
// The strips recolor automatically when the theme changes.
runtime.selection.setMode("dark");Hide the native bar before first paint
Framework providers bootstrap this for you. In vanilla settings, drop a blocking pre-paint script into <head> so the native scrollbar is never painted (it runs in under 1 ms and skips coarse-pointer devices unless you pass { touch: true }).
import { createPrePaintScrollbarScript } from "@themvora/core";
// Called once in your server render or build step. The generated
// <script> is blocking, runs in <1ms, and is idempotent.
const head = [
"<!doctype html>",
"<html>",
" <head>",
` <script>${createPrePaintScrollbarScript()}</script>`,
' <link rel="stylesheet" href="/scrollbar.css" />',
" </head>",
"</html>",
].join("\n");If you're already server-rendering markup, you can skip the script and inline createPrePaintScrollbarCSS() as a <style> alongside a tv-scrollbar class on <html> — no blocking script, no hydration mismatch.
import { createPrePaintScrollbarCSS } from "@themvora/core";
// In your server-rendered <head>: hidden from the very first paint,
// no blocking <script>, and no hydration mismatches.
export function Head() {
return (
<head>
<html lang="en" className="tv-scrollbar" />
<style dangerouslySetInnerHTML={{ __html: createPrePaintScrollbarCSS() }} />
</head>
);
}