Skip to content

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

ThemeScrollbar is not a demo — it is the overlay engine, mounted once in your app. Pick your framework below and every snippet on this page updates to match.

2Quick start

Import the scrollbar CSS, then mount ThemeScrollbar next to your app.
tsx
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}
CSS import required|Add @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.
css
@import "@theme-kit/core/scrollbar.css";

4Zero flash (SSR)

Hide the native bar before the first paint — only shown for frameworks that provide an SSR pre-paint hook.
This framework doesn't provide SSR zero-flash|It renders client-side, so there's no server pre-paint hook. Hide the native bar before first paint with the blocking pre-paint script (see Framework-agnostic) or serve the page from an SSR framework (Next.js / Nuxt).
Why this matters|Without pre-paint hiding, the browser paints the native scrollbar for a frame before the overlay engine mounts — a visible flash. The supported frameworks inline a tiny CSS block + the tk-scrollbar class during SSR (no blocking script, no hydration mismatch).

5Options — grouped props API

Every option is available as a flat top-level prop (flat wins over grouped) or organized into behavior, appearance, and icons groups. Pick your framework for the exact prop shape.
tsx
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/>
Per-framework binding|JSX frameworks (React/Next/Solid/Remix) use object props; Vue/Nuxt use :prop; Svelte uses snippet attributes; Angular binds[themeKitScrollbarOptions]; Web Components use individual attributes; Vanilla JS uses a single options object oncreateThemeScrollbar.

6Thumb appearance

Customize the scrollbar colors beyond what the theme provides. By default, colors derive from the active theme (primary → accent → foreground). Set these to override with any CSS color.

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 as thumbColor.
  • activeThumbColor — thumb color while dragging. Default: same as thumbColor.

You can also theme via CSS custom properties — this is useful for dark/light mode pairs or global design tokens:

css
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

Pass any ReactNode (Vue VNode, Svelte snippet, etc.) as arrow icons via the icons group.
tsx
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

Use appearance.include to scope the overlay to a specific scrollable container instead of the window.
tsx
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)

The overlay engine lives in @theme-kit/core, so you can mount it without React, Vue, Svelte, or any framework — with an identical option surface.

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.

ts
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 }).

ts
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.

ts
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

The overlay never replaces native scrolling. It renders theme-colored strips that represent the scrollbar, while the browser handles the actual scroll physics.
css
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. */

11What's next

Now that you have a custom scrollbar, theme it with your own tokens or explore the full API.
Custom Scrollbar — Theme Kit