Skip to main content

ns-ui

Pagination Dog Ear

A pagination control rendered as book folios: paper cards for each page number, a raised current page, and a CSS corner dog-ear that curls further as you hover prev/next to peek the destination number, leaving a small permanent crease on every page you've visited.

Usage

registry/core/pagination-dog-ear/demo.tsx
"use client";

import { useEffect, useState } from "react";
import { FolioTurn } from "./component";

const COUNT = 6;
// Cycles forward through every page then jumps back, self-driving through
// onChange exactly like a pointing user clicking next repeatedly, no
// pointer/keyboard events required in the loop.
const STEP_MS = 1800;

const ITEMS: Record<number, string[]> = {
  1: ["Cover letter", "Table of contents"],
  2: ["Chapter I — Departure", "Chapter II — The Coast Road"],
  3: ["Chapter III — The Junction", "Chapter IV — Overnight Hold"],
  4: ["Chapter V — Inland", "Chapter VI — Arrival"],
  5: ["Appendix A — Manifest", "Appendix B — Route Notes"],
  6: ["Index", "Colophon"],
};

export default function FolioTurnDemo() {
  const [page, setPage] = useState(1);

  useEffect(() => {
    const timer = setInterval(() => {
      setPage((p) => (p >= COUNT ? 1 : p + 1));
    }, STEP_MS);
    return () => clearInterval(timer);
  }, []);

  return (
    <div className="flex min-h-screen flex-col items-center justify-center gap-8 px-6 py-16">
      <p className="font-mono text-xs uppercase tracking-[0.2em] text-ns-muted">
        ns-ui / pagination-dog-ear
      </p>

      <div className="flex w-full max-w-md flex-col gap-4 rounded-[12px] border border-border bg-background p-6">
        <span className="font-mono text-[10px] uppercase tracking-[0.3em] text-ns-muted">
          Table of contents
        </span>
        <ul className="flex flex-col gap-2">
          {ITEMS[page]!.map((line) => (
            <li key={line} className="text-sm text-foreground">
              {line}
            </li>
          ))}
        </ul>
      </div>

      <FolioTurn page={page} count={COUNT} onChange={setPage} />

      <p className="max-w-md text-center text-xs text-ns-muted">
        Hover next/prev to peek the corner curl on the current page; visited
        pages keep a small permanent crease.
      </p>
    </div>
  );
}

Component

registry/core/pagination-dog-ear/component.tsx
"use client";

import { useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react";

// ---------------------------------------------------------------------------
// FolioTurn — pagination rendered as a row of book folios instead of bare
// numbers. Every button is a paper "card"; the current one sits 1px raised
// with a soft shadow. Each card carries a corner dog-ear built from the
// classic CSS border-triangle technique (an element with all size in
// border-width and only two adjacent border colors opaque) so it costs no
// extra markup layer — just a --curl custom property driving border-width.
//
// Three distinct curl behaviors, all on that same triangle:
//   - hovering ANY page number: its own corner dog-ear grows a few px and
//     its ink darkens (a light "this is interactive" tell).
//   - hovering prev/next: the CURRENT page's corner grows further and a
//     small preview of the destination number fades in beside it — you're
//     peeking at what prev/next would turn to, not what you're hovering.
//   - completing a turn (the `page` prop actually changes, from any
//     trigger): the outgoing page's corner grows the rest of the way while
//     the card itself gets a brief eased tilt+fade, then the new current
//     page settles into its raised resting state. A once-visited page
//     keeps a small permanent crease (a second, smaller, static triangle)
//     forever after.
//
// Keyboard is a roving-tabindex toolbar (prev, every page number, next, all
// one tab stop): ArrowLeft/ArrowRight move focus by one among the ENABLED
// controls, Home/End jump to the first/last, matching the roving pattern
// used elsewhere in this registry (dropdown-drape, accordion-latch) rather than a
// custom listbox. Enter/Space activate the focused control natively.
// `<nav aria-label>` + `aria-current="page"` on the current button is the
// only semantic layer; there is no live region because the page number
// itself, visibly raised, is the state.
//
// prefers-reduced-motion: no curl growth, no tilt/fade on turn, pages swap
// instantly — the permanent visited-crease still appears (it is not an
// animation, just a static mark) so history stays visible either way.
// ---------------------------------------------------------------------------

export interface FolioTurnProps {
  /** Current page, 1-indexed. */
  page: number;
  /** Total number of pages. */
  count: number;
  /** called with the new 1-indexed page when a different page is chosen */
  onChange: (page: number) => void;
  /** extra classes merged onto the rendered root element */
  className?: string;
}

const TURN_MS = 280;

export function FolioTurn({ page, count, onChange, className = "" }: FolioTurnProps) {
  const [hoverPage, setHoverPage] = useState<number | null>(null);
  const [peekDir, setPeekDir] = useState<0 | -1 | 1>(0);
  const [turning, setTurning] = useState<{ from: number } | null>(null);
  const [visited, setVisited] = useState<Set<number>>(() => new Set([page]));
  const reducedRef = useRef(false);
  const prevPageRef = useRef(page);
  const turnTimerRef = useRef<number | undefined>(undefined);

  // Flat control list: index 0 = prev, 1..count = page numbers, count+1 = next.
  const total = count + 2;
  const btnRefs = useRef<Array<HTMLButtonElement | null>>([]);
  const isDisabled = (i: number) => (i === 0 && page <= 1) || (i === total - 1 && page >= count);
  const enabledIndices = Array.from({ length: total }, (_, i) => i).filter((i) => !isDisabled(i));

  useEffect(() => {
    const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
    reducedRef.current = mq.matches;
    const onMq = () => {
      reducedRef.current = mq.matches;
    };
    mq.addEventListener("change", onMq);
    return () => mq.removeEventListener("change", onMq);
  }, []);

  useEffect(() => {
    const prev = prevPageRef.current;
    prevPageRef.current = page;
    setVisited((v) => (v.has(page) ? v : new Set(v).add(page)));
    if (prev === page) return;
    window.clearTimeout(turnTimerRef.current);
    if (reducedRef.current) return;
    setTurning({ from: prev });
    turnTimerRef.current = window.setTimeout(() => setTurning(null), TURN_MS);
    return () => window.clearTimeout(turnTimerRef.current);
  }, [page]);

  const onKeyDown = (e: ReactKeyboardEvent<HTMLButtonElement>, i: number) => {
    const pos = enabledIndices.indexOf(i);
    let nextPos = -1;
    if (e.key === "ArrowLeft") nextPos = pos <= 0 ? enabledIndices.length - 1 : pos - 1;
    else if (e.key === "ArrowRight") nextPos = (pos + 1) % enabledIndices.length;
    else if (e.key === "Home") nextPos = 0;
    else if (e.key === "End") nextPos = enabledIndices.length - 1;
    if (nextPos >= 0) {
      e.preventDefault();
      btnRefs.current[enabledIndices[nextPos]!]?.focus();
    }
  };

  const go = (n: number) => {
    if (n < 1 || n > count || n === page) return;
    onChange(n);
  };

  return (
    <nav aria-label="Pagination" className={`ns-ft-nav ${className}`}>
      <style>{CSS}</style>
      <ul className="ns-ft-list">
        <li>
          <button
            ref={(el) => {
              btnRefs.current[0] = el;
            }}
            type="button"
            aria-label="Previous page"
            disabled={page <= 1}
            tabIndex={enabledIndices[0] === 0 ? 0 : -1}
            className="ns-ft-nav-btn"
            onClick={() => go(page - 1)}
            onKeyDown={(e) => onKeyDown(e, 0)}
            onPointerEnter={() => setPeekDir(-1)}
            onPointerLeave={() => setPeekDir(0)}
          >
            ‹
          </button>
        </li>
        {Array.from({ length: count }, (_, i) => i + 1).map((n) => {
          const flatIndex = n;
          const isCurrent = n === page;
          const isHover = hoverPage === n;
          const isVisited = visited.has(n) && !isCurrent;
          const isTurningFrom = turning?.from === n;
          const showNavPeek = isCurrent && peekDir !== 0;
          const peekNumber = showNavPeek ? page + peekDir : null;
          const curl = isTurningFrom ? 1 : isHover ? 0.55 : showNavPeek ? 0.7 : 0;
          return (
            <li key={n} className="ns-ft-item">
              <button
                ref={(el) => {
                  btnRefs.current[flatIndex] = el;
                }}
                type="button"
                aria-label={`Page ${n}`}
                aria-current={isCurrent ? "page" : undefined}
                tabIndex={enabledIndices[0] === flatIndex ? 0 : -1}
                className="ns-ft-page"
                data-current={isCurrent || undefined}
                data-turning={isTurningFrom || undefined}
                data-visited={isVisited || undefined}
                data-peek={showNavPeek || undefined}
                style={{ "--curl": curl } as React.CSSProperties}
                onClick={() => go(n)}
                onKeyDown={(e) => onKeyDown(e, flatIndex)}
                onPointerEnter={() => setHoverPage(n)}
                onPointerLeave={() => setHoverPage((h) => (h === n ? null : h))}
              >
                <span className="ns-ft-num">{n}</span>
                {peekNumber && peekNumber >= 1 && peekNumber <= count && (
                  <span className="ns-ft-peek" aria-hidden="true">
                    {peekNumber}
                  </span>
                )}
                <span className="ns-ft-curl" aria-hidden="true" />
                {isVisited && <span className="ns-ft-crease" aria-hidden="true" />}
              </button>
            </li>
          );
        })}
        <li>
          <button
            ref={(el) => {
              btnRefs.current[total - 1] = el;
            }}
            type="button"
            aria-label="Next page"
            disabled={page >= count}
            tabIndex={enabledIndices[0] === total - 1 ? 0 : -1}
            className="ns-ft-nav-btn"
            onClick={() => go(page + 1)}
            onKeyDown={(e) => onKeyDown(e, total - 1)}
            onPointerEnter={() => setPeekDir(1)}
            onPointerLeave={() => setPeekDir(0)}
          >
            ›
          </button>
        </li>
      </ul>
    </nav>
  );
}

const CSS = `
.ns-ft-nav{display:inline-block;}
.ns-ft-list{display:flex;align-items:flex-end;gap:6px;list-style:none;margin:0;padding:0;}
.ns-ft-nav-btn{
  display:flex;align-items:center;justify-content:center;
  width:32px;height:38px;
  border:1px solid var(--border);border-radius:4px;
  background:var(--background);color:var(--ns-muted);
  font-size:16px;line-height:1;cursor:pointer;
  transition:color 150ms ease, border-color 150ms ease;
}
.ns-ft-nav-btn:not(:disabled):hover{color:var(--foreground);border-color:var(--ns-muted);}
.ns-ft-nav-btn:disabled{opacity:0.35;cursor:default;}
.ns-ft-nav-btn:focus-visible{outline:2px solid var(--ns-accent);outline-offset:1px;}

.ns-ft-item{position:relative;}
.ns-ft-page{
  --curl:0;
  position:relative;
  overflow:hidden;
  width:34px;height:40px;
  border:1px solid var(--border);border-radius:4px;
  background:var(--background);
  color:var(--ns-muted);
  font-family:var(--font-geist-mono, ui-monospace, monospace);
  font-size:13px;
  cursor:pointer;
  transform:translateY(0);
  box-shadow:none;
  transition:color 150ms ease, transform 200ms cubic-bezier(0.16,1,0.3,1), box-shadow 200ms ease, border-color 150ms ease;
}
.ns-ft-page:hover{color:var(--foreground);border-color:var(--ns-muted);}
.ns-ft-page:focus-visible{outline:2px solid var(--ns-accent);outline-offset:1px;}
.ns-ft-page[data-current]{
  color:var(--foreground);
  transform:translateY(-1px);
  box-shadow:0 2px 4px -1px rgba(0,0,0,0.35);
  border-color:var(--foreground);
}
.ns-ft-page[data-turning]{
  transition:transform ${TURN_MS}ms cubic-bezier(0.55,0,0.85,0.35), opacity ${TURN_MS}ms ease;
  transform:translateY(-1px) rotateZ(-3deg) translateX(-2px);
  opacity:0.75;
}

.ns-ft-num{position:relative;z-index:1;}
.ns-ft-peek{
  position:absolute;
  top:3px;right:3px;
  font-size:9px;
  color:var(--ns-muted);
  opacity:0;
  transition:opacity 200ms ease;
  z-index:1;
}
.ns-ft-page[data-peek] .ns-ft-peek{opacity:1;}

.ns-ft-curl{
  position:absolute;
  top:0;right:0;
  width:0;height:0;
  border-style:solid;
  border-color:transparent var(--border) transparent transparent;
  border-width:0 calc(var(--curl) * 16px) calc(var(--curl) * 16px) 0;
  transition:border-width 220ms cubic-bezier(0.22,1,0.36,1);
}

.ns-ft-crease{
  position:absolute;
  bottom:0;right:0;
  width:0;height:0;
  border-style:solid;
  border-width:0 0 7px 7px;
  border-color:transparent transparent var(--border) transparent;
}

@media (prefers-reduced-motion: reduce){
  .ns-ft-page,.ns-ft-curl,.ns-ft-peek{transition:none !important;}
}
`;

The usage example imports ./component because it sits next to the component file in this repo. Installed, that file lands at components/ui/pagination-dog-ear.tsx, so point the import there.

Use when paged results where the pages should read as a physical set the reader is moving through, hovering prev or next curling a corner to peek the destination number and every visited page keeping a permanent crease; pick breadcrumb-fold or breadcrumb-overflow-menu instead when the navigation is a hierarchy rather than a flat ordered sequence, since a dog-ear encodes visited-ness and adjacency and a hierarchy has neither.

Install

npx shadcn add https://design.helpmarq.com/r/pagination-dog-ear.json

Ask AI

Point an assistant at this component's docs (llms-full.txt) with one click.

Claude, ChatGPT, Grok, and Perplexity open with the prompt already in. Gemini copies it to your clipboard first. Paste it in once the chat opens.

Build spec

Build a pagination control (`page: number` 1-indexed, `count: number`, `onChange: (page: number) => void`) rendered as a row of book folios rather than bare numbers. Every page number is a `<button>` 'card' (fixed size, hairline `var(--border)` outline, `var(--background)` fill, Geist Mono glyph) inside a `<nav aria-label="Pagination">`; the current page carries `aria-current="page"`, sits `translateY(-1px)` raised with a soft `box-shadow`, and its border darkens to `var(--foreground)`. Every card has a corner dog-ear built from the classic CSS border-triangle technique — an absolutely positioned 0x0 element whose `border-width` on two adjacent sides is driven by a `--curl` custom property (0 to 1) and whose other two border sides stay `transparent`, so the shape is a right-triangle wedge in the top-right corner with zero extra DOM. `--curl` responds to three distinct situations, each with a different target value and reason: (1) hovering ANY page's own button sets that button's `--curl` to ~0.55 and darkens its ink from `var(--ns-muted)` to `var(--foreground)` — a light 'this is interactive' tell on every number, not just the current one; (2) hovering the prev/next control (which does NOT change `--curl` on itself) instead sets the CURRENT page's `--curl` to ~0.7 and reveals a small absolutely-positioned preview span near the corner showing `page + direction` (clamped to a valid page, hidden otherwise) — this is peeking at the destination the turn would land on, so it lives on the current card, not the one under the cursor; (3) completing an actual page change (react to the `page` prop changing, not just the click that caused it, so external control changes animate identically) plays the OUTGOING page's `--curl` to a full 1 while that same card also gets a brief 280ms eased tilt (`transform: translateY(-1px) rotateZ(-3deg) translateX(-2px)`, `cubic-bezier(0.55,0,0.85,0.35)`) and opacity dip to 0.75, reverting automatically once the timer clears — this reads as the page lifting and turning away as the new one becomes current. Every page that has ever been the current page (tracked in a `Set<number>`, seeded with the initial page) keeps a SECOND, smaller, permanently static triangle in the opposite (bottom-right) corner once it's no longer current — a fixed-size crease independent of `--curl`, so page history stays visibly marked even after the interactive curl relaxes back to 0. Keyboard is a roving-tabindex toolbar spanning prev, every page button, and next as one sequence: only one control is ever `tabIndex=0` (the first non-disabled control, recalculated as availability changes at the ends), `ArrowLeft`/`ArrowRight` move focus to the previous/next ENABLED control (wrapping), `Home`/`End` jump to the first/last enabled control, and Enter/Space activate the focused button via native `<button>` behavior — no custom activation handling needed. Prev is `disabled` at page 1, next at the last page, both with a visible dimmed state and excluded from the roving sequence while disabled. `prefers-reduced-motion: reduce` removes the curl-growth, tilt, and peek-fade transitions entirely (everything still functions, `--curl` and the turn state still flip, but with `transition:none` so page changes are instant); the small permanent visited-crease is not an animation and is unaffected either way — it should still mark history even with motion off. Zero dependencies, no SVG, no canvas, no scrollHeight measurement.

Props

PropTypeDefaultDescription
pagenumber—Current page, 1-indexed.
countnumber—Total number of pages.
onChange(page: number) => void—called with the new 1-indexed page when a different page is chosen
className?string—extra classes merged onto the rendered root element