component — layout
Card
A surface for a self-contained unit that sits beside a few others of a different kind. Composes a header, content, and footer, with default and feature variants plus an optional hover ground. Most things that look like they want a card want a heading, a list or a table instead — read its usage guidance first.
- layout
- surface
- container
- display
use a card when
- A small number of different, self-contained units side by side — each with its own title and, often, its own actions.
- A dashboard widget: a chart with a title and a menu, next to widgets that are not charts.
- A feature or pricing tile, where each tile is one offer and there are three or four of them.
- A gallery item that is mostly media. Put
p-0 overflow-hiddenon the Card to let the image run edge to edge.
don't
- Never nest a card in a card. The inner one is a section: a
Headingand aSeparator. - Items that share the same fields are rows, not cards: a
Table(DataTable,ServerTable) or a list ofItemrows. - More than about six cards of the same shape is a list. Scanning a grid of identical boxes is slower than reading down a column.
- Don't put a card around a page section just to separate it. Whitespace and a
Headingdo that, and aPanelwhen the section needs a tint behind it. - Don't wrap a key/value block in a card. That is a
DescriptionList. - Don't use a row of cards for KPIs. A
StatGroupofStats — number, label,StatDeltaandSparkline— with the lines between them drawn by the group. - Don't build a callout or a selectable option out of a card. Those are
NoteandChoiceBox.
05 examples
Default
Paper with a hairline round it: square, flat, no shadow.
Feature
The raised ground behind a stronger rule. Reserve it for the one card that carries the point.
Interactive
Opt in to the hover ground for link- or button-like cards. It never lifts.
With action
CardAction pins a control to the top-right of the header.
Composed manually
Use CardTitle and CardDescription directly for full control.
what to use instead
by jobBy what the card was doing.
- Grouping or separating content
- Whitespace + Heading
- Separator
- Panel — A region that needs a tinted band behind it — no border, no radius.
- Two-column settings layout — Label and help text on the left, controls on the right, rows divided by a Separator.
- A list item
- Tabular data
- Table
- DataTable — All the rows are in the browser.
- ServerTable — The server owns the query.
- DescriptionList — A single record.
- A metric or KPI
- Stat + StatGroup — Label, value, delta and Sparkline; the group draws the lines between stats.
- Sparkline
- Meter
- ProgressBar
- Tracker
- A callout
- Hiding or organising content
- An action target
- Link
- LinkButton
- ChoiceBox — The right selectable card.
- IconTile
A card inside a card → sections
The inner card was separating one part of the outer one from the rest. A heading does that, and a separator draws the line if one is needed.
project settings
general
Name and visibility.
danger zone
Archive or delete the project.
A card per list item → divided rows
Three boxes of the same shape are a list. Item rows in an ItemGroup, separated by a hairline, read faster, and they stay readable at thirty. Reach for GridList when the rows are actionable.
- Aurelia VanceOwner
- Jonas KellerEditor
- Mira RothViewer
A card grid of records → a table
When every card has the same fields, the fields are columns. A table lines them up so they can be compared, and sorts them when that matters (DataTable, ServerTable).
KPI cards → a stat row
A number does not need a box to be read. A StatGroup of Stats — label, value, delta and sparkline, with a hairline between them — carries the same data in a third of the ink.
- Signups
- 1.284
- +12 %
- Active kiosks
- 312
- +3 %
- Checkout errors
- 17
- -40 %
A key/value card → DescriptionList
Terms and values in a card are a description list with a border drawn round it. The list already divides its rows.
- Customer
- Nordlicht GmbH
- Status
- Paid
- Issued
- June 30, 2026
A callout card → Note
A card with a warning in it says nothing about how urgent the warning is. A Note carries an intent, an icon and the right role.
Clickable option cards → ChoiceBox
A row of interactive cards that pick one plan is a radio group drawn as cards. ChoiceBox is that, with the selection state, keyboard and form value included.
source
card.tsxCopy this into your project. Resolve its dependencies from the registryDependencies in the component's API entry.
import { cn } from "@/lib/utils"
/**
* Card — quebi design system
*
* A surface for grouping related content: paper with a hairline round it, the
* surface radius, and no shadow — structure comes from the rule, not depth.
* Interactive cards answer hover with the raised ground, never with a lift.
*
* Note that a Card is *not* an overlay: it sits in the page flow on the page
* ground and does not take `bg-quebi-elevated` or `shadow-quebi-float`, which
* are for things that float above the page.
*
* Sub-components compose the layout: CardHeader (title + description + action),
* CardContent, and CardFooter.
*/
export interface CardProps extends React.HTMLAttributes<HTMLDivElement> {
/** `feature` sets the card on the raised ground.
* Reserve it for the one card in a set that carries the point — not every
* card. */
variant?: "default" | "feature"
/** Opt in to the hover ground. Set for cards that behave like a link or
* button. Default cards stay static so informational surfaces don't imply
* interactivity. */
interactive?: boolean
}
const Card = ({ className, variant = "default", interactive = false, ...props }: CardProps) => {
return (
<div
data-slot="card"
className={cn(
// flex-col + h-full so a child with `mt-auto` (e.g. the action button)
// pins to the bottom and buttons align across a row of cards.
"flex flex-col h-full p-6 text-quebi-fg",
// Shape from the theme: surface radius, 1px, flat; square under `.quebi-editorial`.
"rounded-(--q-radius-surface) border-(length:--q-border-surface) shadow-(--q-shadow-surface)",
variant === "feature"
? "border-quebi-hairline bg-quebi-raised"
: "border-quebi-hairline bg-(--q-card)",
interactive &&
"transition-[background-color,translate,box-shadow] duration-150 hover:translate-(--q-press) hover:bg-quebi-raised hover:shadow-none",
className,
)}
{...props}
/>
)
}
interface HeaderProps extends React.HTMLAttributes<HTMLDivElement> {
title?: string
description?: string
}
const CardHeader = ({ className, title, description, children, ...props }: HeaderProps) => (
<div
data-slot="card-header"
className={cn(
"grid auto-rows-min grid-rows-[auto_auto] items-start gap-1 has-data-[slot=card-action]:grid-cols-[1fr_auto]",
className,
)}
{...props}
>
{title && <CardTitle>{title}</CardTitle>}
{description && <CardDescription>{description}</CardDescription>}
{!title && typeof children === "string" ? <CardTitle>{children}</CardTitle> : children}
</div>
)
const CardTitle = ({ className, ...props }: React.ComponentProps<"div">) => {
return (
<div
data-slot="card-title"
className={cn(
"font-display text-quebi-title text-quebi-fg text-balance",
className,
)}
{...props}
/>
)
}
const CardDescription = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => {
return (
<div
data-slot="card-description"
className={cn("text-quebi-body-s text-quebi-fg-muted text-pretty", className)}
{...props}
/>
)
}
const CardAction = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className,
)}
{...props}
/>
)
}
const CardContent = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => {
return <div data-slot="card-content" className={cn("mt-3", className)} {...props} />
}
const CardFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => {
return (
<div
data-slot="card-footer"
className={cn("flex items-center mt-4", className)}
{...props}
/>
)
}
export { Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle }