Web InnoventixFreeCode

Gradient Scrollbar

Original · free

gradient scrollbar

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

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

type ThemeKey = "aurora" | "meadow" | "ember";
type ThicknessKey = "thin" | "regular" | "bold";

type ThemeDef = {
  label: string;
  grad: string;
  swatch: string;
  checked: string;
};

type Entry = {
  version: string;
  date: string;
  tag: "Added" | "Fixed" | "Changed" | "Security" | "Improved";
  title: string;
  body: string;
};

const THEMES: Record<ThemeKey, ThemeDef> = {
  aurora: {
    label: "Aurora",
    grad: "from-indigo-500 via-violet-500 to-sky-400",
    swatch: "from-indigo-500 via-violet-500 to-sky-400",
    checked: "peer-checked:border-violet-400 peer-checked:ring-2 peer-checked:ring-violet-400/60",
  },
  meadow: {
    label: "Meadow",
    grad: "from-emerald-400 via-emerald-500 to-sky-400",
    swatch: "from-emerald-400 via-emerald-500 to-sky-400",
    checked: "peer-checked:border-emerald-400 peer-checked:ring-2 peer-checked:ring-emerald-400/60",
  },
  ember: {
    label: "Ember",
    grad: "from-amber-400 via-rose-500 to-rose-600",
    swatch: "from-amber-400 via-rose-500 to-rose-600",
    checked: "peer-checked:border-rose-400 peer-checked:ring-2 peer-checked:ring-rose-400/60",
  },
};

const THICKNESS: Record<ThicknessKey, { label: string; w: number }> = {
  thin: { label: "Thin", w: 6 },
  regular: { label: "Regular", w: 10 },
  bold: { label: "Bold", w: 14 },
};

const TAG_STYLE: Record<Entry["tag"], string> = {
  Added:
    "bg-emerald-50 text-emerald-700 ring-emerald-600/20 dark:bg-emerald-500/10 dark:text-emerald-300 dark:ring-emerald-400/25",
  Fixed:
    "bg-sky-50 text-sky-700 ring-sky-600/20 dark:bg-sky-500/10 dark:text-sky-300 dark:ring-sky-400/25",
  Changed:
    "bg-amber-50 text-amber-700 ring-amber-600/20 dark:bg-amber-500/10 dark:text-amber-300 dark:ring-amber-400/25",
  Security:
    "bg-rose-50 text-rose-700 ring-rose-600/20 dark:bg-rose-500/10 dark:text-rose-300 dark:ring-rose-400/25",
  Improved:
    "bg-violet-50 text-violet-700 ring-violet-600/20 dark:bg-violet-500/10 dark:text-violet-300 dark:ring-violet-400/25",
};

const ENTRIES: Entry[] = [
  {
    version: "v4.4.0",
    date: "Jul 14, 2026",
    tag: "Added",
    title: "Virtualised tables land in core",
    body: "The new DataTable renders only the rows in view, so a 50,000-row dataset now scrolls at a steady 120fps on a mid-range laptop. Column pinning and sticky headers ship on by default.",
  },
  {
    version: "v4.3.2",
    date: "Jul 2, 2026",
    tag: "Fixed",
    title: "Focus no longer escapes nested dialogs",
    body: "Opening a confirmation modal from inside a drawer used to send focus back to the page body on close. Focus now returns to the exact control that opened each layer.",
  },
  {
    version: "v4.3.0",
    date: "Jun 21, 2026",
    tag: "Improved",
    title: "Tokens compile three times faster",
    body: "We moved theme generation off the main thread. A full rebuild of the 1,200-token palette dropped from 4.1s to 1.3s in our local benchmarks.",
  },
  {
    version: "v4.2.5",
    date: "Jun 10, 2026",
    tag: "Changed",
    title: "Button padding follows the 4px grid",
    body: "Small buttons gained two pixels of horizontal padding to align with the spacing scale. Existing overrides are respected; only the defaults moved.",
  },
  {
    version: "v4.2.1",
    date: "May 30, 2026",
    tag: "Security",
    title: "Patched a sanitiser bypass in rich text",
    body: "A crafted paste payload could slip an unescaped attribute past the editor. Upgrade immediately if you accept pasted HTML from untrusted users.",
  },
  {
    version: "v4.2.0",
    date: "May 18, 2026",
    tag: "Added",
    title: "A command palette, keyboard-first",
    body: "Press Cmd or Ctrl+K anywhere to search actions, pages and settings. Every result is reachable without a mouse and announces itself to screen readers.",
  },
  {
    version: "v4.1.4",
    date: "May 6, 2026",
    tag: "Fixed",
    title: "Date picker respects the user's locale",
    body: "Weeks now start on the correct day for 38 additional locales, and the year selector no longer clips beyond 2099.",
  },
  {
    version: "v4.1.0",
    date: "Apr 24, 2026",
    tag: "Improved",
    title: "Contrast bumped across every surface",
    body: "Muted text now clears WCAG AA on both themes. We re-checked sixty component states and fixed the seven that fell short.",
  },
  {
    version: "v4.0.0",
    date: "Apr 3, 2026",
    tag: "Changed",
    title: "The 4.0 baseline",
    body: "Dropped the legacy CSS reset, moved to logical properties throughout, and renamed six props for consistency. Read the migration guide before upgrading.",
  },
];

const MIN_THUMB = 40;

function clamp(value: number, lo: number, hi: number): number {
  return Math.min(hi, Math.max(lo, value));
}

export default function ScbGradient() {
  const reduce = useReducedMotion();
  const uid = useId();
  const contentId = `${uid}-feed`;
  const themeName = `${uid}-theme`;
  const thickName = `${uid}-thick`;

  const contentRef = useRef<HTMLDivElement | null>(null);
  const trackRef = useRef<HTMLDivElement | null>(null);
  const drag = useRef<{ startY: number; startTop: number } | null>(null);

  const [theme, setTheme] = useState<ThemeKey>("aurora");
  const [thickness, setThickness] = useState<ThicknessKey>("regular");
  const [ratio, setRatio] = useState(0);
  const [thumbFraction, setThumbFraction] = useState(1);
  const [trackH, setTrackH] = useState(0);
  const [dragging, setDragging] = useState(false);
  const [scrollable, setScrollable] = useState(false);

  const measure = useCallback(() => {
    const el = contentRef.current;
    if (!el) return;
    const max = el.scrollHeight - el.clientHeight;
    setScrollable(max > 1);
    setRatio(max > 0 ? clamp(el.scrollTop / max, 0, 1) : 0);
    setThumbFraction(el.scrollHeight > 0 ? el.clientHeight / el.scrollHeight : 1);
  }, []);

  useEffect(() => {
    measure();
    const content = contentRef.current;
    const track = trackRef.current;
    if (track) setTrackH(track.clientHeight);
    const ro = new ResizeObserver(() => {
      if (trackRef.current) setTrackH(trackRef.current.clientHeight);
      measure();
    });
    if (content) ro.observe(content);
    if (track) ro.observe(track);
    return () => ro.disconnect();
  }, [measure]);

  const thumbW = THICKNESS[thickness].w;
  const trackPad = 4;
  const trackWidth = thumbW + trackPad * 2;
  const thumbPx = trackH > 0 ? Math.max(MIN_THUMB, Math.round(thumbFraction * trackH)) : 0;
  const travel = Math.max(0, trackH - thumbPx);
  const thumbTop = Math.round(ratio * travel);
  const percent = Math.round(ratio * 100);
  const activeTheme = THEMES[theme];

  const onThumbDown = (e: ReactPointerEvent<HTMLDivElement>) => {
    e.stopPropagation();
    const el = contentRef.current;
    if (!el) return;
    drag.current = { startY: e.clientY, startTop: el.scrollTop };
    trackRef.current?.setPointerCapture(e.pointerId);
    setDragging(true);
  };

  const onTrackDown = (e: ReactPointerEvent<HTMLDivElement>) => {
    if (e.target !== e.currentTarget) return;
    const el = contentRef.current;
    if (!el || travel <= 0) return;
    const rect = e.currentTarget.getBoundingClientRect();
    const max = el.scrollHeight - el.clientHeight;
    const y = e.clientY - rect.top - thumbPx / 2;
    el.scrollTop = clamp(y / travel, 0, 1) * max;
    drag.current = { startY: e.clientY, startTop: el.scrollTop };
    e.currentTarget.setPointerCapture(e.pointerId);
    setDragging(true);
  };

  const onPointerMove = (e: ReactPointerEvent<HTMLDivElement>) => {
    if (!drag.current) return;
    const el = contentRef.current;
    if (!el || travel <= 0) return;
    const max = el.scrollHeight - el.clientHeight;
    const dy = e.clientY - drag.current.startY;
    el.scrollTop = clamp(drag.current.startTop + (dy / travel) * max, 0, max);
  };

  const onPointerUp = (e: ReactPointerEvent<HTMLDivElement>) => {
    if (!drag.current) return;
    drag.current = null;
    setDragging(false);
    if (trackRef.current?.hasPointerCapture(e.pointerId)) {
      trackRef.current.releasePointerCapture(e.pointerId);
    }
  };

  const onThumbKey = (e: ReactKeyboardEvent<HTMLDivElement>) => {
    const el = contentRef.current;
    if (!el) return;
    const max = el.scrollHeight - el.clientHeight;
    const line = 56;
    const page = el.clientHeight * 0.85;
    const keys = ["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End"];
    if (!keys.includes(e.key)) return;
    e.preventDefault();
    let next = el.scrollTop;
    if (e.key === "ArrowDown") next += line;
    else if (e.key === "ArrowUp") next -= line;
    else if (e.key === "PageDown") next += page;
    else if (e.key === "PageUp") next -= page;
    else if (e.key === "Home") next = 0;
    else if (e.key === "End") next = max;
    el.scrollTop = clamp(next, 0, max);
  };

  const thumbStyle: CSSProperties = {
    top: thumbTop,
    height: thumbPx,
    left: trackPad,
    right: trackPad,
    backgroundSize: "100% 220%",
  };

  return (
    <section className="relative w-full overflow-hidden bg-slate-50 px-4 py-16 text-slate-900 sm:px-6 sm:py-24 dark:bg-slate-950 dark:text-slate-100">
      <style>{`
        @keyframes scbgr-drift {
          0% { background-position: 50% 0%; }
          100% { background-position: 50% 120%; }
        }
        @keyframes scbgr-shine {
          0% { transform: translateY(-160%); }
          60%, 100% { transform: translateY(420%); }
        }
        .scbgr-scroll { scrollbar-width: none; -ms-overflow-style: none; }
        .scbgr-scroll::-webkit-scrollbar { width: 0; height: 0; display: none; }
        .scbgr-drift { animation: scbgr-drift 5.5s linear infinite; }
        .scbgr-shine { animation: scbgr-shine 3.6s ease-in-out infinite; }
        @media (prefers-reduced-motion: reduce) {
          .scbgr-drift, .scbgr-shine { animation: none !important; }
        }
      `}</style>

      <div className="pointer-events-none absolute -top-24 right-[-6rem] h-72 w-72 rounded-full bg-gradient-to-br from-violet-300/40 to-sky-300/30 blur-3xl dark:from-violet-600/20 dark:to-sky-600/15" />

      <div className="relative mx-auto w-full max-w-5xl">
        <div className="mx-auto max-w-2xl text-center">
          <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 uppercase tracking-[0.18em] text-slate-500 backdrop-blur dark:border-slate-800 dark:bg-slate-900/70 dark:text-slate-400">
            <span className={`h-1.5 w-6 rounded-full bg-gradient-to-r ${activeTheme.swatch}`} />
            Scrollbars
          </span>
          <h2 className="mt-5 text-3xl font-semibold tracking-tight text-slate-900 sm:text-4xl dark:text-white">
            A scrollbar worth scrolling
          </h2>
          <p className="mt-3 text-base leading-relaxed text-slate-600 dark:text-slate-400">
            A fully custom, pointer-and-keyboard driven gradient scrollbar. Drag the thumb,
            click the track, or focus it and use the arrow, page, home and end keys.
          </p>
        </div>

        {/* Controls */}
        <div className="mx-auto mt-8 flex max-w-2xl flex-col items-stretch justify-center gap-4 sm:flex-row sm:items-end sm:gap-8">
          <fieldset>
            <legend className="mb-2 block text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">
              Gradient
            </legend>
            <div className="flex gap-2">
              {(Object.keys(THEMES) as ThemeKey[]).map((key) => (
                <label key={key} className="cursor-pointer">
                  <input
                    type="radio"
                    name={themeName}
                    value={key}
                    checked={theme === key}
                    onChange={() => setTheme(key)}
                    className="peer sr-only"
                  />
                  <span
                    className={`flex items-center gap-2 rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm font-medium text-slate-600 transition ${THEMES[key].checked} peer-focus-visible:ring-2 peer-focus-visible:ring-slate-900 peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-slate-50 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-300 dark:peer-focus-visible:ring-white dark:peer-focus-visible:ring-offset-slate-950`}
                  >
                    <span className={`h-4 w-4 rounded-full bg-gradient-to-br ${THEMES[key].swatch}`} />
                    {THEMES[key].label}
                  </span>
                </label>
              ))}
            </div>
          </fieldset>

          <fieldset>
            <legend className="mb-2 block text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">
              Thickness
            </legend>
            <div className="inline-flex rounded-xl border border-slate-200 bg-white p-1 dark:border-slate-800 dark:bg-slate-900">
              {(Object.keys(THICKNESS) as ThicknessKey[]).map((key) => (
                <label key={key} className="cursor-pointer">
                  <input
                    type="radio"
                    name={thickName}
                    value={key}
                    checked={thickness === key}
                    onChange={() => setThickness(key)}
                    className="peer sr-only"
                  />
                  <span className="block rounded-lg px-3 py-1.5 text-sm font-medium text-slate-500 transition peer-checked:bg-slate-900 peer-checked:text-white peer-focus-visible:ring-2 peer-focus-visible:ring-slate-900 peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-white dark:text-slate-400 dark:peer-checked:bg-white dark:peer-checked:text-slate-900 dark:peer-focus-visible:ring-white dark:peer-focus-visible:ring-offset-slate-900">
                    {THICKNESS[key].label}
                  </span>
                </label>
              ))}
            </div>
          </fieldset>
        </div>

        {/* Card */}
        <motion.div
          initial={reduce ? false : { opacity: 0, y: 18 }}
          whileInView={reduce ? undefined : { opacity: 1, y: 0 }}
          viewport={{ once: true, margin: "-80px" }}
          transition={{ duration: 0.55, ease: [0.22, 1, 0.36, 1] }}
          className="mx-auto mt-10 max-w-2xl overflow-hidden rounded-3xl border border-slate-200 bg-white shadow-xl shadow-slate-900/5 dark:border-slate-800 dark:bg-slate-900 dark:shadow-black/30"
        >
          <header className="flex items-center justify-between gap-4 border-b border-slate-200 px-5 py-4 sm:px-6 dark:border-slate-800">
            <div>
              <h3 className="text-sm font-semibold text-slate-900 dark:text-white">
                Meridian UI · Release notes
              </h3>
              <p className="text-xs text-slate-500 dark:text-slate-400">
                Nine updates · newest first
              </p>
            </div>
            <span
              aria-hidden="true"
              className="inline-flex items-center gap-2 rounded-full bg-slate-100 px-2.5 py-1 text-xs font-semibold tabular-nums text-slate-600 dark:bg-slate-800 dark:text-slate-300"
            >
              <span className={`h-2 w-2 rounded-full bg-gradient-to-br ${activeTheme.swatch}`} />
              {percent}%
            </span>
          </header>

          <div className="relative">
            <div
              id={contentId}
              ref={contentRef}
              onScroll={measure}
              tabIndex={0}
              role="region"
              aria-label="Meridian UI release notes, scrollable region"
              className="scbgr-scroll h-[26rem] overflow-y-auto py-5 pl-5 focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-slate-400 sm:pl-6 dark:focus-visible:ring-slate-500"
              style={{ paddingRight: trackWidth + 22 }}
            >
              <ol className="space-y-5">
                {ENTRIES.map((entry) => (
                  <li
                    key={entry.version}
                    className="border-b border-dashed border-slate-200 pb-5 last:border-none last:pb-0 dark:border-slate-800"
                  >
                    <div className="flex items-center gap-2">
                      <span
                        className={`inline-flex rounded-md px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide ring-1 ring-inset ${TAG_STYLE[entry.tag]}`}
                      >
                        {entry.tag}
                      </span>
                      <span className="font-mono text-xs font-medium text-slate-500 dark:text-slate-400">
                        {entry.version}
                      </span>
                      <span className="ml-auto text-xs text-slate-400 dark:text-slate-500">
                        {entry.date}
                      </span>
                    </div>
                    <h4 className="mt-2 text-[15px] font-semibold text-slate-900 dark:text-white">
                      {entry.title}
                    </h4>
                    <p className="mt-1 text-sm leading-relaxed text-slate-600 dark:text-slate-400">
                      {entry.body}
                    </p>
                  </li>
                ))}
              </ol>
            </div>

            {/* Edge fades */}
            <div
              aria-hidden="true"
              className="pointer-events-none absolute inset-x-0 top-0 h-8 bg-gradient-to-b from-white to-transparent transition-opacity duration-200 dark:from-slate-900"
              style={{ opacity: ratio > 0.02 ? 1 : 0 }}
            />
            <div
              aria-hidden="true"
              className="pointer-events-none absolute inset-x-0 bottom-0 h-8 bg-gradient-to-t from-white to-transparent transition-opacity duration-200 dark:from-slate-900"
              style={{ opacity: scrollable && ratio < 0.98 ? 1 : 0 }}
            />

            {/* Custom gradient scrollbar */}
            <div
              ref={trackRef}
              onPointerDown={onTrackDown}
              onPointerMove={onPointerMove}
              onPointerUp={onPointerUp}
              onPointerCancel={onPointerUp}
              className="absolute inset-y-3 right-2 rounded-full bg-slate-200/70 backdrop-blur-sm dark:bg-slate-800/70"
              style={{ width: trackWidth }}
            >
              <div
                role="scrollbar"
                aria-controls={contentId}
                aria-orientation="vertical"
                aria-valuemin={0}
                aria-valuemax={100}
                aria-valuenow={percent}
                aria-valuetext={`${percent} percent scrolled`}
                aria-label="Scroll release notes"
                aria-disabled={!scrollable}
                tabIndex={scrollable ? 0 : -1}
                onPointerDown={onThumbDown}
                onKeyDown={onThumbKey}
                className={`scbgr-drift absolute cursor-grab touch-none rounded-full bg-gradient-to-b ${activeTheme.grad} shadow-lg shadow-slate-900/20 outline-none transition-[filter,box-shadow] duration-150 focus-visible:ring-2 focus-visible:ring-slate-900 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-100 active:cursor-grabbing dark:focus-visible:ring-white dark:focus-visible:ring-offset-slate-800 ${dragging ? "brightness-110" : ""}`}
                style={thumbStyle}
              >
                <span className="pointer-events-none absolute inset-0 overflow-hidden rounded-full">
                  <span className="scbgr-shine absolute inset-x-0 top-0 h-1/4 bg-gradient-to-b from-transparent via-white/70 to-transparent" />
                </span>
              </div>
            </div>
          </div>
        </motion.div>

        <p className="mx-auto mt-4 max-w-2xl text-center text-xs text-slate-400 dark:text-slate-500">
          The gradient thumb is a real <code className="rounded bg-slate-100 px-1 py-0.5 font-mono text-slate-500 dark:bg-slate-800 dark:text-slate-400">role=&quot;scrollbar&quot;</code> control with live value and full keyboard support.
        </p>
      </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 →