Card
A box that groups related things, and can be a link or a button.
Preview
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/veneerTry it
Change the controls: the preview and its code update together. The address keeps what you set, so you can link to it.
import { Card } from '@kerf-js/veneer'<Card eyebrow="Project" title="storefront"> <Text subtle>Live in eu-west, updated 2 minutes ago.</Text></Card>Usage
import { Card } from '@kerf-js/veneer'<Card eyebrow="Project" title="storefront"> <Text subtle> Live in eu-west, updated 2 minutes ago. </Text></Card>A link
href makes the whole card a link.
import { Card, Text } from '@kerf-js/veneer'export function AsLink() { return ( <Card href="/components/button" eyebrow="Guide" title="Roll back a deploy" > <Text subtle> Promote any earlier deploy in about ten seconds. </Text> </Card> )}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.
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".
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.
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.
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_000When to use
Accessibility
- A card with
hrefis a real link, and withonClicka 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
| Key | Own keys | States |
|---|---|---|
parts.card | — | hover, focus, press, active, open, disabled |
veneer({ colors: { brand: '#e8590c', ink: '#1b1b1f' }, parts: { card: { padding: 32, radius: 20, shadow: 'md' }, },})API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
eyebrow | unknown | — | A small line above the title. |
title | unknown | — | The card's title. |
href | string | — | Makes the card a link. |
sweep | boolean | false | Opts in to the line that runs on hover, at every expression. No card sweeps by default, a link or button card included. |
children | unknown | — | What it contains. |
| Event | Type | Default | Description |
|---|---|---|---|
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