{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "prev-next-post",
  "type": "registry:ui",
  "title": "Prev Next Post",
  "description": "Paired previous / next link cards rendered at the foot of an article. The canonical \"what to read next\" surface — improves session depth, reduces bounce, and gives keyboard / screen-reader users a deterministic exit ramp from long-form content. Wrapped in…",
  "author": "ofri-peretz <https://github.com/ofri-peretz>",
  "categories": [
    "blog",
    "pattern"
  ],
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "https://ds.interlace.tools/r/theme.json",
    "https://ds.interlace.tools/r/cn.json",
    "https://ds.interlace.tools/r/skeleton.json"
  ],
  "files": [
    {
      "path": "registry/interlace-ui/patterns/prev-next-post.tsx",
      "target": "components/ui/patterns/prev-next-post.tsx",
      "type": "registry:ui",
      "content": "import * as React from 'react';\n\n// @interlace/prev-next-post v1.3.0 — Interlace design system.\n// Docs, props and live preview: https://ds.interlace.tools/c/prev-next-post\n// What changed since: https://ds.interlace.tools/c/prev-next-post#history\n// Generated banner — keep it, the upgrade diff reads this version.\n\n/**\n * @interlace/ui — PrevNextPost\n *\n * Paired previous / next link cards rendered at the foot of an article. The\n * canonical \"what to read next\" surface — improves session depth, reduces\n * bounce, and gives keyboard / screen-reader users a deterministic exit ramp\n * from long-form content. Wrapped in a `<nav aria-label=\"Article navigation\">`\n * so AT users can jump to it from the landmarks rotor.\n *\n * Either side is optional: a series-start post passes only `next`, a\n * series-end post passes only `prev`. When only one side is supplied the\n * grid still reserves two columns at `md` so the surviving card keeps its\n * column width — no layout shift between pages.\n *\n * ## Anatomy\n *\n *   PrevNextPost                       (nav — data-min-viewport=480)\n *     ├─ a [data-slot=\"prev-next-prev\"]   (left card — ArrowLeft + kicker + title)\n *     └─ a [data-slot=\"prev-next-next\"]   (right card — kicker + title + ArrowRight)\n *\n * ## MIN_VIEWPORT — 480\n *\n * Article-footer surface; below ~480 CSS px the two cards stack and the\n * arrow + title row becomes the dominant chrome of the screen, which is\n * fine. We don't promise design quality below 480 — phones at 360–390\n * still get a usable single-column stack via `grid-cols-1`, and the\n * preflight dev outline flags the regression if a consumer tries to embed\n * the block inside a narrower container.\n *\n * | Rule | Concept                          | Where in this file                                          |\n * | ---- | -------------------------------- | ----------------------------------------------------------- |\n * | R4   | Extends native el                | `React.ComponentProps<'nav'> & PrevNextPostProps`           |\n * | R6   | data-slot on root + parts        | `data-slot=\"prev-next-post\"` + per-card `prev-next-prev/next` |\n * | R7   | className merged + ...rest       | `cn(BASE, className)` + `{...props}`                        |\n * | R8   | No isXxx; enums for variants     | n/a — no boolean variants                                   |\n * | R10  | Composition seam                 | `prev` + `next` slots; `kicker` per side                    |\n * | R14  | Declares min viewport            | `data-min-viewport={String(MIN_VIEWPORT)}` + exported const |\n * | R18  | Tailwind only                    | Zero inline `style`; utility classes only                   |\n * | R19  | Tokens only                      | border-border / rounded-lg / p-md / gap-md / text-muted-foreground |\n * | R20  | AA contrast                      | Hover ring uses `primary/60`; base uses semantic tokens  |\n * | R25  | Server component                 | No hooks → no `'use client'`                                |\n * | R26  | A11y from native el              | `<a href>` per card; `<nav aria-label>` landmark            |\n */\n\nimport { ArrowLeft, ArrowRight } from 'lucide-react';\n\nimport { cn } from '@/lib/utils';\nimport { Skeleton } from '@/components/ui/skeleton';\n\nexport const MIN_VIEWPORT = 480 as const;\n\n/** One side of the pair. `kicker` is the eyebrow label, e.g. \"Previous\" or section name. */\nexport type PrevNextPostLink = {\n  /** Destination URL. */\n  href: string;\n  /** Card title — the post title or section name. */\n  title: React.ReactNode;\n  /** Optional eyebrow label above the title. Defaults to \"Previous\" / \"Next\" per side. */\n  kicker?: React.ReactNode;\n};\n\ntype PrevNextPostProps = Omit<React.ComponentProps<'nav'>, 'aria-label'> & {\n  /** Previous post — left card. Omit for the first post in a series. */\n  prev?: PrevNextPostLink;\n  /** Next post — right card. Omit for the last post in a series. */\n  next?: PrevNextPostLink;\n  /** Override the nav landmark label. @default \"Article navigation\" */\n  'aria-label'?: string;\n  /**\n   * When true, paint a `<Skeleton variant=\"prev-next-post\" />` pair instead\n   * of the links. The sibling titles usually arrive from the same async\n   * query as the article body, so without this the page footer jumps at the\n   * exact moment a reader reaches it. @default false\n   */\n  loading?: boolean;\n  /**\n   * Stable selector hook for E2E tests. Each side derives its own id\n   * (`{value}-prev`, `{value}-next`). Required — no default (R5).\n   */\n  'data-testid': string;\n};\n\nconst CARD_BASE =\n  'group flex flex-col gap-1 rounded-lg border border-border p-md no-underline transition-colors hover:border-primary/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2';\n\nexport function PrevNextPost({\n  className,\n  prev,\n  next,\n  loading,\n  'aria-label': ariaLabel = 'Article navigation',\n  'data-testid': testId,\n  ...props\n}: PrevNextPostProps) {\n  if (loading) {\n    return (\n      <nav\n        data-slot=\"prev-next-post\"\n        data-testid={testId}\n        data-min-viewport={String(MIN_VIEWPORT)}\n        aria-label={ariaLabel}\n        aria-busy=\"true\"\n        className={cn('w-full', className)}\n        {...props}\n      >\n        <Skeleton variant=\"prev-next-post\" />\n      </nav>\n    );\n  }\n\n  return (\n    <nav\n      data-slot=\"prev-next-post\"\n      data-testid={testId}\n      data-min-viewport={String(MIN_VIEWPORT)}\n      aria-label={ariaLabel}\n      className={cn('grid grid-cols-1 gap-md md:grid-cols-2', className)}\n      {...props}\n    >\n      {prev ? (\n        <a\n          href={prev.href}\n          data-slot=\"prev-next-prev\"\n          data-testid={`${testId}-prev`}\n          className={cn(CARD_BASE, 'items-start text-left')}\n        >\n          <span className=\"flex items-center gap-2 text-xs font-medium uppercase tracking-wide text-muted-foreground\">\n            <ArrowLeft className=\"size-4\" aria-hidden />\n            {prev.kicker ?? 'Previous'}\n          </span>\n          <span className=\"text-body font-medium text-foreground\">\n            {prev.title}\n          </span>\n        </a>\n      ) : (\n        // Empty cell keeps the next card right-aligned on md+ when prev is absent.\n        <span aria-hidden className=\"hidden md:block\" />\n      )}\n\n      {next ? (\n        <a\n          href={next.href}\n          data-slot=\"prev-next-next\"\n          data-testid={`${testId}-next`}\n          className={cn(CARD_BASE, 'items-end text-right md:col-start-2')}\n        >\n          <span className=\"flex items-center gap-2 text-xs font-medium uppercase tracking-wide text-muted-foreground\">\n            {next.kicker ?? 'Next'}\n            <ArrowRight className=\"size-4\" aria-hidden />\n          </span>\n          <span className=\"text-body font-medium text-foreground\">\n            {next.title}\n          </span>\n        </a>\n      ) : null}\n    </nav>\n  );\n}\n\nexport type { PrevNextPostProps };\n"
    }
  ],
  "meta": {
    "tier": "pattern",
    "client": false,
    "minViewport": 480,
    "loading": true,
    "version": "1.3.0",
    "since": "1.0.0"
  },
  "docs": "## @interlace/prev-next-post\n\nInstalled to `components/ui/patterns/prev-next-post.tsx`.\n\n```tsx\nimport { /* … */ } from '@/components/ui/patterns/prev-next-post';\n```\n\nProps, a11y contract, live preview and source: https://ds.interlace.tools/c/prev-next-post\n\nRequires the `@interlace/theme` CSS baseline (installed automatically as a registry dependency)."
}
