Pagination Dog Ear

Pagination

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.

Install
npx shadcn add https://design.helpmarq.com/r/pagination-dog-ear.json
Source
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;
  onChange: (page: number) => void;
  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(--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(--muted);}
.ns-ft-nav-btn:disabled{opacity:0.35;cursor:default;}
.ns-ft-nav-btn:focus-visible{outline:2px solid var(--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(--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(--muted);}
.ns-ft-page:focus-visible{outline:2px solid var(--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(--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;}
}
`;
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(--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.

Tags
paginationnavigationnavdog-eararia-currentroving-tabindexbook