Skip to content
kerfjsGitHub

Card

A box that groups related things, and can be a link or a button.

SourcePackage @kerf-js/veneerTry it

Preview

tsx
import { Card, Text } from '@kerf-js/veneer'export function Static() {  return (  <Card    eyebrow="Project"    title="storefront"  >    <Text subtle>      Live in eu-west, updated 2 minutes ago.    </Text>  </Card>  )}

Installation

npm install @kerf-js/veneer

Try it

Change the controls: the preview and its code update together. The address keeps what you set, so you can link to it.

Live preview
Controls
Codeupdates as you change the controls
import { Card } from '@kerf-js/veneer'<Card eyebrow="Project" title="storefront">  <Text subtle>Live in eu-west, updated 2 minutes ago.</Text></Card>

Usage

tsx
import { Card } from '@kerf-js/veneer'<Card  eyebrow="Project"  title="storefront">  <Text subtle>    Live in eu-west, updated 2 minutes ago.  </Text></Card>

A button

onClick makes the whole card a button.

tsx
import { state } from '@kerf-js/core'import { Card, Grid, Text } from '@kerf-js/veneer'export function Press() {  const picked = state('Team')  return (    <Grid      cols={2}      gap="related"    >      {PLANS.map((plan) => (        <Card          key={plan}          eyebrow={            picked.value === plan ? 'Selected' : 'Plan'          }          title={plan}          aria-pressed={            picked.value === plan ? 'true' : 'false'          }          onClick={() => (picked.value = plan)}        >          <Text subtle>            {plan === 'Team'              ? '$40 a month'              : '$120 a month'}          </Text>        </Card>      ))}    </Grid>  )}const PLANS = ['Team', 'Scale']

The hover line

sweep opts a card in to the line that runs around its edge on hover. Without it, as on the right, a card has none.

tsx
import { Card, Grid, Text } from '@kerf-js/veneer'export function Sweep() {  return (  <Grid    min="xs"    gap="related"  >    <Card      href="/components/button"      eyebrow="sweep"      title="With the line"      sweep    >      <Text subtle>        Hover me: a line runs around the edge.      </Text>    </Card>    <Card      href="/components/button"      eyebrow="Default"      title="Without it"    >      <Text subtle>        Hover me: only its shadow deepens.      </Text>    </Card>  </Grid>  )}

Bare

Every part is optional, and sweep={false} leaves the hover line out even under data-flair="sweep".

tsx
import { Card, Text } from '@kerf-js/veneer'export function Bare() {  return (  <Card sweep={false}>    <Text>      No eyebrow, no title and no sweep. Just a surface      for what you put in it.    </Text>  </Card>  )}

A row of figures

Use eyebrow and title for a label and a figure, laid out in a Grid.

tsx
import { Card, Grid, Text } from '@kerf-js/veneer'export function Stats() {  return (  <Grid    min="xs"    gap="related"  >    <Card      eyebrow="Requests"      title="86.1M"    >      <Text        role="caption"        tone="positive"      >        Up 8.6% on September      </Text>    </Card>    <Card      eyebrow="Errors"      title="0.22%"    >      <Text        role="caption"        tone="positive"      >        Down from 0.36%      </Text>    </Card>    <Card      eyebrow="Deploys"      title="167"    >      <Text        role="caption"        subtle      >        About 5 a day      </Text>    </Card>    <Card      eyebrow="Spend"      title="$8,930"    >      <Text        role="caption"        subtle      >        Up $370      </Text>    </Card>  </Grid>  )}

A grid of projects

Give a card href to make the whole card a link.

tsx
import {  Badge,  Card,  Grid,  Row,  Text} from '@kerf-js/veneer'export function ProjectGrid() {  return (  <Grid    min="md"    gap="related"  >    {PROJECTS.slice(0, 6).map((p) => (      <Card        key={p.id}        href="/components/card"        eyebrow={p.region}        title={p.name}      >        <Row justify="between">          <Text            role="caption"            subtle          >            {p.deploys} deploys          </Text>          <Badge            tone={TONE_OF[p.status]}            dot          >            {p.status}          </Badge>        </Row>      </Card>    ))}  </Grid>  )}type Status =  | 'Live'  | 'Building'  | 'Failed'  | 'Paused'const PROJECTS: {  id: string  name: string  owner: string  region: string  status: Status  deploys: number  uptime: number  latency: number  cost: number  updated: string}[] = [  {    id: 'storefront',    name: 'storefront',    owner: 'Priya Raman',    region: 'eu-west',    status: 'Live',    deploys: 412,    uptime: 99.98,    latency: 84,    cost: 1240,    updated: '2026-10-31',  },  {    id: 'checkout',    name: 'checkout',    owner: 'Tomás Oliveira',    region: 'eu-west',    status: 'Live',    deploys: 288,    uptime: 99.95,    latency: 112,    cost: 980,    updated: '2026-10-31',  },  {    id: 'search-api',    name: 'search-api',    owner: 'Lena Fischer',    region: 'us-east',    status: 'Building',    deploys: 196,    uptime: 99.91,    latency: 146,    cost: 1610,    updated: '2026-10-30',  },  {    id: 'billing',    name: 'billing',    owner: 'Mara Lindqvist',    region: 'us-east',    status: 'Live',    deploys: 154,    uptime: 99.99,    latency: 73,    cost: 720,    updated: '2026-10-29',  },  {    id: 'mobile-bff',    name: 'mobile-bff',    owner: 'Sam Okafor',    region: 'ap-south',    status: 'Failed',    deploys: 233,    uptime: 99.42,    latency: 208,    cost: 860,    updated: '2026-10-29',  },  {    id: 'notifier',    name: 'notifier',    owner: 'Jun Tanaka',    region: 'eu-north',    status: 'Live',    deploys: 97,    uptime: 99.97,    latency: 58,    cost: 310,    updated: '2026-10-27',  },  {    id: 'reports',    name: 'reports',    owner: 'Noor Haddad',    region: 'us-west',    status: 'Paused',    deploys: 41,    uptime: 99.8,    latency: 321,    cost: 190,    updated: '2026-10-22',  },  {    id: 'edge-cache',    name: 'edge-cache',    owner: 'Jun Tanaka',    region: 'global',    status: 'Live',    deploys: 318,    uptime: 100,    latency: 21,    cost: 1430,    updated: '2026-10-31',  },  {    id: 'auth',    name: 'auth',    owner: 'Mara Lindqvist',    region: 'eu-west',    status: 'Live',    deploys: 176,    uptime: 99.99,    latency: 66,    cost: 540,    updated: '2026-10-28',  },  {    id: 'ingest',    name: 'ingest',    owner: 'Lena Fischer',    region: 'us-east',    status: 'Live',    deploys: 264,    uptime: 99.93,    latency: 131,    cost: 2120,    updated: '2026-10-30',  },]const TONE_OF: Record<  Status,  'positive' | 'info' | 'critical' | 'neutral'> = {  Live: 'positive',  Building: 'info',  Failed: 'critical',  Paused: 'neutral',}const min = 60_000

When to use

Use it for

  • Grouping one thing's details
  • A grid of things to pick from

Reach for something else

  • Table, when people compare many rows by the same columns
  • Inset, when you only want padding, with no surface

Accessibility

  • A card with href is a real link, and with onClick a real button, so don't put other buttons or links inside those.

Theming

Restyle it app-wide from veneer() in your kerf config. Every key takes colour, spacing, size, radius, shadow, type and motion; its own keys are listed. A state nests inside: hover: { bg: 'ink' }. Text on a fill you set is contrast-checked at build. How theming works

KeyOwn keysStates
parts.card—hover, focus, press, active, open, disabled
ts
veneer({  colors: { brand: '#e8590c', ink: '#1b1b1f' },  parts: {    card: { padding: 32, radius: 20, shadow: 'md' },  },})

API Reference

PropTypeDefaultDescription
eyebrowunknown—A small line above the title.
titleunknown—The card's title.
hrefstring—Makes the card a link.
sweepbooleanfalseOpts in to the line that runs on hover, at every expression. No card sweeps by default, a link or button card included.
childrenunknown—What it contains.
EventTypeDefaultDescription
onClick((e: MouseEvent) => void)—Makes the card a button.

Parts

Every element carries a stable name, so you can style it from your own CSS: [data-scope="surface"] [data-part="eyebrow"]

eyebrowtitlesweep

RelatedGridBadgeInset