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.
Related reading
Customize
Cards
3
Slots
Motion
Installation
npx shadcn@latest add https://craftbits.dev/r/related-cards.jsonUsage
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
- Horizontal scroll row. The card list is a flex row with
overflow-x-autoandscroll-snap-type: x mandatory. Each card declaresscroll-snap-align: startand a fixed minimum width (itemMinWidth, default18rem), so the row scrolls smoothly snap-by-card on narrow viewports and lays out as a contained row on wide viewports. - Polymorphic card root. When an item declares
href, that card renders as amotion.a; otherwise it renders as amotion.buttonoftype="button". The whole card is one click target — title, kind chip, and description all read as part of the same accessible name. - Accessible name. Each card uses its visible string
titleas the defaultaria-label. Passaria-labelon the item whentitleis a non-stringReactNode. - Staggered entrance. When
animatedistrue(the default), each card fades + lifts in withSTAGGER(0.04s) between siblings — well within thecraft-bits/no-excessive-staggercap.useReducedMotion()short-circuits the entrance, hover lift, and tap-scale so the row stays still for users who request reduced motion. - Hover + tap micro-interactions. Hover translates the card
y: -2and deepens its two-layer non-pure-black shadow; tap applies the sharedTAP_SCALE. The CSS transition allow-list isbackground-color,border-color,box-shadow,color— nevertransition-all. - Section semantics. The root renders a
<section>with the heading wired througharia-labelledbywhentitleis a string. The card list itself carriesrole="list"so assistive tech can count items.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
cards | readonly RelatedCardItem[] | required | The card items to render. |
title | ReactNode | — | Optional section heading rendered above the row. |
titleAs | 'h2' | 'h3' | 'h4' | 'h2' | Heading level for title. |
itemMinWidth | string | '18rem' | Minimum width of each card. |
gap | number | string | 3 | Number → multiplied by 0.25rem. String → used verbatim. |
animated | boolean | true | When true, cards fade + lift in with stagger. Respects reduced motion. |
className | string | — | Merged onto the <section> via cn(). |
RelatedCardItem
| Field | Type | Description |
|---|---|---|
id | string | number | Stable identity used as the React key. |
title | ReactNode | Card title — serif, prominent. |
description | ReactNode | Optional one-line description below the title. |
href | string | When present the card is an <a>; otherwise a <button type="button">. |
kind | ReactNode | Optional small chip above the title (e.g. 'Pattern'). |
aria-label | string | Override 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 asrole="listitem". - The
aria-labeldefaults to the visible stringtitle; pass an explicitaria-labelper item whentitleis a non-stringReactNode. - Focus ring is
:focus-visibleonly — 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-fgon--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-specificPrincipleListItemshape and aCategoryChipchip that linked into the principles taxonomy, plus a CSS-module grid. craft-bits generalises the shape to a flatcards[]array of{ id, title, description?, href?, kind? }, swaps the grid for a snap-scrolling row, drops the principles-only styling, and routes motion throughSPRINGS.smooth+SPRINGS.snap+TAP_SCALEwithSTAGGERbetween siblings and a reduced-motion fallback.