Category Card

A clickable card primitive that introduces a category or topic. Renders as a single click surface — an anchor when href is provided, a button otherwise — with a serif title, an optional description, an optional trailing icon / illustration slot, and a subtle Motion-driven hover lift plus tap-press.

Customize
Variant
surface
Size
md
Slots

Installation

npx shadcn@latest add https://craftbits.dev/r/category-card.json

Usage

As a link (the common case):

import { CategoryCard } from "@craft-bits/core";
 
<CategoryCard
  href="/cookbook/streaming-ui"
  title="Streaming UI"
  description="Patterns for incremental rendering, backpressure, and Server-Sent Events."
  meta="12 recipes"
/>

As a button (no navigation — e.g. expand an inline panel):

<CategoryCard
  title="Streaming UI"
  description="Patterns for incremental rendering and Server-Sent Events."
  meta="12 recipes"
  onClick={() => setExpanded(true)}
/>

With an inline SVG icon slot:

<CategoryCard
  href="/cookbook/streaming-ui"
  title="Streaming UI"
  description="Patterns for incremental rendering and Server-Sent Events."
  icon={<MyCategoryIllustration />}
/>

Understanding the component

  1. Polymorphic root. When href is provided the card renders as a motion.a; otherwise it renders as a motion.button with type="button". forwardRef targets the union HTMLAnchorElement | HTMLButtonElement so callers can hand off the underlying element to Next.js / Remix routers without writing a wrapper.
  2. Single click target. The entire surface is one element — the title, description, meta, and icon all read as part of the same accessible name. The aria-label defaults to the visible title when it is a string and can be overridden when callers pass a ReactNode.
  3. Hover lift is a transform. On hover the card translates y: -2 and the two-layer non-pure-black shadow deepens, the border deepens to cb-border-strong, and the title color shifts toward cb-accent. The transition allow-list is background-color,border-color,box-shadow,color — never transition-all.
  4. Tap-press uses TAP_SCALE. The shared TAP_SCALE token is applied on whileTap — the same press feel every other interactive primitive in the library uses. Both whileHover and whileTap short-circuit to undefined when useReducedMotion() reports the user prefers reduced motion.
  5. Three visual variants. surface (default) is the bordered elevated card with the soft shadow. outline is a transparent bordered surface, useful inside dense grids where the shadow would be noise. ghost is borderless and paints a tinted hover background, useful in vertical lists where the card sits flush with the page.

Variants

<CategoryCard variant="surface" title="Streaming UI" />
<CategoryCard variant="outline" title="Streaming UI" />
<CategoryCard variant="ghost"   title="Streaming UI" />

Sizes:

<CategoryCard size="sm" title="Streaming UI" />
<CategoryCard size="md" title="Streaming UI" />
<CategoryCard size="lg" title="Streaming UI" />

Props

PropTypeDefaultDescription
titleReactNoderequiredCard title — serif, prominent.
descriptionReactNodeOptional supporting line below the title.
metaReactNodeOptional small line below the description (e.g. '12 recipes').
iconReactNodeOptional icon / illustration rendered to the right of the body.
hrefstringWhen present the root renders as <a>; otherwise it renders as <button type="button">.
variant'surface' | 'outline' | 'ghost''surface'Visual style of the card.
size'sm' | 'md' | 'lg''md'Inner padding and gap.
aria-labelstringtitle (when string)Override the accessible name. Required when title is a non-string ReactNode.
classNamestringMerged onto the root via cn().

Accessibility

  • Renders a real <a> or <button type="button"> — keyboard activation works natively.
  • aria-label defaults to the visible string title; pass an explicit aria-label when title is a non-string ReactNode.
  • The icon slot is aria-hidden="true" — the title already announces the card's intent.
  • Focus ring is :focus-visible only — a 2px accent ring with a 2px offset against the page background.
  • prefers-reduced-motion: both whileHover and whileTap collapse to undefined, so the card stays still for users who request reduced motion.
  • Color contrast: the title uses --cb-fg on --cb-bg-elevated (surface) or --cb-bg (outline / ghost); both pass WCAG AA in the default theme.

Credits

  • Extracted from: terminal-dreams (src/components/cookbook/CategoryCard.tsx). The source wrapped a recipe-grid expander tied to a project-specific CategoryInfo / CookbookRecipe type and a project-specific getCategoryIllustration helper. craft-bits keeps the visual identity but reshapes the API to a generic clickable card: polymorphic anchor / button root, flat title / description / icon / meta props, surface / outline / ghost variants, sm / md / lg sizes, SPRINGS.snap + TAP_SCALE motion, cb-* semantic tokens, and a reduced-motion fallback. The cookbook-specific expand / collapse panel and the embedded RecipeCard grid are intentionally dropped — pair CategoryCard with any disclosure component when a grid is needed.