Web InnoventixFreeCode

Rounded Scrollbar

Original · free

rounded pill scrollbar

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

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

type Thickness = "thin" | "regular" | "bold";

type Entry = {
  version: string;
  date: string;
  kind: "Feature" | "Fix" | "Performance";
  title: string;
  points: string[];
};

const MIN_THUMB = 34;

const THUMB_WIDTH: Record<Thickness, number> = {
  thin: 6,
  regular: 10,
  bold: 14,
};

const THICKNESS_OPTIONS: { value: Thickness; label: string }[] = [
  { value: "thin", label: "Thin" },
  { value: "regular", label: "Regular" },
  { value: "bold", label: "Bold" },
];

const KIND_CLASS: Record<Entry["kind"], string> = {
  Feature:
    "bg-emerald-500/10 text-emerald-700 ring-emerald-500/30 dark:text-emerald-300",
  Fix: "bg-amber-500/10 text-amber-700 ring-amber-500/30 dark:text-amber-300",
  Performance:
    "bg-sky-500/10 text-sky-700 ring-sky-500/30 dark:text-sky-300",
};

const ENTRIES: Entry[] = [
  {
    version: "2.8.0",
    date: "Jul 14, 2026",
    kind: "Feature",
    title: "The command palette, rebuilt from scratch",
    points: [
      "Fuzzy matching now spans files, symbols, settings, and recent commands from a single input.",
      "Results stream in as you type — no waiting on the first keystroke in large repositories.",
      "Press ⌘K twice to scope the search to the current file only.",
    ],
  },
  {
    version: "2.7.3",
    date: "Jun 30, 2026",
    kind: "Fix",
    title: "Stability pass for the terminal pane",
    points: [
      "Fixed a hang when pasting more than 10,000 lines into an active shell session.",
      "Resolved cursor drift on reflow when resizing a split with a wrapped prompt.",
    ],
  },
  {
    version: "2.7.0",
    date: "Jun 12, 2026",
    kind: "Feature",
    title: "Infinite split panes",
    points: [
      "Drag any tab to an edge to spawn a new pane; layouts persist per workspace.",
      "Panes share a synchronized scroll group when you hold ⌥ while scrolling.",
      "Added keyboard-only pane navigation with ⌃⌘ and the arrow keys.",
    ],
  },
  {
    version: "2.6.1",
    date: "May 28, 2026",
    kind: "Performance",
    title: "Startup is 40% faster on a cold launch",
    points: [
      "Deferred extension activation until the editor's first paint.",
      "Trimmed 18 MB from the bundled runtime by dropping an unused ICU dataset.",
    ],
  },
  {
    version: "2.6.0",
    date: "May 9, 2026",
    kind: "Feature",
    title: "Bring-your-own themes",
    points: [
      "Author themes in plain JSON and hot-reload them without restarting.",
      "Ships with six hand-tuned presets, including a high-contrast option that passes WCAG AA.",
    ],
  },
  {
    version: "2.5.2",
    date: "Apr 22, 2026",
    kind: "Fix",
    title: "Git integration corrections",
    points: [
      "Blame annotations no longer duplicate after a rebase with a force-push.",
      "Fixed an off-by-one in the diff gutter for files without a trailing newline.",
    ],
  },
  {
    version: "2.5.0",
    date: "Apr 3, 2026",
    kind: "Feature",
    title: "See your teammates in real time",
    points: [
      "Live cursors and selections appear inline during a shared session.",
      "Follow mode locks your viewport to a collaborator until you scroll away.",
    ],
  },
  {
    version: "2.4.0",
    date: "Mar 15, 2026",
    kind: "Feature",
    title: "Inline history you can trust",
    points: [
      "Hover any line to reveal the commit, author, and message without leaving the editor.",
      "Click through to a full-screen, side-by-side history for the current file.",
    ],
  },
];

const STYLES = `
@keyframes scbrnd-hint { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(3px); } }
@keyframes scbrnd-drift { 0%, 100% { transform: translate(0, 0) scale(1); } 50% { transform: translate(18px, -14px) scale(1.07); } }
.scbrnd-hint { animation: scbrnd-hint 1.4s ease-in-out infinite; }
.scbrnd-drift { animation: scbrnd-drift 15s ease-in-out infinite; }
.scbrnd-viewport { scrollbar-width: none; -ms-overflow-style: none; }
.scbrnd-viewport::-webkit-scrollbar { width: 0; height: 0; display: none; }
@media (prefers-reduced-motion: reduce) {
  .scbrnd-hint, .scbrnd-drift { animation: none !important; }
  .scbrnd-viewport { scroll-behavior: auto !important; }
}
`;

export default function ScbRounded() {
  const reduce = useReducedMotion();
  const uid = useId();
  const viewportId = `${uid}-viewport`;

  const [thickness, setThickness] = useState<Thickness>("regular");
  const [autoHide, setAutoHide] = useState(false);
  const [visible, setVisible] = useState(true);
  const [hovering, setHovering] = useState(false);
  const [dragging, setDragging] = useState(false);
  const [thumb, setThumb] = useState<{ height: number; top: number }>({
    height: 48,
    top: 0,
  });
  const [progress, setProgress] = useState(0);
  const [scrollable, setScrollable] = useState(true);

  const viewportRef = useRef<HTMLDivElement | null>(null);
  const trackRef = useRef<HTMLDivElement | null>(null);
  const dragState = useRef<{ startY: number; startScrollTop: number } | null>(
    null,
  );
  const thumbHeightRef = useRef(48);
  const thumbTopRef = useRef(0);
  const hideTimer = useRef<number | null>(null);
  const segRefs = useRef<(HTMLButtonElement | null)[]>([]);

  const thumbW = THUMB_WIDTH[thickness];
  const trackW = Math.max(thumbW, 12);
  const shown = !autoHide || visible || dragging || hovering;

  const updateThumb = useCallback(() => {
    const viewport = viewportRef.current;
    const track = trackRef.current;
    if (!viewport || !track) return;
    const { scrollTop, scrollHeight, clientHeight } = viewport;
    const trackH = track.clientHeight;
    const maxScrollTop = scrollHeight - clientHeight;
    const ratio = scrollHeight > 0 ? clientHeight / scrollHeight : 1;
    const thumbH = Math.min(
      trackH,
      Math.max(MIN_THUMB, Math.round(trackH * ratio)),
    );
    const maxThumbTop = trackH - thumbH;
    const top =
      maxScrollTop > 0 ? (scrollTop / maxScrollTop) * maxThumbTop : 0;
    thumbHeightRef.current = thumbH;
    thumbTopRef.current = top;
    setThumb({ height: thumbH, top });
    setScrollable(maxScrollTop > 1);
    setProgress(
      maxScrollTop > 0 ? Math.round((scrollTop / maxScrollTop) * 100) : 0,
    );
  }, []);

  const showScrollbar = useCallback(() => {
    setVisible(true);
    if (hideTimer.current) window.clearTimeout(hideTimer.current);
    if (autoHide && !dragging) {
      hideTimer.current = window.setTimeout(() => setVisible(false), 1400);
    }
  }, [autoHide, dragging]);

  useEffect(() => {
    updateThumb();
    const viewport = viewportRef.current;
    if (!viewport || typeof ResizeObserver === "undefined") return;
    const ro = new ResizeObserver(() => updateThumb());
    ro.observe(viewport);
    if (viewport.firstElementChild) ro.observe(viewport.firstElementChild);
    return () => ro.disconnect();
  }, [updateThumb]);

  useEffect(() => {
    updateThumb();
  }, [thickness, updateThumb]);

  const toggleAutoHide = () => {
    const next = !autoHide;
    setAutoHide(next);
    setVisible(true);
    if (hideTimer.current) window.clearTimeout(hideTimer.current);
    if (next) {
      hideTimer.current = window.setTimeout(() => setVisible(false), 1400);
    }
  };

  useEffect(
    () => () => {
      if (hideTimer.current) window.clearTimeout(hideTimer.current);
    },
    [],
  );

  const onScroll = () => {
    updateThumb();
    if (autoHide) showScrollbar();
  };

  const onThumbPointerDown = (e: ReactPointerEvent<HTMLDivElement>) => {
    const viewport = viewportRef.current;
    if (!viewport) return;
    e.preventDefault();
    e.stopPropagation();
    (e.target as HTMLElement).setPointerCapture(e.pointerId);
    dragState.current = {
      startY: e.clientY,
      startScrollTop: viewport.scrollTop,
    };
    setDragging(true);
  };

  const onThumbPointerMove = (e: ReactPointerEvent<HTMLDivElement>) => {
    const state = dragState.current;
    const viewport = viewportRef.current;
    const track = trackRef.current;
    if (!state || !viewport || !track) return;
    const trackH = track.clientHeight;
    const maxThumbTop = trackH - thumbHeightRef.current;
    const maxScrollTop = viewport.scrollHeight - viewport.clientHeight;
    if (maxThumbTop <= 0) return;
    const deltaY = e.clientY - state.startY;
    viewport.scrollTop =
      state.startScrollTop + (deltaY / maxThumbTop) * maxScrollTop;
  };

  const onThumbPointerUp = (e: ReactPointerEvent<HTMLDivElement>) => {
    dragState.current = null;
    setDragging(false);
    try {
      (e.target as HTMLElement).releasePointerCapture(e.pointerId);
    } catch {
      /* pointer already released */
    }
    if (autoHide) showScrollbar();
  };

  const onTrackPointerDown = (e: ReactPointerEvent<HTMLDivElement>) => {
    const viewport = viewportRef.current;
    const track = trackRef.current;
    if (!viewport || !track) return;
    const rect = track.getBoundingClientRect();
    const clickY = e.clientY - rect.top;
    const thumbCenter = thumbTopRef.current + thumbHeightRef.current / 2;
    const dir = clickY < thumbCenter ? -1 : 1;
    viewport.scrollBy({
      top: dir * viewport.clientHeight * 0.9,
      behavior: reduce ? "auto" : "smooth",
    });
  };

  const onThumbKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => {
    const viewport = viewportRef.current;
    if (!viewport) return;
    const page = viewport.clientHeight * 0.9;
    const behavior: ScrollBehavior = reduce ? "auto" : "smooth";
    let handled = true;
    switch (e.key) {
      case "ArrowDown":
        viewport.scrollBy({ top: 56, behavior });
        break;
      case "ArrowUp":
        viewport.scrollBy({ top: -56, behavior });
        break;
      case "PageDown":
        viewport.scrollBy({ top: page, behavior });
        break;
      case "PageUp":
        viewport.scrollBy({ top: -page, behavior });
        break;
      case "Home":
        viewport.scrollTo({ top: 0, behavior });
        break;
      case "End":
        viewport.scrollTo({ top: viewport.scrollHeight, behavior });
        break;
      default:
        handled = false;
    }
    if (handled) e.preventDefault();
  };

  const onSegKeyDown = (
    e: ReactKeyboardEvent<HTMLButtonElement>,
    i: number,
  ) => {
    const count = THICKNESS_OPTIONS.length;
    let next = i;
    if (e.key === "ArrowRight" || e.key === "ArrowDown") next = (i + 1) % count;
    else if (e.key === "ArrowLeft" || e.key === "ArrowUp")
      next = (i - 1 + count) % count;
    else if (e.key === "Home") next = 0;
    else if (e.key === "End") next = count - 1;
    else return;
    e.preventDefault();
    setThickness(THICKNESS_OPTIONS[next].value);
    segRefs.current[next]?.focus();
  };

  const focusRing =
    "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500";

  return (
    <section className="relative w-full overflow-hidden bg-zinc-50 px-4 py-16 sm:px-6 sm:py-24 dark:bg-zinc-950">
      <div
        aria-hidden="true"
        className="pointer-events-none absolute inset-0 overflow-hidden"
      >
        <div className="scbrnd-drift absolute -left-16 -top-24 h-72 w-72 rounded-full bg-indigo-400/20 blur-3xl dark:bg-indigo-500/15" />
        <div
          className="scbrnd-drift absolute -bottom-24 -right-10 h-80 w-80 rounded-full bg-violet-400/20 blur-3xl dark:bg-violet-500/15"
          style={{ animationDelay: "-5s" }}
        />
      </div>

      <div className="relative mx-auto max-w-2xl">
        <header className="mb-8">
          <span className="inline-flex items-center gap-2 font-mono text-xs uppercase tracking-[0.2em] text-indigo-600 dark:text-indigo-400">
            <svg
              viewBox="0 0 24 24"
              className="h-3.5 w-3.5"
              fill="none"
              stroke="currentColor"
              strokeWidth="1.8"
              strokeLinecap="round"
              strokeLinejoin="round"
              aria-hidden="true"
            >
              <path d="M12 3v4M12 17v4M3 12h4M17 12h4M6 6l2 2M16 16l2 2M18 6l-2 2M8 16l-2 2" />
            </svg>
            Release notes
          </span>
          <h2 className="mt-3 text-3xl font-semibold tracking-tight text-zinc-900 sm:text-4xl dark:text-white">
            What&rsquo;s new in Halcyon
          </h2>
          <p className="mt-3 leading-relaxed text-zinc-500 dark:text-zinc-400">
            Eight releases of the editor teams actually keep open. Scroll the
            log with the pill on the right — drag it, click the track to page,
            or tab to it and steer with the arrow keys.
          </p>
        </header>

        <div className="mb-5 flex flex-wrap items-center gap-x-6 gap-y-3">
          <div className="flex items-center gap-3">
            <span className="text-sm font-medium text-zinc-600 dark:text-zinc-300">
              Thickness
            </span>
            <div
              role="radiogroup"
              aria-label="Scrollbar thickness"
              className="inline-flex rounded-full bg-zinc-100 p-1 dark:bg-zinc-800"
            >
              {THICKNESS_OPTIONS.map((opt, i) => {
                const selected = thickness === opt.value;
                return (
                  <button
                    key={opt.value}
                    ref={(el) => {
                      segRefs.current[i] = el;
                    }}
                    type="button"
                    role="radio"
                    aria-checked={selected}
                    tabIndex={selected ? 0 : -1}
                    onClick={() => setThickness(opt.value)}
                    onKeyDown={(e) => onSegKeyDown(e, i)}
                    className={`relative z-10 rounded-full px-3.5 py-1.5 text-sm font-medium transition-colors ${focusRing} focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-800 ${
                      selected
                        ? "text-zinc-900 dark:text-white"
                        : "text-zinc-500 hover:text-zinc-700 dark:text-zinc-400 dark:hover:text-zinc-200"
                    }`}
                  >
                    {selected && (
                      <motion.span
                        layoutId={`${uid}-seg`}
                        transition={
                          reduce
                            ? { duration: 0 }
                            : { type: "spring", stiffness: 500, damping: 40 }
                        }
                        className="absolute inset-0 -z-10 rounded-full bg-white shadow-sm dark:bg-zinc-700"
                      />
                    )}
                    {opt.label}
                  </button>
                );
              })}
            </div>
          </div>

          <div className="flex items-center gap-2.5">
            <button
              type="button"
              role="switch"
              aria-checked={autoHide}
              aria-label="Auto-hide the scrollbar when idle"
              onClick={() => setAutoHide((v) => !v)}
              className={`relative inline-flex h-6 w-11 shrink-0 rounded-full transition-colors ${focusRing} focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-50 dark:focus-visible:ring-offset-zinc-950 ${
                autoHide ? "bg-indigo-500" : "bg-zinc-300 dark:bg-zinc-700"
              }`}
            >
              <motion.span
                className="absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-white shadow-sm"
                animate={{ x: autoHide ? 20 : 0 }}
                transition={
                  reduce
                    ? { duration: 0 }
                    : { type: "spring", stiffness: 500, damping: 32 }
                }
              />
            </button>
            <span className="text-sm font-medium text-zinc-600 dark:text-zinc-300">
              Auto-hide
            </span>
          </div>
        </div>

        <motion.div
          initial={reduce ? { opacity: 1 } : { opacity: 0, y: 24 }}
          animate={{ opacity: 1, y: 0 }}
          transition={reduce ? { duration: 0 } : { duration: 0.6, ease: "easeOut" }}
          onPointerEnter={() => setHovering(true)}
          onPointerLeave={() => setHovering(false)}
          className={`relative rounded-3xl border border-zinc-200/80 bg-white p-3 shadow-xl shadow-zinc-900/5 dark:border-zinc-800 dark:bg-zinc-900 ${
            dragging ? "cursor-grabbing select-none" : ""
          }`}
        >
          <div
            aria-hidden="true"
            className="mb-2 flex items-center gap-3 px-1"
          >
            <div className="h-1 flex-1 overflow-hidden rounded-full bg-zinc-200 dark:bg-zinc-800">
              <div
                className="h-full rounded-full bg-gradient-to-r from-indigo-500 to-violet-500 transition-[width] duration-150 motion-reduce:transition-none"
                style={{ width: `${progress}%` }}
              />
            </div>
            <span className="font-mono text-[11px] tabular-nums text-zinc-400 dark:text-zinc-500">
              {progress}%
            </span>
          </div>

          <div className="relative">
            <div
              id={viewportId}
              ref={viewportRef}
              role="region"
              aria-label="Halcyon release notes, scrollable"
              tabIndex={0}
              onScroll={onScroll}
              className={`scbrnd-viewport relative h-[24rem] overflow-y-auto rounded-2xl py-6 pl-5 pr-9 sm:h-[26rem] ${focusRing} focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-zinc-900`}
            >
              <ol className="space-y-6">
                {ENTRIES.map((entry, i) => (
                  <motion.li
                    key={entry.version}
                    initial={
                      reduce ? { opacity: 1, y: 0 } : { opacity: 0, y: 16 }
                    }
                    animate={{ opacity: 1, y: 0 }}
                    transition={
                      reduce
                        ? { duration: 0 }
                        : {
                            duration: 0.5,
                            delay: Math.min(i, 7) * 0.05,
                            ease: "easeOut",
                          }
                    }
                    className="border-b border-zinc-100 pb-6 last:border-0 last:pb-0 dark:border-zinc-800/70"
                  >
                    <div className="flex flex-wrap items-center gap-3">
                      <span className="rounded-md bg-zinc-100 px-2 py-0.5 font-mono text-xs font-medium text-zinc-600 dark:bg-zinc-800 dark:text-zinc-300">
                        v{entry.version}
                      </span>
                      <span
                        className={`rounded-full px-2.5 py-0.5 text-xs font-medium ring-1 ring-inset ${KIND_CLASS[entry.kind]}`}
                      >
                        {entry.kind}
                      </span>
                      <span className="ml-auto font-mono text-xs text-zinc-400 dark:text-zinc-500">
                        {entry.date}
                      </span>
                    </div>
                    <h3 className="mt-2.5 text-lg font-semibold tracking-tight text-zinc-900 dark:text-white">
                      {entry.title}
                    </h3>
                    <ul className="mt-2 space-y-1.5">
                      {entry.points.map((point) => (
                        <li
                          key={point}
                          className="flex gap-2.5 text-sm leading-relaxed text-zinc-600 dark:text-zinc-300"
                        >
                          <span className="mt-2 h-1 w-1 shrink-0 rounded-full bg-indigo-400 dark:bg-indigo-500" />
                          <span>{point}</span>
                        </li>
                      ))}
                    </ul>
                  </motion.li>
                ))}
              </ol>
            </div>

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

            <div
              ref={trackRef}
              onPointerDown={onTrackPointerDown}
              aria-hidden={!scrollable}
              className="absolute bottom-5 right-3 top-5 z-20 select-none rounded-full transition-opacity duration-300 motion-reduce:transition-none"
              style={{ width: trackW, opacity: shown ? 1 : 0 }}
            >
              <div className="absolute inset-0 rounded-full bg-zinc-200/70 dark:bg-zinc-700/40" />
              <div
                role="scrollbar"
                aria-controls={viewportId}
                aria-orientation="vertical"
                aria-label="Scroll release notes"
                aria-valuemin={0}
                aria-valuemax={100}
                aria-valuenow={progress}
                tabIndex={scrollable ? 0 : -1}
                onPointerDown={onThumbPointerDown}
                onPointerMove={onThumbPointerMove}
                onPointerUp={onThumbPointerUp}
                onPointerCancel={onThumbPointerUp}
                onKeyDown={onThumbKeyDown}
                className={`absolute left-1/2 top-0 touch-none rounded-full bg-gradient-to-b from-indigo-400 to-violet-500 shadow-sm transition-colors hover:from-indigo-500 hover:to-violet-600 focus-visible: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-zinc-900 ${
                  dragging ? "cursor-grabbing" : "cursor-grab"
                }`}
                style={{
                  width: thumbW,
                  height: thumb.height,
                  transform: `translateX(-50%) translateY(${thumb.top}px)`,
                }}
              />
            </div>

            <div
              aria-hidden="true"
              className={`pointer-events-none absolute bottom-3 left-1/2 z-20 -translate-x-1/2 transition-opacity duration-300 motion-reduce:transition-none ${
                scrollable && progress < 94 ? "opacity-100" : "opacity-0"
              }`}
            >
              <span className="inline-flex items-center gap-1.5 rounded-full bg-zinc-900/85 px-3 py-1 text-[11px] font-medium text-white shadow-lg backdrop-blur dark:bg-white/90 dark:text-zinc-900">
                Scroll for more
                <svg
                  viewBox="0 0 24 24"
                  className="scbrnd-hint h-3 w-3"
                  fill="none"
                  stroke="currentColor"
                  strokeWidth="2.4"
                  strokeLinecap="round"
                  strokeLinejoin="round"
                >
                  <path d="M6 9l6 6 6-6" />
                </svg>
              </span>
            </div>
          </div>
        </motion.div>

        <p className="mt-4 text-center text-xs text-zinc-400 dark:text-zinc-500">
          Drag the pill, click the track to page, or focus it and use ↑ ↓ ·
          Page&nbsp;Up / Page&nbsp;Down · Home / End.
        </p>
      </div>

      <style>{STYLES}</style>
    </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 →