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-hidden on 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 Heading and a Separator.
  • Items that share the same fields are rows, not cards: a Table (DataTable, ServerTable) or a list of Item rows.
  • 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 Heading do that, and a Panel when 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 StatGroup of Stats — number, label, StatDelta and Sparkline — with the lines between them drawn by the group.
  • Don't build a callout or a selectable option out of a card. Those are Note and ChoiceBox.

05 examples

Default

Paper with a hairline round it: square, flat, no shadow.

storage upgrade
Expand your workspace with another 500 GB of fast object storage.
Billed monthly. Cancel anytime — no long-term contract required.
€9/mo

Feature

The raised ground behind a stronger rule. Reserve it for the one card that carries the point.

unlimited pro
Every feature with no usage caps. Priority support included.
The complete quebi platform with no usage caps.
€49/mo

Interactive

Opt in to the hover ground for link- or button-like cards. It never lifts.

open dashboard →
Jump back into your projects and recent activity.

With action

CardAction pins a control to the top-right of the header.

team members
3 people have access to this project.

Composed manually

Use CardTitle and CardDescription directly for full control.

custom layout
Compose the pieces yourself when props aren't enough.
Every sub-component accepts a className and forwards native div props.

what to use instead

by job

By 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
A metric or KPI
A callout
Hiding or organising content
An action target

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.

instead of
project settings
general
Name and visibility.
danger zone
Archive or delete the project.
use

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.

instead of
Aurelia Vance
Owner
Jonas Keller
Editor
Mira Roth
Viewer
use
  • Aurelia Vance
    Owner
  • Jonas Keller
    Editor
  • Mira Roth
    Viewer

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).

instead of
#0042
Nordlicht GmbH
Paid
#0043
Hafenbau AG
Open
#0044
Kiosk Süd
Overdue
use
Invoice
Customer
Status
Amount
#0042Nordlicht GmbHPaid1.280,00 €
#0043Hafenbau AGOpen640,00 €
#0044Kiosk SüdOverdue215,00 €

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.

instead of
Signups
1.284
Active kiosks
312
Checkout errors
17
use
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.

instead of
invoice #0042
Customer: Nordlicht GmbHStatus: PaidIssued: June 30, 2026
use
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.

instead of
heads up
Two devices in this bundle are out of stock.
use
Two devices in this bundle are out of stock.

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.

instead of
starter
For solo projects.
pro
For growing teams.
use
starterFor solo projects.
proFor growing teams.

source

card.tsx

Copy 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 }