Related Cards

A horizontally-scrolling row of related-content cards. Drop it at the bottom of an article, lesson, recipe, or product page to surface a handful of "you might also like" links. Each entry is a single click surface — an anchor when href is provided, a button otherwise — with an optional kind chip, a serif title, and an optional one-line description.

Customize
Cards
3
Slots
Motion

Installation

npx shadcn@latest add https://craftbits.dev/r/related-cards.json

Usage

import { RelatedCards } from "@craft-bits/core";
 
<RelatedCards
  title="Related reading"
  cards={[
    {
      id: "streaming",
      href: "/cookbook/streaming-ui",
      kind: "Pattern",
      title: "Streaming UI",
      description: "Incrementally render content as the server produces it.",
    },
    {
      id: "backpressure",
      href: "/cookbook/backpressure",
      kind: "Recipe",
      title: "Backpressure for client streams",
      description: "Throttle consumers so producers do not drown the UI thread.",
    },
  ]}
/>

Without a section heading (when the row sits below an existing <h2> you control):

<RelatedCards
  cards={[
    { id: "a", href: "/a", title: "First related" },
    { id: "b", href: "/b", title: "Second related" },
  ]}
/>

As buttons (no navigation — e.g. open an inline panel):

<RelatedCards
  cards={items.map((it) => ({
    id: it.id,
    title: it.title,
    description: it.summary,
    onClick: () => open(it),
  }))}
/>

Understanding the component

  1. Horizontal scroll row. The card list is a flex row with overflow-x-auto and scroll-snap-type: x mandatory. Each card declares scroll-snap-align: start and a fixed minimum width (itemMinWidth, default 18rem), so the row scrolls smoothly snap-by-card on narrow viewports and lays out as a contained row on wide viewports.
  2. Polymorphic card root. When an item declares href, that card renders as a motion.a; otherwise it renders as a motion.button of type="button". The whole card is one click target — title, kind chip, and description all read as part of the same accessible name.
  3. Accessible name. Each card uses its visible string title as the default aria-label. Pass aria-label on the item when title is a non-string ReactNode.
  4. Staggered entrance. When animated is true (the default), each card fades + lifts in with STAGGER (0.04s) between siblings — well within the craft-bits/no-excessive-stagger cap. useReducedMotion() short-circuits the entrance, hover lift, and tap-scale so the row stays still for users who request reduced motion.
  5. Hover + tap micro-interactions. Hover translates the card y: -2 and deepens its two-layer non-pure-black shadow; tap applies the shared TAP_SCALE. The CSS transition allow-list is background-color,border-color,box-shadow,color — never transition-all.
  6. Section semantics. The root renders a <section> with the heading wired through aria-labelledby when title is a string. The card list itself carries role="list" so assistive tech can count items.

Props

PropTypeDefaultDescription
cardsreadonly RelatedCardItem[]requiredThe card items to render.
titleReactNodeOptional section heading rendered above the row.
titleAs'h2' | 'h3' | 'h4''h2'Heading level for title.
itemMinWidthstring'18rem'Minimum width of each card.
gapnumber | string3Number → multiplied by 0.25rem. String → used verbatim.
animatedbooleantrueWhen true, cards fade + lift in with stagger. Respects reduced motion.
classNamestringMerged onto the <section> via cn().

RelatedCardItem

FieldTypeDescription
idstring | numberStable identity used as the React key.
titleReactNodeCard title — serif, prominent.
descriptionReactNodeOptional one-line description below the title.
hrefstringWhen present the card is an <a>; otherwise a <button type="button">.
kindReactNodeOptional small chip above the title (e.g. 'Pattern').
aria-labelstringOverride the accessible name. Required when title is non-string.

Accessibility

  • Each card renders a real <a> or <button type="button"> — keyboard activation works natively.
  • The card list carries role="list" with each item as role="listitem".
  • The aria-label defaults to the visible string title; pass an explicit aria-label per item when title is a non-string ReactNode.
  • Focus ring is :focus-visible only — a 2px accent ring with a 2px offset against the page background.
  • Horizontal scrolling is keyboard-accessible: focus the card and use Tab / Shift+Tab to walk the row; the browser keeps the focused card scrolled into view.
  • prefers-reduced-motion: entrance, hover lift, and tap-scale all collapse to no-op, so the row paints instantly and stays still.
  • Color contrast: the title uses --cb-fg on --cb-bg-elevated; the description uses --cb-fg-muted; both pass WCAG AA in the default theme.

Credits

  • Extracted from: terminal-dreams (src/components/principles/RelatedPrincipleCards.tsx). The source was wired to a project-specific PrincipleListItem shape and a CategoryChip chip that linked into the principles taxonomy, plus a CSS-module grid. craft-bits generalises the shape to a flat cards[] array of { id, title, description?, href?, kind? }, swaps the grid for a snap-scrolling row, drops the principles-only styling, and routes motion through SPRINGS.smooth + SPRINGS.snap + TAP_SCALE with STAGGER between siblings and a reduced-motion fallback.