Skip to content
Component

Doc pager

Previous and next cards at the foot of a document, drawn only for the sides that exist.

  • #arc
  • #component
  • #pager
  • #navigation
  • #docs
  • #prev
  • #next

Install

One command installs the files and packages this item needs.

Terminalbash
npx shadcn@latest add https://dsui.vercel.app/r/doc-pager.json

doc-pager.module.csscss
.pager { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-4); }
/* A lone "next" keeps the right-hand column, so which way it leads stays obvious. */
.link[data-dir="next"]:only-child { grid-column: 2; }
.link { display: flex; min-width: 0; flex-direction: column; gap: var(--space-1); border: 1px solid var(--border); border-radius: var(--radius-panel); padding: var(--space-5); background: var(--surface); text-decoration: none; -webkit-tap-highlight-color: transparent; transition: border-color var(--duration-fast) var(--ease-standard), box-shadow var(--duration-fast) var(--ease-standard), transform var(--duration-fast) var(--ease-standard); }
.link[data-dir="next"] { align-items: flex-end; text-align: right; }
.hint { display: inline-flex; align-items: center; gap: var(--space-1); color: var(--text-secondary); font-size: var(--text-xs); font-weight: var(--label-weight); }
.hint svg { flex: none; }
.title { color: var(--foreground); font-size: var(--text-base); font-weight: 600; line-height: 1.4; text-wrap: balance; }
@media (hover: hover) and (pointer: fine) {
  .link:hover { border-color: var(--border-strong); box-shadow: var(--shadow-raised); transform: translateY(-2px); }
  .link:hover .hint { color: var(--accent); }
}
.link:active { box-shadow: var(--shadow-resting); transform: translateY(0); }
/* Side by side they would each be too narrow to read on a phone. */
@media (max-width: 599px) {
  .pager { grid-template-columns: minmax(0, 1fr); }
  .link[data-dir="next"]:only-child { grid-column: 1; }
}
@media (prefers-reduced-motion: reduce) { .link { transition: none; } .link:hover, .link:active { transform: none; } }
doc-pager.tsxtsx
"use client";

import { ArcLink } from "../lib/arc-provider";
import { playUiSound } from "../sound/play";
import { UiIcon } from "../theme/ui-icon";
import styles from "./doc-pager.module.css";

export interface DocPagerLink {
  href: string;
  title: string;
}

export interface DocPagerProps {
  prev?: DocPagerLink;
  next?: DocPagerLink;
  /** The accessible name of the pair, e.g. "이전 다음 문서". */
  label?: string;
  /** The words above each title. */
  labels?: { prev: string; next: string };
  className?: string;
}

/**
 * Previous and next at the foot of a document. Only the sides that exist are drawn, and the next card keeps its place
 * on the right even when there is no previous one, so the direction stays readable. With both missing it renders
 * nothing rather than an empty rule.
 */
export function DocPager({ prev, next, label = "이전 다음 문서", labels = { prev: "이전", next: "다음" }, className }: DocPagerProps) {
  if (!prev && !next) return null;
  return (
    <nav aria-label={label} data-print="hide" className={[styles.pager, className].filter(Boolean).join(" ")}>
      {prev ? (
        <ArcLink href={prev.href} rel="prev" data-dir="prev" className={styles.link} onClick={() => playUiSound("tick")}>
          <span className={styles.hint}>
            <UiIcon iconRole="arrow-left" size={14} strokeWidth={1.75} aria-hidden="true" />
            {labels.prev}
          </span>
          <span className={styles.title}>{prev.title}</span>
        </ArcLink>
      ) : null}
      {next ? (
        <ArcLink href={next.href} rel="next" data-dir="next" className={styles.link} onClick={() => playUiSound("tick")}>
          <span className={styles.hint}>
            {labels.next}
            <UiIcon iconRole="arrow-right" size={14} strokeWidth={1.75} aria-hidden="true" />
          </span>
          <span className={styles.title}>{next.title}</span>
        </ArcLink>
      ) : null}
    </nav>
  );
}

DocPager.displayName = "DocPager";

export default DocPager;