Web InnoventixFreeCode

Thin Scrollbar

Original · free

thin custom scrollbar demo

byWeb InnoventixReact + Tailwind
scbthinscrollbars
Open

Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.

npx shadcn@latest add https://webinnoventix.com/r/scb-thin.json
scb-thin.tsx
"use client";

import type { CSSProperties, KeyboardEvent as ReactKeyboardEvent } from "react";
import { useCallback, useMemo, useRef, useState } from "react";
import { motion, useReducedMotion } from "motion/react";

type Thickness = "hairline" | "thin" | "comfortable";
type Accent = "indigo" | "emerald" | "rose" | "amber";

type ReleaseEntry = {
  version: string;
  date: string;
  tag: "feature" | "fix" | "perf";
  title: string;
  points: string[];
};

const THICKNESS_OPTIONS: { id: Thickness; label: string; hint: string }[] = [
  { id: "hairline", label: "Hairline", hint: "4px" },
  { id: "thin", label: "Thin", hint: "8px" },
  { id: "comfortable", label: "Comfortable", hint: "12px" },
];

const ACCENT_OPTIONS: { id: Accent; label: string; swatch: string }[] = [
  { id: "indigo", label: "Indigo", swatch: "bg-indigo-500" },
  { id: "emerald", label: "Emerald", swatch: "bg-emerald-500" },
  { id: "rose", label: "Rose", swatch: "bg-rose-500" },
  { id: "amber", label: "Amber", swatch: "bg-amber-500" },
];

const RELEASES: ReleaseEntry[] = [
  {
    version: "4.2.0",
    date: "Jul 14, 2026",
    tag: "feature",
    title: "Query snapshots land in the timeline",
    points: [
      "Pin any dashboard state and diff two snapshots side by side.",
      "Snapshots are addressable by URL, so a link always reopens the exact view.",
      "Added a keyboard palette (press /) to jump between saved snapshots.",
    ],
  },
  {
    version: "4.1.3",
    date: "Jul 2, 2026",
    tag: "fix",
    title: "Timezone drift on scheduled exports",
    points: [
      "Exports scheduled across a DST boundary no longer shift by one hour.",
      "The scheduler now stores an IANA zone instead of a fixed UTC offset.",
    ],
  },
  {
    version: "4.1.0",
    date: "Jun 20, 2026",
    tag: "perf",
    title: "Streaming charts render 40% faster",
    points: [
      "Point decimation moved off the main thread into a worker.",
      "First paint for a 1M-row series dropped from 1.8s to 1.1s on mid-tier laptops.",
      "Memory ceiling for live series is now capped and configurable per board.",
    ],
  },
  {
    version: "4.0.2",
    date: "Jun 9, 2026",
    tag: "fix",
    title: "Sticky filters lost on refresh",
    points: [
      "Filter state now persists to the URL and survives a hard reload.",
      "Cleared a race where two filters applied in the same tick would cancel out.",
    ],
  },
  {
    version: "4.0.0",
    date: "May 28, 2026",
    tag: "feature",
    title: "Boards become collaborative",
    points: [
      "Live cursors and presence for everyone viewing the same board.",
      "Comment threads anchor to a specific chart region, not the whole panel.",
      "Role-based edit locks prevent two people overwriting the same query.",
    ],
  },
  {
    version: "3.9.1",
    date: "May 15, 2026",
    tag: "perf",
    title: "Cold-start latency on serverless queries",
    points: [
      "Connection pools warm on the first keystroke instead of on submit.",
      "Median cold query time fell from 640ms to 210ms.",
    ],
  },
  {
    version: "3.9.0",
    date: "May 3, 2026",
    tag: "feature",
    title: "Formula fields with autocomplete",
    points: [
      "Type-ahead now suggests columns, functions, and prior formulas.",
      "Errors surface inline with the exact token that failed to parse.",
    ],
  },
];

const SHORTCUTS: { keys: string; action: string }[] = [
  { keys: "/", action: "Open palette" },
  { keys: "G then B", action: "Go to boards" },
  { keys: "⌘ K", action: "Search everything" },
  { keys: "⇧ ?", action: "Show shortcuts" },
  { keys: "⌘ ⏎", action: "Run query" },
  { keys: "E", action: "Edit panel" },
  { keys: "⌥ D", action: "Duplicate view" },
  { keys: "Esc", action: "Dismiss overlay" },
];

const TAG_STYLES: Record<ReleaseEntry["tag"], string> = {
  feature:
    "bg-indigo-100 text-indigo-700 dark:bg-indigo-500/15 dark:text-indigo-300",
  fix: "bg-sky-100 text-sky-700 dark:bg-sky-500/15 dark:text-sky-300",
  perf: "bg-emerald-100 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300",
};

function moveWithinGroup(
  event: ReactKeyboardEvent,
  currentIndex: number,
  length: number,
  onSelect: (index: number) => void,
): void {
  let next = currentIndex;
  if (event.key === "ArrowRight" || event.key === "ArrowDown") {
    next = (currentIndex + 1) % length;
  } else if (event.key === "ArrowLeft" || event.key === "ArrowUp") {
    next = (currentIndex - 1 + length) % length;
  } else if (event.key === "Home") {
    next = 0;
  } else if (event.key === "End") {
    next = length - 1;
  } else {
    return;
  }
  event.preventDefault();
  onSelect(next);
  const radios = event.currentTarget.parentElement?.querySelectorAll<HTMLButtonElement>(
    '[role="radio"]',
  );
  radios?.[next]?.focus();
}

export default function ScbThin() {
  const [thickness, setThickness] = useState<Thickness>("thin");
  const [accent, setAccent] = useState<Accent>("indigo");
  const [stableGutter, setStableGutter] = useState<boolean>(true);
  const [progress, setProgress] = useState<number>(0);

  const scrollRef = useRef<HTMLDivElement | null>(null);
  const reduceMotion = useReducedMotion();

  const handleScroll = useCallback(() => {
    const el = scrollRef.current;
    if (!el) return;
    const max = el.scrollHeight - el.clientHeight;
    setProgress(max <= 0 ? 0 : Math.min(1, Math.max(0, el.scrollTop / max)));
  }, []);

  const scrollToTop = useCallback(() => {
    scrollRef.current?.scrollTo({
      top: 0,
      behavior: reduceMotion ? "auto" : "smooth",
    });
  }, [reduceMotion]);

  const panelClassName = useMemo(
    () =>
      [
        "scbthin-scroll",
        `scbthin-accent-${accent}`,
        stableGutter ? "scbthin-gutter-stable" : "",
      ]
        .filter(Boolean)
        .join(" "),
    [accent, stableGutter],
  );

  const progressPct = Math.round(progress * 100);

  return (
    <section className="relative w-full overflow-hidden bg-slate-50 px-5 py-16 text-slate-900 sm:px-8 sm:py-20 dark:bg-slate-950 dark:text-slate-100">
      <style>{`
        .scbthin-scroll {
          scrollbar-width: var(--scbthin-ff-width, thin);
          scrollbar-color: var(--scbthin-thumb) transparent;
          scrollbar-gutter: auto;
        }
        .scbthin-scroll.scbthin-gutter-stable {
          scrollbar-gutter: stable;
        }
        .scbthin-scroll::-webkit-scrollbar {
          width: var(--scbthin-size, 8px);
          height: var(--scbthin-size, 8px);
        }
        .scbthin-scroll::-webkit-scrollbar-track {
          background: transparent;
          margin: 6px 0;
        }
        .scbthin-scroll::-webkit-scrollbar-thumb {
          background: var(--scbthin-thumb);
          border-radius: 999px;
          border: 2px solid transparent;
          background-clip: padding-box;
          transition: background-color 160ms ease;
        }
        .scbthin-scroll::-webkit-scrollbar-thumb:hover {
          background: var(--scbthin-thumb-hover);
        }
        .scbthin-scroll::-webkit-scrollbar-thumb:active {
          background: var(--scbthin-thumb-hover);
        }
        .scbthin-scroll::-webkit-scrollbar-corner {
          background: transparent;
        }
        .scbthin-x-scroll {
          scrollbar-width: var(--scbthin-ff-width, thin);
          scrollbar-color: var(--scbthin-thumb) transparent;
        }
        .scbthin-x-scroll::-webkit-scrollbar {
          height: var(--scbthin-size, 8px);
        }
        .scbthin-x-scroll::-webkit-scrollbar-track {
          background: transparent;
          margin: 0 8px;
        }
        .scbthin-x-scroll::-webkit-scrollbar-thumb {
          background: var(--scbthin-thumb);
          border-radius: 999px;
          border: 2px solid transparent;
          background-clip: padding-box;
        }
        .scbthin-x-scroll::-webkit-scrollbar-thumb:hover {
          background: var(--scbthin-thumb-hover);
        }

        .scbthin-accent-indigo { --scbthin-thumb: #6366f1; --scbthin-thumb-hover: #4f46e5; }
        .scbthin-accent-emerald { --scbthin-thumb: #10b981; --scbthin-thumb-hover: #059669; }
        .scbthin-accent-rose { --scbthin-thumb: #f43f5e; --scbthin-thumb-hover: #e11d48; }
        .scbthin-accent-amber { --scbthin-thumb: #f59e0b; --scbthin-thumb-hover: #d97706; }

        :is(.dark) .scbthin-accent-indigo { --scbthin-thumb: #818cf8; --scbthin-thumb-hover: #a5b4fc; }
        :is(.dark) .scbthin-accent-emerald { --scbthin-thumb: #34d399; --scbthin-thumb-hover: #6ee7b7; }
        :is(.dark) .scbthin-accent-rose { --scbthin-thumb: #fb7185; --scbthin-thumb-hover: #fda4af; }
        :is(.dark) .scbthin-accent-amber { --scbthin-thumb: #fbbf24; --scbthin-thumb-hover: #fcd34d; }

        @keyframes scbthin-rise {
          from { opacity: 0; transform: translateY(10px); }
          to { opacity: 1; transform: translateY(0); }
        }
        @keyframes scbthin-hint {
          0%, 100% { transform: translateY(0); opacity: 0.55; }
          50% { transform: translateY(3px); opacity: 1; }
        }
        .scbthin-rise {
          animation: scbthin-rise 520ms cubic-bezier(0.22, 1, 0.36, 1) both;
        }
        .scbthin-hint {
          animation: scbthin-hint 1600ms ease-in-out infinite;
        }
        @media (prefers-reduced-motion: reduce) {
          .scbthin-rise, .scbthin-hint {
            animation: none !important;
          }
          .scbthin-scroll::-webkit-scrollbar-thumb {
            transition: none !important;
          }
        }
      `}</style>

      <div
        className="mx-auto max-w-5xl"
        style={
          {
            "--scbthin-size":
              thickness === "hairline"
                ? "4px"
                : thickness === "comfortable"
                  ? "12px"
                  : "8px",
            "--scbthin-ff-width":
              thickness === "comfortable" ? "auto" : "thin",
          } as CSSProperties
        }
      >
        <header className={reduceMotion ? "" : "scbthin-rise"}>
          <p className="text-xs font-semibold uppercase tracking-[0.2em] text-indigo-600 dark:text-indigo-400">
            Overflow, refined
          </p>
          <h2 className="mt-3 text-3xl font-bold tracking-tight sm:text-4xl">
            Thin custom scrollbars
          </h2>
          <p className="mt-3 max-w-2xl text-base leading-relaxed text-slate-600 dark:text-slate-400">
            A scroll region that keeps the browser&apos;s native behavior and
            accessibility, but trades the chunky default track for a slim,
            themeable bar. Tune the thickness, accent, and gutter below and
            scroll the release notes to see it live.
          </p>
        </header>

        <div className="mt-8 grid gap-6 lg:grid-cols-[minmax(0,1fr)_18rem]">
          {/* Controls */}
          <div className="order-2 flex flex-col gap-6 rounded-2xl border border-slate-200 bg-white p-5 lg:order-2 dark:border-slate-800 dark:bg-slate-900">
            {/* Thickness */}
            <fieldset>
              <legend
                id="scbthin-thickness-label"
                className="mb-2 text-sm font-semibold text-slate-800 dark:text-slate-200"
              >
                Thickness
              </legend>
              <div
                role="radiogroup"
                aria-labelledby="scbthin-thickness-label"
                className="flex gap-1.5 rounded-xl bg-slate-100 p-1 dark:bg-slate-800"
              >
                {THICKNESS_OPTIONS.map((option, index) => {
                  const active = option.id === thickness;
                  return (
                    <button
                      key={option.id}
                      type="button"
                      role="radio"
                      aria-checked={active}
                      tabIndex={active ? 0 : -1}
                      onClick={() => setThickness(option.id)}
                      onKeyDown={(event) =>
                        moveWithinGroup(
                          event,
                          index,
                          THICKNESS_OPTIONS.length,
                          (i) => setThickness(THICKNESS_OPTIONS[i].id),
                        )
                      }
                      className={[
                        "flex-1 rounded-lg px-2 py-2 text-center text-xs font-medium transition-colors outline-none",
                        "focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900",
                        active
                          ? "bg-white text-slate-900 shadow-sm dark:bg-slate-950 dark:text-white"
                          : "text-slate-500 hover:text-slate-800 dark:text-slate-400 dark:hover:text-slate-200",
                      ].join(" ")}
                    >
                      <span className="block">{option.label}</span>
                      <span className="mt-0.5 block text-[10px] tabular-nums text-slate-400 dark:text-slate-500">
                        {option.hint}
                      </span>
                    </button>
                  );
                })}
              </div>
            </fieldset>

            {/* Accent */}
            <fieldset>
              <legend
                id="scbthin-accent-label"
                className="mb-2 text-sm font-semibold text-slate-800 dark:text-slate-200"
              >
                Accent
              </legend>
              <div
                role="radiogroup"
                aria-labelledby="scbthin-accent-label"
                className="flex gap-2"
              >
                {ACCENT_OPTIONS.map((option, index) => {
                  const active = option.id === accent;
                  return (
                    <button
                      key={option.id}
                      type="button"
                      role="radio"
                      aria-checked={active}
                      aria-label={option.label}
                      tabIndex={active ? 0 : -1}
                      onClick={() => setAccent(option.id)}
                      onKeyDown={(event) =>
                        moveWithinGroup(
                          event,
                          index,
                          ACCENT_OPTIONS.length,
                          (i) => setAccent(ACCENT_OPTIONS[i].id),
                        )
                      }
                      className={[
                        "relative h-9 w-9 rounded-full outline-none transition-transform",
                        "focus-visible:ring-2 focus-visible:ring-slate-400 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900",
                        active
                          ? "ring-2 ring-slate-900 ring-offset-2 ring-offset-white dark:ring-white dark:ring-offset-slate-900"
                          : "hover:scale-105",
                      ].join(" ")}
                    >
                      <span
                        className={[
                          "block h-full w-full rounded-full",
                          option.swatch,
                        ].join(" ")}
                        aria-hidden="true"
                      />
                    </button>
                  );
                })}
              </div>
            </fieldset>

            {/* Stable gutter switch */}
            <div className="flex items-start justify-between gap-3">
              <div>
                <p
                  id="scbthin-gutter-label"
                  className="text-sm font-semibold text-slate-800 dark:text-slate-200"
                >
                  Stable gutter
                </p>
                <p className="mt-0.5 text-xs leading-relaxed text-slate-500 dark:text-slate-400">
                  Reserve space so content doesn&apos;t shift when the bar
                  appears.
                </p>
              </div>
              <button
                type="button"
                role="switch"
                aria-checked={stableGutter}
                aria-labelledby="scbthin-gutter-label"
                onClick={() => setStableGutter((prev) => !prev)}
                className={[
                  "relative mt-0.5 inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors outline-none",
                  "focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900",
                  stableGutter
                    ? "bg-indigo-600 dark:bg-indigo-500"
                    : "bg-slate-300 dark:bg-slate-700",
                ].join(" ")}
              >
                <span
                  aria-hidden="true"
                  className={[
                    "inline-block h-5 w-5 transform rounded-full bg-white shadow transition-transform",
                    stableGutter ? "translate-x-5" : "translate-x-0.5",
                  ].join(" ")}
                />
              </button>
            </div>

            {/* Live progress */}
            <div aria-live="polite">
              <div className="flex items-center justify-between text-xs font-medium text-slate-500 dark:text-slate-400">
                <span>Scroll position</span>
                <span className="tabular-nums">{progressPct}%</span>
              </div>
              <div
                className="mt-2 h-1.5 w-full overflow-hidden rounded-full bg-slate-200 dark:bg-slate-800"
                role="progressbar"
                aria-valuemin={0}
                aria-valuemax={100}
                aria-valuenow={progressPct}
                aria-label="Release notes scroll progress"
              >
                <motion.div
                  className="h-full rounded-full bg-indigo-600 dark:bg-indigo-400"
                  initial={false}
                  animate={{ width: `${progressPct}%` }}
                  transition={
                    reduceMotion
                      ? { duration: 0 }
                      : { type: "spring", stiffness: 260, damping: 30 }
                  }
                />
              </div>
            </div>
          </div>

          {/* Scroll panel */}
          <div className="order-1 lg:order-1 lg:col-start-1 lg:row-start-1">
            <div className="relative rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
              <div className="flex items-center justify-between gap-3 border-b border-slate-200 px-5 py-3 dark:border-slate-800">
                <div className="flex items-center gap-2">
                  <span className="h-2.5 w-2.5 rounded-full bg-rose-400" />
                  <span className="h-2.5 w-2.5 rounded-full bg-amber-400" />
                  <span className="h-2.5 w-2.5 rounded-full bg-emerald-400" />
                  <h3 className="ml-2 text-sm font-semibold text-slate-700 dark:text-slate-300">
                    Meridian — Release notes
                  </h3>
                </div>
                <button
                  type="button"
                  onClick={scrollToTop}
                  disabled={progress <= 0.001}
                  className={[
                    "rounded-lg px-2.5 py-1 text-xs font-medium transition-colors outline-none",
                    "focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900",
                    "text-indigo-600 hover:bg-indigo-50 disabled:pointer-events-none disabled:opacity-0 dark:text-indigo-400 dark:hover:bg-indigo-500/10",
                  ].join(" ")}
                >
                  Back to top
                </button>
              </div>

              <div
                ref={scrollRef}
                onScroll={handleScroll}
                tabIndex={0}
                role="region"
                aria-label="Meridian release notes, scrollable"
                className={[
                  panelClassName,
                  "max-h-[26rem] overflow-y-auto px-5 py-5 outline-none",
                  "focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-indigo-500",
                ].join(" ")}
              >
                <ol className="space-y-5">
                  {RELEASES.map((entry) => (
                    <li
                      key={entry.version}
                      className="rounded-xl border border-slate-100 p-4 dark:border-slate-800/70"
                    >
                      <div className="flex flex-wrap items-center gap-2">
                        <span className="rounded-md bg-slate-900 px-2 py-0.5 font-mono text-xs font-semibold text-white dark:bg-slate-100 dark:text-slate-900">
                          v{entry.version}
                        </span>
                        <span
                          className={[
                            "rounded-md px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide",
                            TAG_STYLES[entry.tag],
                          ].join(" ")}
                        >
                          {entry.tag}
                        </span>
                        <time className="ml-auto text-xs text-slate-400 dark:text-slate-500">
                          {entry.date}
                        </time>
                      </div>
                      <h4 className="mt-3 text-base font-semibold text-slate-900 dark:text-slate-100">
                        {entry.title}
                      </h4>
                      <ul className="mt-2 space-y-1.5">
                        {entry.points.map((point) => (
                          <li
                            key={point}
                            className="flex gap-2 text-sm leading-relaxed text-slate-600 dark:text-slate-400"
                          >
                            <svg
                              viewBox="0 0 16 16"
                              className="mt-1.5 h-1.5 w-1.5 shrink-0 fill-indigo-500 dark:fill-indigo-400"
                              aria-hidden="true"
                            >
                              <circle cx="8" cy="8" r="8" />
                            </svg>
                            <span>{point}</span>
                          </li>
                        ))}
                      </ul>
                    </li>
                  ))}
                </ol>
                <p className="mt-5 text-center text-xs text-slate-400 dark:text-slate-500">
                  You&apos;ve reached the end of the changelog.
                </p>
              </div>

              {progress <= 0.001 ? (
                <div
                  aria-hidden="true"
                  className="pointer-events-none absolute inset-x-0 bottom-3 flex justify-center"
                >
                  <span
                    className={[
                      "inline-flex items-center gap-1 text-xs text-slate-400 dark:text-slate-500",
                      reduceMotion ? "" : "scbthin-hint",
                    ].join(" ")}
                  >
                    <svg
                      viewBox="0 0 20 20"
                      className="h-3.5 w-3.5 fill-current"
                      aria-hidden="true"
                    >
                      <path d="M10 13.5 4.5 8h11L10 13.5Z" />
                    </svg>
                    Scroll for more
                  </span>
                </div>
              ) : null}
            </div>

            {/* Horizontal thin scrollbar demo */}
            <div className="mt-5 rounded-2xl border border-slate-200 bg-white p-4 dark:border-slate-800 dark:bg-slate-900">
              <h3 className="text-sm font-semibold text-slate-700 dark:text-slate-300">
                Keyboard shortcuts
                <span className="ml-2 font-normal text-slate-400 dark:text-slate-500">
                  scroll horizontally
                </span>
              </h3>
              <div
                tabIndex={0}
                role="region"
                aria-label="Keyboard shortcuts, scrollable horizontally"
                className={[
                  "scbthin-x-scroll",
                  `scbthin-accent-${accent}`,
                  "mt-3 flex gap-3 overflow-x-auto pb-3 outline-none",
                  "focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-indigo-500 rounded-lg",
                ].join(" ")}
              >
                {SHORTCUTS.map((shortcut) => (
                  <div
                    key={shortcut.action}
                    className="flex min-w-[10rem] shrink-0 flex-col gap-2 rounded-xl border border-slate-200 bg-slate-50 p-3 dark:border-slate-800 dark:bg-slate-950"
                  >
                    <kbd className="w-fit rounded-md border border-slate-300 bg-white px-2 py-1 font-mono text-xs font-semibold text-slate-700 shadow-sm dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200">
                      {shortcut.keys}
                    </kbd>
                    <span className="text-sm text-slate-600 dark:text-slate-400">
                      {shortcut.action}
                    </span>
                  </div>
                ))}
              </div>
            </div>
          </div>
        </div>
      </div>
    </section>
  );
}

Dependencies

motion

Licence

Built by Web Innoventix. Free for personal and commercial use, no attribution required.

Built by Web Innoventix

Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.

Get a free quote

Similar components

Browse all →