Web InnoventixFreeCode

Custom Track Scrollbar

Original · free

custom track and thumb scrollbar

byWeb InnoventixReact + Tailwind
scbcustomtrackscrollbars
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-custom-track.json
scb-custom-track.tsx
"use client";

import {
  useCallback,
  useEffect,
  useId,
  useRef,
  useState,
  type KeyboardEvent as ReactKeyboardEvent,
  type PointerEvent as ReactPointerEvent,
} from "react";
import { motion, useReducedMotion } from "motion/react";

type SectionMeta = { id: string; label: string };

const SECTIONS: SectionMeta[] = [
  { id: "restraint", label: "Restraint" },
  { id: "rhythm", label: "Rhythm" },
  { id: "contrast", label: "Contrast" },
  { id: "motion", label: "Motion" },
  { id: "density", label: "Density" },
];

const MIN_THUMB = 36;

const clamp = (value: number, min: number, max: number): number =>
  Math.min(Math.max(value, min), max);

export default function ScbCustomTrack() {
  const reduce = useReducedMotion();
  const viewportId = useId();

  const scrollRef = useRef<HTMLDivElement | null>(null);
  const trackRef = useRef<HTMLDivElement | null>(null);
  const dragState = useRef<{ startY: number; startScroll: number } | null>(null);

  const [thumb, setThumb] = useState<{ height: number; top: number }>({
    height: MIN_THUMB,
    top: 0,
  });
  const [progress, setProgress] = useState(0);
  const [dragging, setDragging] = useState(false);
  const [hovering, setHovering] = useState(false);
  const [atTop, setAtTop] = useState(true);
  const [atBottom, setAtBottom] = useState(false);
  const [active, setActive] = useState<string>(SECTIONS[0].id);

  const sync = useCallback(() => {
    const el = scrollRef.current;
    const track = trackRef.current;
    if (!el || !track) return;

    const { scrollTop, scrollHeight, clientHeight } = el;
    const trackH = track.clientHeight;
    const scrollable = scrollHeight - clientHeight;

    const ratio = clientHeight / scrollHeight;
    const thumbH = clamp(Math.round(trackH * ratio), MIN_THUMB, trackH);
    const maxThumbTop = trackH - thumbH;
    const p = scrollable > 0 ? scrollTop / scrollable : 0;

    setThumb({ height: thumbH, top: Math.round(maxThumbTop * p) });
    setProgress(Math.round(p * 100));
    setAtTop(scrollTop <= 1);
    setAtBottom(scrollTop >= scrollable - 1);

    const cTop = el.getBoundingClientRect().top;
    let current = SECTIONS[0].id;
    for (const s of SECTIONS) {
      const node = el.querySelector<HTMLElement>(`[data-scb-section="${s.id}"]`);
      if (node && node.getBoundingClientRect().top - cTop <= 28) current = s.id;
    }
    setActive(current);
  }, []);

  useEffect(() => {
    const el = scrollRef.current;
    if (!el) return;

    sync();
    el.addEventListener("scroll", sync, { passive: true });

    const ro = new ResizeObserver(sync);
    ro.observe(el);
    if (el.firstElementChild) ro.observe(el.firstElementChild);
    window.addEventListener("resize", sync);

    return () => {
      el.removeEventListener("scroll", sync);
      window.removeEventListener("resize", sync);
      ro.disconnect();
    };
  }, [sync]);

  const onThumbPointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
    const el = scrollRef.current;
    if (!el) return;
    event.preventDefault();
    event.currentTarget.setPointerCapture(event.pointerId);
    dragState.current = { startY: event.clientY, startScroll: el.scrollTop };
    setDragging(true);
  };

  const onThumbPointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
    const el = scrollRef.current;
    const track = trackRef.current;
    if (!el || !track || !dragState.current) return;

    const trackH = track.clientHeight;
    const scrollable = el.scrollHeight - el.clientHeight;
    const maxThumbTop = trackH - thumb.height;
    const delta = event.clientY - dragState.current.startY;
    const scrollDelta = maxThumbTop > 0 ? (delta / maxThumbTop) * scrollable : 0;
    el.scrollTop = dragState.current.startScroll + scrollDelta;
  };

  const endDrag = (event: ReactPointerEvent<HTMLDivElement>) => {
    if (event.currentTarget.hasPointerCapture(event.pointerId)) {
      event.currentTarget.releasePointerCapture(event.pointerId);
    }
    dragState.current = null;
    setDragging(false);
  };

  const onTrackPointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
    if (event.target !== event.currentTarget) return;
    const el = scrollRef.current;
    const track = trackRef.current;
    if (!el || !track) return;

    const rect = track.getBoundingClientRect();
    const trackH = track.clientHeight;
    const scrollable = el.scrollHeight - el.clientHeight;
    const maxThumbTop = trackH - thumb.height;
    const targetTop = clamp(
      event.clientY - rect.top - thumb.height / 2,
      0,
      maxThumbTop,
    );
    const p = maxThumbTop > 0 ? targetTop / maxThumbTop : 0;
    el.scrollTo({
      top: p * scrollable,
      behavior: reduce ? "auto" : "smooth",
    });
  };

  const onScrollbarKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {
    const el = scrollRef.current;
    if (!el) return;

    const line = 52;
    const page = el.clientHeight * 0.9;
    let handled = true;

    switch (event.key) {
      case "ArrowDown":
        el.scrollTop += line;
        break;
      case "ArrowUp":
        el.scrollTop -= line;
        break;
      case "PageDown":
        el.scrollTop += page;
        break;
      case "PageUp":
        el.scrollTop -= page;
        break;
      case "Home":
        el.scrollTop = 0;
        break;
      case "End":
        el.scrollTop = el.scrollHeight;
        break;
      default:
        handled = false;
    }

    if (handled) event.preventDefault();
  };

  const scrollToEdge = (edge: "top" | "bottom") => {
    const el = scrollRef.current;
    if (!el) return;
    el.scrollTo({
      top: edge === "top" ? 0 : el.scrollHeight,
      behavior: reduce ? "auto" : "smooth",
    });
  };

  const jumpTo = (id: string) => {
    const el = scrollRef.current;
    if (!el) return;
    const target = el.querySelector<HTMLElement>(`[data-scb-section="${id}"]`);
    if (!target) return;
    const cRect = el.getBoundingClientRect();
    const tRect = target.getBoundingClientRect();
    el.scrollTo({
      top: el.scrollTop + (tRect.top - cRect.top) - 16,
      behavior: reduce ? "auto" : "smooth",
    });
  };

  const thumbActive = dragging || hovering;

  return (
    <section className="relative w-full overflow-hidden bg-slate-50 px-4 py-16 text-slate-900 sm:px-6 sm:py-20 dark:bg-slate-950 dark:text-slate-100">
      <style>{`
        .scb-ct-viewport { scrollbar-width: none; -ms-overflow-style: none; }
        .scb-ct-viewport::-webkit-scrollbar { width: 0; height: 0; display: none; }
        .scb-ct-sheen { animation: scb-ct-sheen 3.4s ease-in-out infinite; }
        @keyframes scb-ct-sheen {
          0%, 12% { transform: translateY(-160%); opacity: 0; }
          50% { opacity: 0.85; }
          88%, 100% { transform: translateY(280%); opacity: 0; }
        }
        @media (prefers-reduced-motion: reduce) {
          .scb-ct-sheen { animation: none; }
          .scb-ct-viewport { scroll-behavior: auto; }
        }
      `}</style>

      <div
        aria-hidden="true"
        className="pointer-events-none absolute -top-24 right-[-6rem] h-72 w-72 rounded-full bg-indigo-300/25 blur-3xl dark:bg-indigo-500/15"
      />
      <div
        aria-hidden="true"
        className="pointer-events-none absolute -bottom-24 left-[-6rem] h-72 w-72 rounded-full bg-violet-300/25 blur-3xl dark:bg-violet-500/10"
      />

      <div className="relative mx-auto max-w-4xl">
        <header className="mb-8 flex flex-col gap-4 sm:mb-10 sm:flex-row sm:items-end sm:justify-between">
          <div>
            <span className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white/70 px-3 py-1 text-xs font-medium tracking-wide text-indigo-700 uppercase backdrop-blur dark:border-slate-800 dark:bg-slate-900/70 dark:text-indigo-300">
              <span className="h-1.5 w-1.5 rounded-full bg-indigo-500" />
              Reading pane
            </span>
            <h2 className="mt-4 text-2xl font-semibold tracking-tight sm:text-3xl">
              Field notes on quiet interfaces
            </h2>
            <p className="mt-2 max-w-md text-sm text-slate-600 dark:text-slate-400">
              A native scrollbar, replaced. Drag the thumb, click the track,
              or use the arrow keys once it holds focus.
            </p>
          </div>

          <div className="flex shrink-0 items-center gap-2">
            <button
              type="button"
              onClick={() => scrollToEdge("top")}
              disabled={atTop}
              className="inline-flex items-center gap-1.5 rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm font-medium text-slate-700 transition hover:border-indigo-300 hover:text-indigo-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 disabled:cursor-not-allowed disabled:opacity-40 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-200 dark:hover:border-indigo-500 dark:hover:text-indigo-300 dark:focus-visible:ring-offset-slate-950"
            >
              <svg
                aria-hidden="true"
                viewBox="0 0 16 16"
                className="h-3.5 w-3.5"
                fill="none"
                stroke="currentColor"
                strokeWidth="1.75"
                strokeLinecap="round"
                strokeLinejoin="round"
              >
                <path d="M8 12.5V4M4 7.5 8 3.5 12 7.5" />
              </svg>
              Top
            </button>
            <button
              type="button"
              onClick={() => scrollToEdge("bottom")}
              disabled={atBottom}
              className="inline-flex items-center gap-1.5 rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm font-medium text-slate-700 transition hover:border-indigo-300 hover:text-indigo-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 disabled:cursor-not-allowed disabled:opacity-40 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-200 dark:hover:border-indigo-500 dark:hover:text-indigo-300 dark:focus-visible:ring-offset-slate-950"
            >
              <svg
                aria-hidden="true"
                viewBox="0 0 16 16"
                className="h-3.5 w-3.5"
                fill="none"
                stroke="currentColor"
                strokeWidth="1.75"
                strokeLinecap="round"
                strokeLinejoin="round"
              >
                <path d="M8 3.5V12M4 8.5 8 12.5 12 8.5" />
              </svg>
              Bottom
            </button>
          </div>
        </header>

        <nav
          aria-label="Jump to section"
          className="mb-4 flex flex-wrap gap-2"
        >
          {SECTIONS.map((s) => {
            const isActive = active === s.id;
            return (
              <button
                key={s.id}
                type="button"
                onClick={() => jumpTo(s.id)}
                aria-current={isActive ? "true" : undefined}
                className={`rounded-full border px-3 py-1.5 text-xs font-medium transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 dark:focus-visible:ring-offset-slate-950 ${
                  isActive
                    ? "border-transparent bg-indigo-600 text-white shadow-sm shadow-indigo-600/25 dark:bg-indigo-500"
                    : "border-slate-200 bg-white text-slate-600 hover:border-indigo-300 hover:text-indigo-700 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-400 dark:hover:border-indigo-500 dark:hover:text-indigo-300"
                }`}
              >
                {s.label}
              </button>
            );
          })}
        </nav>

        <div className="relative overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-xl shadow-slate-900/5 dark:border-slate-800 dark:bg-slate-900 dark:shadow-black/40">
          <div className="relative flex">
            <div
              ref={scrollRef}
              id={viewportId}
              tabIndex={0}
              className="scb-ct-viewport h-[26rem] flex-1 overflow-y-auto overscroll-contain px-6 py-6 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-indigo-500 sm:px-9 sm:py-8"
            >
              <article className="max-w-prose space-y-9 text-[15px] leading-relaxed text-slate-700 dark:text-slate-300">
                <p className="text-xs font-medium tracking-wide text-slate-400 uppercase dark:text-slate-500">
                  Working log &middot; entry 14 &middot; revised in build
                </p>

                <section data-scb-section="restraint" className="space-y-3">
                  <h3
                    id="restraint"
                    className="text-lg font-semibold text-slate-900 dark:text-slate-100"
                  >
                    On restraint
                  </h3>
                  <p>
                    You only earn the right to hide the browser&apos;s scrollbar
                    once you have built something better to take its place. The
                    default is not ugly by accident; it is a hundred small
                    decisions about affordance and hit-area that you now inherit
                    the responsibility for. Replace it casually and users lose
                    the one control they never had to think about.
                  </p>
                  <p>
                    So the rule here is narrow: the custom track exists to match
                    the surface it sits on, nothing more. It keeps a generous
                    drag target, it respects keyboard focus, and it never fakes
                    a position it cannot back up with a real scroll offset. When
                    in doubt, the replacement should behave so faithfully that
                    nobody notices it was replaced at all.
                  </p>
                </section>

                <section data-scb-section="rhythm" className="space-y-3">
                  <h3
                    id="rhythm"
                    className="text-lg font-semibold text-slate-900 dark:text-slate-100"
                  >
                    On vertical rhythm
                  </h3>
                  <p>
                    Long-form reading lives or dies on rhythm. A measure of
                    around sixty-six characters, line-height near 1.6, and space
                    between paragraphs that is unmistakably larger than the space
                    between lines. Get those three ratios wrong and no amount of
                    typeface polish will rescue the page.
                  </p>
                  <p>
                    The thumb becomes part of that rhythm too. Its height maps to
                    how much of the document you can see at once, so a tall thumb
                    quietly tells you the piece is short, and a sliver warns you
                    to settle in. That is information the reader absorbs without
                    ever reading a word of it.
                  </p>
                </section>

                <section data-scb-section="contrast" className="space-y-3">
                  <h3
                    id="contrast"
                    className="text-lg font-semibold text-slate-900 dark:text-slate-100"
                  >
                    On contrast that survives dark mode
                  </h3>
                  <p>
                    A control that reads perfectly at noon can vanish at night.
                    The track and thumb are drawn from the same token set as the
                    surrounding card, so the pair keeps a legible gap in both
                    themes rather than collapsing into the background the moment
                    the lights go out.
                  </p>
                  <p>
                    Test the extremes deliberately: pure white behind black text,
                    then near-black behind off-white. If the thumb stays findable
                    at a glance in both, and the focus ring stays visible against
                    each, the contrast budget is honest. Anything that only works
                    in one theme is a bug wearing a costume.
                  </p>
                </section>

                <section data-scb-section="motion" className="space-y-3">
                  <h3
                    id="motion"
                    className="text-lg font-semibold text-slate-900 dark:text-slate-100"
                  >
                    On motion budgets
                  </h3>
                  <p>
                    Motion is a currency and you have less of it than you think.
                    Spend it on transitions that clarify cause and effect, and
                    refuse to spend it on decoration that competes with the text.
                    A smooth scroll to an anchor earns its keep; a thumb that
                    jitters as you read does not.
                  </p>
                  <p>
                    Every animation here checks the reduced-motion preference
                    first. When a reader asks the system to calm down, the sheen
                    stops, smooth scrolling turns instant, and the interface
                    honours the request without argument. Respecting that setting
                    is not a nice-to-have; it is the whole point of having it.
                  </p>
                </section>

                <section data-scb-section="density" className="space-y-3">
                  <h3
                    id="density"
                    className="text-lg font-semibold text-slate-900 dark:text-slate-100"
                  >
                    On density and breathing room
                  </h3>
                  <p>
                    Density is a choice, not a default. A dashboard packed with
                    live numbers wants tight rows; an essay wants air. The same
                    scrollbar has to feel at home in both, which is why it stays
                    thin and defers to its container instead of announcing
                    itself with chrome and shadow.
                  </p>
                  <p>
                    When you reach the end of a piece like this one, the thumb
                    settles flush against the bottom of the track and the reading
                    is done. That final resting position is the last small
                    promise the control makes: what you see is exactly where you
                    are, with nothing hidden below the fold.
                  </p>
                </section>
              </article>
            </div>

            <div className="flex w-6 shrink-0 items-stretch py-3 pr-2 select-none sm:w-7">
              <div
                ref={trackRef}
                onPointerDown={onTrackPointerDown}
                className="relative w-full cursor-pointer rounded-full bg-slate-100 ring-1 ring-inset ring-slate-200/70 transition-colors dark:bg-slate-800/60 dark:ring-slate-700/60"
              >
                <div
                  role="scrollbar"
                  aria-controls={viewportId}
                  aria-orientation="vertical"
                  aria-label="Article scroll position"
                  aria-valuemin={0}
                  aria-valuemax={100}
                  aria-valuenow={progress}
                  aria-valuetext={`${progress}% scrolled`}
                  tabIndex={0}
                  onPointerDown={onThumbPointerDown}
                  onPointerMove={onThumbPointerMove}
                  onPointerUp={endDrag}
                  onPointerCancel={endDrag}
                  onKeyDown={onScrollbarKeyDown}
                  onMouseEnter={() => setHovering(true)}
                  onMouseLeave={() => setHovering(false)}
                  onFocus={() => setHovering(true)}
                  onBlur={() => setHovering(false)}
                  style={{
                    height: `${thumb.height}px`,
                    transform: `translateY(${thumb.top}px)`,
                    willChange: "transform",
                  }}
                  className={`absolute inset-x-0 flex touch-none flex-col items-center justify-center overflow-hidden rounded-full bg-gradient-to-b from-indigo-500 to-violet-600 shadow-sm transition-[box-shadow,filter] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:from-indigo-400 dark:to-violet-500 dark:focus-visible:ring-offset-slate-900 ${
                    thumbActive
                      ? "shadow-indigo-500/40 brightness-110"
                      : "brightness-100"
                  } ${dragging ? "cursor-grabbing" : "cursor-grab"}`}
                >
                  {!reduce && (
                    <span
                      aria-hidden="true"
                      className="scb-ct-sheen pointer-events-none absolute inset-x-0 top-0 h-1/3 bg-gradient-to-b from-white/70 to-transparent"
                    />
                  )}
                  <span
                    aria-hidden="true"
                    className="pointer-events-none flex flex-col gap-[3px]"
                  >
                    <span className="h-px w-2.5 rounded-full bg-white/70" />
                    <span className="h-px w-2.5 rounded-full bg-white/70" />
                    <span className="h-px w-2.5 rounded-full bg-white/70" />
                  </span>
                </div>
              </div>
            </div>

            <div className="pointer-events-none absolute inset-x-0 top-0 h-8 rounded-t-2xl bg-gradient-to-b from-white to-transparent dark:from-slate-900" />
            <div className="pointer-events-none absolute inset-x-0 bottom-0 h-8 rounded-b-2xl bg-gradient-to-t from-white to-transparent dark:from-slate-900" />

            <div className="pointer-events-none absolute bottom-4 left-1/2 -translate-x-1/2">
              <motion.div
                aria-hidden="true"
                initial={false}
                animate={
                  atBottom
                    ? { opacity: 0, y: 6 }
                    : reduce
                      ? { opacity: 1, y: 0 }
                      : { opacity: 1, y: [0, 5, 0] }
                }
                transition={
                  atBottom || reduce
                    ? { duration: 0.25 }
                    : { duration: 1.6, repeat: Infinity, ease: "easeInOut" }
                }
                className="flex items-center gap-1.5 rounded-full border border-slate-200 bg-white/85 px-3 py-1 text-[11px] font-medium text-slate-500 shadow-sm backdrop-blur dark:border-slate-700 dark:bg-slate-800/85 dark:text-slate-400"
              >
                <svg
                  viewBox="0 0 16 16"
                  className="h-3 w-3"
                  fill="none"
                  stroke="currentColor"
                  strokeWidth="1.75"
                  strokeLinecap="round"
                  strokeLinejoin="round"
                >
                  <path d="M4 6.5 8 10.5 12 6.5" />
                </svg>
                Scroll to read
              </motion.div>
            </div>
          </div>

          <div className="flex items-center justify-between gap-4 border-t border-slate-200 bg-slate-50/80 px-5 py-3 text-xs text-slate-500 dark:border-slate-800 dark:bg-slate-950/40 dark:text-slate-400">
            <span className="font-medium">
              {SECTIONS.find((s) => s.id === active)?.label ?? "Start"}
            </span>
            <span className="flex items-center gap-3">
              <span
                className="h-1.5 w-28 overflow-hidden rounded-full bg-slate-200 dark:bg-slate-800"
                role="presentation"
              >
                <span
                  className="block h-full rounded-full bg-gradient-to-r from-indigo-500 to-violet-500"
                  style={{ width: `${progress}%` }}
                />
              </span>
              <span className="w-9 text-right font-semibold tabular-nums text-slate-700 dark:text-slate-200">
                {progress}%
              </span>
            </span>
          </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 →