Custom Scrollbar
@theme-kit/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
1import { ThemeProvider, ThemeScrollbar } from "@theme-kit/react";
2import { ArrowUp, ArrowDown } from "lucide-react";
3
4export default function Layout({ children }) {
5 return (
6 <ThemeProvider themes={themes} defaultTheme="light">
7 <ThemeScrollbar
8 behavior={{ autoHide: true }}
9 appearance={{ thickness: 8, radius: 999 }}
10 icons={{ up: <ArrowUp />, down: <ArrowDown /> }}
11 />
12 {children}
13 </ThemeProvider>
14 );
15}@import "@theme-kit/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 "@theme-kit/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 overlay strips inherit your theme's colors automatically via CSS custom properties. No extra configuration is needed — the thumb, track, and arrows all pick up --theme-color-* tokens from the active theme. When you switch themes, the scrollbar updates instantly without re-mounting.
Best practices
- Don't manually hide native scrollbars with
overflow: hiddenor::-webkit-scrollbarCSS — the bootstrap handles this automatically via thetk-scrollbarclass. - Use
includeto scope the overlay to specific containers instead of the window when you only want custom scrollbars in certain areas. - Use
appearance.includeandappearance.excludefor per-scrollbar configuration. - Pass custom arrow icons via the
iconsgroup for framework-owned rendering (JSX, VNodes, etc.).
4Zero flash (SSR)
Framework-agnostic) or serve the page from an SSR framework (Next.js / Nuxt).tk-scrollbar class during SSR (no blocking script, no hydration mismatch).5Options — grouped props API
1<ThemeScrollbar
2 behavior={{
3 autoHide: true,
4 autoHideDelay: 900,
5 hoverExpand: false,
6 draggable: true,
7 clickToJump: true,
8 smooth: false,
9 overscroll: true,
10 axes: ["x", "y"],
11 touch: false,
12 dir: "ltr",
13 }}
14 appearance={{
15 arrows: true,
16 thickness: 8,
17 hoverThickness: 12,
18 radius: 999,
19 minThumbSize: 40,
20 offset: 2,
21 trackOpacity: 0.2,
22 thumbOpacity: 0.7,
23 zIndex: 9999,
24 duration: 180,
25 animationDuration: 180,
26 include: [".panel"],
27 exclude: [".no-scroll"],
28 thumbColor: "#ff6b6b",
29 trackColor: "#2d2d2d",
30 activeThumbColor: "#ff4444",
31 thumbHoverColor: "#ff8888",
32 }}
33 icons={{
34 arrow: <ScrollIcon />,
35 up: <ArrowUp />,
36 down: <ArrowDown />,
37 left: <ArrowLeft />,
38 right: <ArrowRight />,
39 }}
40/>:prop; Svelte uses snippet attributes; Angular binds[themeKitScrollbarOptions]; 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:
1:root {
2 --tk-scrollbar-thumb: #ff6b6b;
3 --tk-scrollbar-track: #2d2d2d;
4 --tk-scrollbar-thumb-hover: #ff8888;
5 --tk-scrollbar-thumb-active: #ff4444;
6}
7
8/* Dark mode override */
9.dark {
10 --tk-scrollbar-thumb: #7c3aed;
11 --tk-scrollbar-track: #1e1e1e;
12 --tk-scrollbar-thumb-hover: #a855f7;
13 --tk-scrollbar-thumb-active: #d946ef;
14}The CSS variables are defined on the overlay host element ([data-theme-kit-host]) with theme-derived defaults, so the scrollbar automatically follows your active theme. Custom values override the defaults.
7Arrow icons
1import { ThemeScrollbar } from "@theme-kit/react";
2import { ChevronUp, ChevronDown } from "lucide-react";
3
4// Pass any ReactNode as arrow icons via the icons group
5<ThemeScrollbar
6 icons={{
7 up: <ChevronUp />,
8 down: <ChevronDown />,
9 }}
10/>8Container scrollbars
1import { ThemeScrollbar } from "@theme-kit/react";
2
3// Scrollbar scoped to a specific container
4<ThemeScrollbar
5 appearance={{ include: [".panel"] }}
6/>
7
8<div className="panel" style={{ overflow: "auto", height: 400 }}>
9 <LongContent />
10</div>9Framework-agnostic (vanilla JS)
Programmatic mount
Create a theme store and pass it — plus the same options you'd give ThemeScrollbar — to createThemeScrollbar. It returns an OverlayScrollbarHandle (or null when running on the server or on a coarse-pointer device where native bars are kept). createThemeScrollbar is the public alias for createOverlayScrollbar — use whichever reads best in your codebase.
1import {
2 createThemeRuntime,
3 createThemeScrollbar,
4} from "@theme-kit/core";
5import "@theme-kit/core/scrollbar.css";
6import themes from "./themes";
7
8const runtime = createThemeRuntime({
9 themes,
10 defaultTheme: "light",
11});
12
13// One call mounts the overlay and discovers every scrollable container
14// on the page. Returns an OverlayScrollbarHandle (or null on SSR /
15// coarse-pointer devices).
16const handle = createThemeScrollbar(runtime.store, {
17 autoHide: true,
18 arrows: true,
19 thickness: 8,
20 radius: 999,
21});
22
23// The strips recolor automatically when the theme changes.
24runtime.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 }).
1import { createPrePaintScrollbarScript } from "@theme-kit/core";
2
3// Called once in your server render or build step. The generated
4// <script> is blocking, runs in <1ms, and is idempotent.
5const head = [
6 "<!doctype html>",
7 "<html>",
8 " <head>",
9 ` <script>${createPrePaintScrollbarScript()}</script>`,
10 ' <link rel="stylesheet" href="/scrollbar.css" />',
11 " </head>",
12 "</html>",
13 ].join("\n");If you're already server-rendering markup, you can skip the script and inline createPrePaintScrollbarCSS() as a <style> alongside a tk-scrollbar class on <html> — no blocking script, no hydration mismatch.
1import { createPrePaintScrollbarCSS } from "@theme-kit/core";
2
3// In your server-rendered <head>: hidden from the very first paint,
4// no blocking <script>, and no hydration mismatches.
5export function Head() {
6 return (
7 <head>
8 <html lang="en" className="tk-scrollbar" />
9 <style dangerouslySetInnerHTML={{ __html: createPrePaintScrollbarCSS() }} />
10 </head>
11 );
12}10How it works
1/* Phase 1 — Bootstrap (before first paint):
2 The @theme-kit/next ThemeProvider (or the bootstrap
3 script in vanilla JS) adds the `tk-scrollbar` class
4 to <html> and inlines this
5 <style data-theme-kit-pre-paint="scrollbar"> in
6 <head>. This hides native scrollbars immediately —
7 no flash:
8
9 html.tk-scrollbar,
10 html.tk-scrollbar * {
11 scrollbar-width: none;
12 -ms-overflow-style: none;
13 }
14 html.tk-scrollbar *::-webkit-scrollbar {
15 width: 0; height: 0;
16 }
17
18 Phase 2 — the engine: every managed container also gets
19 data-theme-kit-scrollbar="overlay"
20 so its native track is hidden while the custom overlay
21 strips (position: fixed, marked data-theme-kit-host)
22 are drawn over it. The browser still performs all
23 scrolling; the overlay only represents it. */