DHxWhy

Scrub Pagination – Drag a Track to Scrub Pages, Ellipsis Morphs into Jump Input

Navigation

About

Scrub Pagination for React and Tailwind CSS (shadcn/ui): page numbers over a thin track that scrubs hundreds of pages at once. It is built to reach page 37 of 120 in one drag. A click slides the pill and rolls the numbers; the dragged handle shows a live “Page 37 / 120” tooltip and snaps on release; the ellipsis morphs into a jump field. onPageChange fires only on commit while onScrub reports the live page; controlled or uncontrolled, a keyboard role="slider" handle, aria-current, reduced motion, light and dark. Use it for data tables, search results and log viewers. The included demo pages through 720 invoices and makes no requests. Requires React only, with no animation library.

Built with React and Tailwind. Copy the prompt and paste it into Claude Code, Cursor or Lovable: your agent adds Scrub Pagination – Drag a Track to Scrub Pages, Ellipsis Morphs into Jump Input to your project and fits it to your theme.

Code

How Scrub Pagination – Drag a Track to Scrub Pages, Ellipsis Morphs into Jump Input is used: the demo's code. The component's own source comes with a free account.

Usage.tsx

"use client";

import * as React from "react";
import { ScrubPagination } from "@/components/ui/scrub-pagination";

const PAGE_COUNT = 120;
const PER_PAGE = 6;
const TOTAL = PAGE_COUNT * PER_PAGE;
const CYCLE = 9000;
const POINTER_ID = 9007;
const EASE = "cubic-bezier(0.22, 1, 0.36, 1)";
const CUSTOMERS = [
  "Acme Design", "Northwind Studio", "Lumen Labs", "Harbor & Co.", "Pinecone Media", "Juniper Health", "Oakline Foods", "Quartz Analytics",
  "Mosaic Retail", "Ferro Works", "Saltmarsh Books", "Tidewater Energy", "Birchwood Clinic", "Copperfield Co.", "Granite Logistics", "Meadow Fintech",
];
type Status = "Paid" | "Pending" | "Overdue";
type Row = { id: string; customer: string; amount: string; status: Status };

const money = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0 });
const STATUS_CLASS = {
  Paid: "bg-muted text-muted-foreground",
  Pending: "bg-chart-4/15 text-foreground",
  Overdue: "bg-destructive/10 text-destructive",
} as const;

function rowsFor(page: number): Row[] {
  return Array.from({ length: PER_PAGE }, (_, offset) => {
    const index = (page - 1) * PER_PAGE + offset;
    const seed = (index * 2654435761) >>> 0;
    const customer = CUSTOMERS[seed % CUSTOMERS.length] ?? "Acme Design";
    const amount = 120 + ((seed >>> 8) % 4880);
    const bucket = (seed >>> 20) % 10;
    const status: Status = bucket < 6 ? "Paid" : bucket < 9 ? "Pending" : "Overdue";
    return { id: `#${10231 + index}`, customer, amount: money.format(amount), status };
  });
}

const easeInOut = (progress: number) => (progress < 0.5 ? 4 * progress ** 3 : 1 - (-2 * progress + 2) ** 3 / 2);

function fire(target: EventTarget, type: string, clientX: number, clientY: number) {
  const released = type === "pointerup" || type === "pointercancel";
  target.dispatchEvent(new PointerEvent(type, {
    bubbles: true, cancelable: true, composed: true, pointerId: POINTER_ID, pointerType: "mouse", isPrimary: true,
    button: 0, buttons: released ? 0 : 1, clientX, clientY,
  }));
}

function press(root: HTMLElement, selector: string) {
  const node = root.querySelector(selector);
  if (node instanceof HTMLElement) node.click();
}

function pressLastBeforeEllipsis(root: HTMLElement) {
  const buttons = root.querySelectorAll("[data-page]");
  const node = buttons[buttons.length - 2];
  if (node instanceof HTMLElement) node.click();
}

function typeDigit(root: HTMLElement, digit: string) {
  const field = root.querySelector("[data-jump-input]");
  if (!(field instanceof HTMLInputElement)) return;
  field.value = `${field.value}${digit}`;
  field.dispatchEvent(new Event("input", { bubbles: true }));
}

function pressKey(root: HTMLElement, key: string) {
  const field = root.querySelector("[data-jump-input]");
  if (!(field instanceof HTMLInputElement)) return;
  field.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }));
}

function simulateScrub(root: HTMLElement, targetPage: number, duration: number, hold: number, done: () => void) {
  const nav = root.querySelector('[data-slot="scrub-pagination"]');
  const wrap = root.querySelector("[data-track-wrap]");
  const thumb = root.querySelector("[data-thumb]");
  if (!(nav instanceof HTMLElement) || !(wrap instanceof HTMLElement) || !(thumb instanceof HTMLElement)) { done(); return () => {}; }
  const count = Number(nav.dataset.count) || PAGE_COUNT;
  const track = wrap.getBoundingClientRect();
  const grip = thumb.getBoundingClientRect();
  const half = grip.width / 2;
  const x0 = grip.left + half;
  const y = grip.top + grip.height / 2;
  const x1 = track.left + half + ((targetPage - 1) / (count - 1)) * (track.width - grip.width);
  fire(thumb, "pointerdown", x0, y);
  let frame = 0;
  let timer = 0;
  let released = false;
  const started = performance.now();
  const tick = (now: number) => {
    const progress = Math.min(1, (now - started) / duration);
    fire(window, "pointermove", x0 + (x1 - x0) * easeInOut(progress), y);
    if (progress < 1) { frame = requestAnimationFrame(tick); return; }
    timer = window.setTimeout(() => { released = true; fire(window, "pointerup", x1, y); done(); }, hold);
  };
  frame = requestAnimationFrame(tick);
  return () => {
    cancelAnimationFrame(frame);
    window.clearTimeout(timer);
    if (!released) { released = true; fire(window, "pointercancel", x0, y); }
  };
}

export default function Demo({ autoplay = true }: { autoplay?: boolean }) {
  const params = React.useMemo(() => new URLSearchParams(typeof location === "undefined" ? "" : location.search), []);
  const freeze = params.get("freeze") === "1";
  const siblingsParam = params.get("siblings");
  const sizeParam = params.get("size");
  const showTrack = params.get("track") !== "0";
  const cardRef = React.useRef(null as HTMLElement | null);
  const listRef = React.useRef(null as HTMLDivElement | null);
  const prevPage = React.useRef(1);
  const [page, setPage] = React.useState(1);
  const [scrubbed, setScrubbed] = React.useState("");
  const [scrubs, setScrubs] = React.useState(0);
  const [changes, setChanges] = React.useState(0);
  const [requested, setRequested] = React.useState("");
  const [stopped, setStopped] = React.useState(false);
  const [stage, setStage] = React.useState("idle");
  const [layout, setLayout] = React.useState({ size: "md" as "sm" | "md", siblingCount: 2 });
  const size = sizeParam === "sm" || sizeParam === "md" ? sizeParam : layout.size;
  const siblingCount = siblingsParam === null ? layout.siblingCount : Math.max(0, Number(siblingsParam) || 0);
  const rows = rowsFor(page);
  const first = (page - 1) * PER_PAGE + 1;
  const last = Math.min(TOTAL, page * PER_PAGE);

  React.useLayoutEffect(() => {
    const card = cardRef.current;
    if (!card) return;
    const apply = (width: number) => setLayout(width < 480 ? { size: "sm", siblingCount: 0 } : width < 640 ? { size: "sm", siblingCount: 1 } : { size: "md", siblingCount: 2 });
    apply(card.clientWidth);
    if (typeof ResizeObserver === "undefined") return;
    const observer = new ResizeObserver((entries) => { for (const entry of entries) apply(entry.contentRect.width); });
    observer.observe(card);
    return () => observer.disconnect();
  }, []);

  React.useLayoutEffect(() => {
    const from = prevPage.current;
    prevPage.current = page;
    const list = listRef.current;
    if (from === page || !list || typeof list.animate !== "function" || window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
    const shift = page > from ? 18 : -18;
    list.animate([{ opacity: 0.15, transform: `translateX(${shift}px)` }, { opacity: 1, transform: "translateX(0)" }], { duration: 180, easing: EASE });
  }, [page]);

  React.useEffect(() => {
    if (!autoplay || stopped) return;
    const root = cardRef.current;
    if (!root) return;
    const timers: number[] = [];
    let cancelScrub = () => {};
    const run = () => {
      const at = (ms: number, task: () => void) => timers.push(window.setTimeout(task, ms));
      at(500, () => { setStage("click"); pressLastBeforeEllipsis(root); });
      at(1800, () => { setStage("scrub"); cancelScrub = simulateScrub(root, 37, 1600, 200, () => setStage("idle")); });
      at(4600, () => { setStage("jump-open"); press(root, '[data-ellipsis="end-ellipsis"]'); });
      at(5200, () => typeDigit(root, "1"));
      at(5330, () => typeDigit(root, "2"));
      at(5460, () => typeDigit(root, "0"));
      at(5750, () => { setStage("jump"); pressKey(root, "Enter"); });
      at(6500, () => setStage("idle"));
      at(8300, () => { setStage("rewind"); setPage(1); });
      at(8800, () => setStage("idle"));
    };
    run();
    const loop = window.setInterval(run, CYCLE);
    return () => {
      window.clearInterval(loop);
      for (const timer of timers) window.clearTimeout(timer);
      cancelScrub();
    };
  }, [autoplay, stopped]);

  return (
    <main
      data-demo-root
      data-stage={stage}
      data-playing={autoplay && !stopped ? "true" : "false"}
      data-page={page}
      data-scrubbed={scrubbed}
      data-scrubs={scrubs}
      data-changes={changes}
      data-requested={requested}
      className="flex min-h-[min(100svh,760px)] w-full items-center justify-center bg-background px-4 py-6 text-foreground sm:px-6"
    >
      <section ref={cardRef} aria-labelledby="invoices-title" className="w-full max-w-[720px] overflow-hidden rounded-2xl bg-card text-card-foreground shadow-sm ring-1 ring-border">
        <header className="flex items-end justify-between gap-3 px-4 pt-4 pb-3 sm:px-5 sm:pt-5">
          <div>
            <h2 id="invoices-title" className="text-sm font-semibold tracking-tight">Invoices</h2>
            <p className="mt-0.5 text-xs text-muted-foreground">{TOTAL.toLocaleString("en-US")} invoices · {PER_PAGE} per page</p>
          </div>
          <p data-range className="text-xs tabular-nums text-muted-foreground">Showing {first}–{last} of {TOTAL.toLocaleString("en-US")}</p>
        </header>
        <div className="px-2 sm:px-3">
          <div className="grid h-8 grid-cols-[84px_minmax(0,1fr)_84px_76px] items-center gap-3 border-b border-border px-3 text-[11px] font-medium tracking-wide text-muted-foreground uppercase">
            <span>Invoice</span>
            <span>Customer</span>
            <span className="text-right">Amount</span>
            <span className="text-right">Status</span>
          </div>
          <div ref={listRef} data-list data-page={page} className="divide-y divide-border/60">
            {rows.map((row) => (
              <div key={row.id} data-row className="grid h-11 grid-cols-[84px_minmax(0,1fr)_84px_76px] items-center gap-3 px-3 text-[13px]">
                <span className="font-mono text-xs tabular-nums text-muted-foreground">{row.id}</span>
                <span className="truncate font-medium">{row.customer}</span>
                <span className="text-right tabular-nums">{row.amount}</span>
                <span className="flex justify-end">
                  <span data-status={row.status} className={`inline-flex h-5 items-center rounded-full px-2 text-[11px] font-medium ${STATUS_CLASS[row.status]}`}>{row.status}</span>
                </span>
              </div>
            ))}
          </div>
        </div>
        <footer className="border-t border-border px-4 pt-4 pb-4 sm:px-5">
          <ScrubPagination
            page={page}
            pageCount={PAGE_COUNT}
            siblingCount={siblingCount}
            size={size}
            showTrack={showTrack}
            onPageChange={(next) => { setChanges((n) => n + 1); setRequested(String(next)); if (!freeze) setPage(next); }}
            onScrub={(next) => { setScrubbed(String(next)); setScrubs((n) => n + 1); }}
            onInteractionStart={(event) => { if (event.isTrusted) setStopped(true); }}
          />
        </footer>
      </section>
    </main>
  );
}

Dependencies

More by DHxWhy

View all
  • Product Rail Gallery – Fashion Store Hero Section

    Product Rail Gallery – Fashion Store Hero Section

    2.2K
  • Sushi Conveyor Belt Menu – Restaurant Hero Section

    Sushi Conveyor Belt Menu – Restaurant Hero Section

    883
  • Team Lanyard Rail – Meet the Team Section with Flip Badges

    Team Lanyard Rail – Meet the Team Section with Flip Badges

    827
  • Skateboard Deck Rail Gallery – Skate Shop Hero Section

    Skateboard Deck Rail Gallery – Skate Shop Hero Section

    430
  • Sun Moon Dark Mode Theme Toggle

    Sun Moon Dark Mode Theme Toggle

    328
  • Picture Rail Image Gallery – Art Print Shop Hero Section

    Picture Rail Image Gallery – Art Print Shop Hero Section

    268
  • Pendant Lamp Rail Gallery – Lighting Store Hero Section

    Pendant Lamp Rail Gallery – Lighting Store Hero Section

    243
  • World Testimonials Globe – Social Proof Hero Section

    World Testimonials Globe – Social Proof Hero Section

    217

More in Navigation

View all
  • Sidebar

    Sidebar

    341
  • Pagination

    Pagination

    99
  • Oversized 404 Numeral

    Oversized 404 Numeral

    68
  • Stacked Circular Footer

    Stacked Circular Footer

    443
  • Navbar

    Navbar

    6
  • Navbar

    Navbar

    74
  • Tabs like bookmark

    Tabs like bookmark

    73
  • Tabs

    Tabs

    33