Card

Displays a card with header, content, and footer.

Scheduled reports
Weekly snapshots. No more manual exports.
Beta
Choose a schedule, daily or weekly.
Send to channels or specific teammates.
Include charts, tables, and key metrics.
import * as stylex from "@stylexjs/stylex"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
  Card,
  CardAction,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from "@/components/ui/card"
import { HStack, VStack } from "@/components/ui/stack"
import { color, space } from "@/theme/tokens.stylex"
import { ChevronRight } from "lucide-react"

const styles = stylex.create({
  card: { width: "100%", maxWidth: "24rem" },
  footer: { flexDirection: "column", rowGap: space.s2, alignItems: "stretch" },
  chevron: {
    width: space.s4,
    height: space.s4,
    flexShrink: 0,
    marginBlockStart: space.s0_5,
    color: color.mutedForeground,
  },
})

export default function CardDemo() {
  return (
    <Card xstyle={styles.card}>
      <CardHeader>
        <CardTitle>Scheduled reports</CardTitle>
        <CardDescription>Weekly snapshots. No more manual exports.</CardDescription>
        <CardAction>
          <Badge variant="secondary">Beta</Badge>
        </CardAction>
      </CardHeader>
      <CardContent>
        <VStack gap={2}>
          <HStack gap={2}>
            <ChevronRight {...stylex.props(styles.chevron)} />
            <span>Choose a schedule, daily or weekly.</span>
          </HStack>
          <HStack gap={2}>
            <ChevronRight {...stylex.props(styles.chevron)} />
            <span>Send to channels or specific teammates.</span>
          </HStack>
          <HStack gap={2}>
            <ChevronRight {...stylex.props(styles.chevron)} />
            <span>Include charts, tables, and key metrics.</span>
          </HStack>
        </VStack>
      </CardContent>
      <CardFooter xstyle={styles.footer}>
        <Button>Set up scheduled reports</Button>
        <Button variant="secondary">See what&apos;s new</Button>
      </CardFooter>
    </Card>
  )
}

Installation

Install the following dependencies:

pnpm add @base-ui/react @stylexjs/stylex clsx

Set up the token layer and the StyleX compiler, if you have not already. See Installation, and copy states.ts from the same page for the footer's tray. The card also needs interop.css, which pulls a leading image to the top edge.

Copy and paste the following code into your project.

components/ui/card.tsx
"use client"

import { useRender } from "@base-ui/react/use-render"
import * as stylex from "@stylexjs/stylex"
import type { StyleXStyles } from "@stylexjs/stylex"
import { clsx } from "clsx"

import { states } from "@/theme/states"
import { color, leading, radius, shadow, space, text } from "@/theme/tokens.stylex"

const styles = stylex.create({
  root: {
    display: "flex",
    flexDirection: "column",
    "--carbon-card-spacing": space.s4,
    "--carbon-card-title-size": text.base,
    rowGap: "var(--carbon-card-spacing)",
    paddingBlock: "var(--carbon-card-spacing)",
    overflow: "hidden",
    borderRadius: radius.xl,
    backgroundColor: color.card,
    color: color.cardForeground,
    boxShadow: shadow.raised,
    fontSize: text.sm,
    lineHeight: leading.sm,
  },
  header: {
    display: "grid",
    gridTemplateColumns: "1fr auto",
    gridAutoRows: "min-content",
    alignItems: "start",
    rowGap: space.s1,
    paddingInline: "var(--carbon-card-spacing)",
  },
  title: {
    gridColumnStart: "1",
    fontSize: "var(--carbon-card-title-size)",
    lineHeight: leading.base,
    fontWeight: 500,
  },
  description: { gridColumnStart: "1", color: color.mutedForeground },
  action: {
    gridColumnStart: "2",
    gridRowStart: "1",
    gridRowEnd: "span 2",
    alignSelf: "start",
    justifySelf: "end",
  },
  content: { paddingInline: "var(--carbon-card-spacing)" },
  footer: {
    display: "flex",
    alignItems: "center",
    "--carbon-tray-spacing": "calc(var(--carbon-card-spacing) * 0.75)",
    "--carbon-tray-body-bg": color.card,
    marginBlockStart: `calc(-1 * ${radius.xl})`,
    marginBlockEnd: "calc(-1 * var(--carbon-card-spacing))",
  },
})

const sizes = stylex.create({
  default: {},
  sm: {
    "--carbon-card-spacing": space.s3,
    "--carbon-card-title-size": text.sm,
  },
})

const cardStyles = {
  base: styles.root,
  sizes,
  header: styles.header,
  title: styles.title,
  description: styles.description,
  action: styles.action,
  content: styles.content,
  footer: [states.trayFooter, styles.footer],
}

type CardSize = keyof typeof sizes

type Overrides = { xstyle?: StyleXStyles; className?: string }

type CardProps = useRender.ComponentProps<"div"> & Overrides & { size?: CardSize }

function Card({ render, ref, style, size = "default", xstyle, className, ...props }: CardProps) {
  const sx = stylex.props(styles.root, sizes[size], xstyle)
  return useRender({
    defaultTagName: "div",
    render,
    ref,
    props: {
      "data-slot": "card",
      "data-size": size,
      ...sx,
      ...props,
      className: clsx(sx.className, className),
      style: { ...sx.style, ...style },
    },
  })
}

function CardHeader({
  render,
  ref,
  style,
  xstyle,
  className,
  ...props
}: useRender.ComponentProps<"div"> & Overrides) {
  const sx = stylex.props(styles.header, xstyle)
  return useRender({
    defaultTagName: "div",
    render,
    ref,
    props: {
      "data-slot": "card-header",
      ...sx,
      ...props,
      className: clsx(sx.className, className),
      style: { ...sx.style, ...style },
    },
  })
}

function CardTitle({
  render,
  ref,
  style,
  xstyle,
  className,
  ...props
}: useRender.ComponentProps<"div"> & Overrides) {
  const sx = stylex.props(styles.title, xstyle)
  return useRender({
    defaultTagName: "div",
    render,
    ref,
    props: {
      "data-slot": "card-title",
      ...sx,
      ...props,
      className: clsx(sx.className, className),
      style: { ...sx.style, ...style },
    },
  })
}

function CardDescription({
  render,
  ref,
  style,
  xstyle,
  className,
  ...props
}: useRender.ComponentProps<"div"> & Overrides) {
  const sx = stylex.props(styles.description, xstyle)
  return useRender({
    defaultTagName: "div",
    render,
    ref,
    props: {
      "data-slot": "card-description",
      ...sx,
      ...props,
      className: clsx(sx.className, className),
      style: { ...sx.style, ...style },
    },
  })
}

function CardAction({
  render,
  ref,
  style,
  xstyle,
  className,
  ...props
}: useRender.ComponentProps<"div"> & Overrides) {
  const sx = stylex.props(styles.action, xstyle)
  return useRender({
    defaultTagName: "div",
    render,
    ref,
    props: {
      "data-slot": "card-action",
      ...sx,
      ...props,
      className: clsx(sx.className, className),
      style: { ...sx.style, ...style },
    },
  })
}

function CardContent({
  render,
  ref,
  style,
  xstyle,
  className,
  ...props
}: useRender.ComponentProps<"div"> & Overrides) {
  const sx = stylex.props(styles.content, xstyle)
  return useRender({
    defaultTagName: "div",
    render,
    ref,
    props: {
      "data-slot": "card-content",
      ...sx,
      ...props,
      className: clsx(sx.className, className),
      style: { ...sx.style, ...style },
    },
  })
}

function CardFooter({
  render,
  ref,
  style,
  xstyle,
  className,
  ...props
}: useRender.ComponentProps<"div"> & Overrides) {
  const sx = stylex.props(states.trayFooter, styles.footer, xstyle)
  return useRender({
    defaultTagName: "div",
    render,
    ref,
    props: {
      "data-slot": "card-footer",
      ...sx,
      ...props,
      className: clsx(sx.className, className),
      style: { ...sx.style, ...style },
    },
  })
}

export {
  Card,
  CardAction,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
  cardStyles,
  type CardSize,
}

Update the import paths to match your project setup.

Usage

import {
  Card,
  CardAction,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from "@/components/ui/card"
<Card>
  <CardHeader>
    <CardTitle>Card Title</CardTitle>
    <CardDescription>Card Description</CardDescription>
    <CardAction>Card Action</CardAction>
  </CardHeader>
  <CardContent>
    <p>Card Content</p>
  </CardContent>
  <CardFooter>
    <p>Card Footer</p>
  </CardFooter>
</Card>

Composition

Card
├── CardHeader
│   ├── CardTitle
│   ├── CardDescription
│   └── CardAction
├── CardContent
└── CardFooter

CardHeader is a two-column grid. CardAction sits in the second column and spans both rows, so a title and description stay to its left.

Size

Use size="sm" for a tighter inset and a title one step down.

Default
Sections sit 16px from the edge.
Weekly snapshots. No more manual exports.
Small
12px, and a title one step down.
Weekly snapshots. No more manual exports.
import * as stylex from "@stylexjs/stylex"
import { Button } from "@/components/ui/button"
import {
  Card,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from "@/components/ui/card"
import { HStack } from "@/components/ui/stack"

const styles = stylex.create({
  card: { width: "100%", maxWidth: "18rem" },
})

export default function CardSize() {
  return (
    <HStack align="start" gap={4} wrap="wrap" justify="center">
      <Card size="default" xstyle={styles.card}>
        <CardHeader>
          <CardTitle>Default</CardTitle>
          <CardDescription>Sections sit 16px from the edge.</CardDescription>
        </CardHeader>
        <CardContent>Weekly snapshots. No more manual exports.</CardContent>
        <CardFooter>
          <Button>Enable</Button>
        </CardFooter>
      </Card>
      <Card size="sm" xstyle={styles.card}>
        <CardHeader>
          <CardTitle>Small</CardTitle>
          <CardDescription>12px, and a title one step down.</CardDescription>
        </CardHeader>
        <CardContent>Weekly snapshots. No more manual exports.</CardContent>
        <CardFooter>
          <Button size="sm">Enable</Button>
        </CardFooter>
      </Card>
    </HStack>
  )
}

Spacing

Set --carbon-card-spacing to change the gap between sections and the inset of every part at once.

Terms of Service
Review the terms before accepting.
The default inset is 16px.
Terms of Service
Review the terms before accepting.
This one sets 32px.
import * as stylex from "@stylexjs/stylex"
import { Button } from "@/components/ui/button"
import {
  Card,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from "@/components/ui/card"
import { HStack } from "@/components/ui/stack"
import { space } from "@/theme/tokens.stylex"

const styles = stylex.create({
  card: { width: "100%", maxWidth: "18rem" },
  roomy: { width: "100%", maxWidth: "18rem", "--carbon-card-spacing": space.s8 },
})

export default function CardSpacing() {
  return (
    <HStack align="start" gap={4} wrap="wrap" justify="center">
      <Card xstyle={styles.card}>
        <CardHeader>
          <CardTitle>Terms of Service</CardTitle>
          <CardDescription>Review the terms before accepting.</CardDescription>
        </CardHeader>
        <CardContent>The default inset is 16px.</CardContent>
        <CardFooter>
          <Button>Accept</Button>
        </CardFooter>
      </Card>
      <Card xstyle={styles.roomy}>
        <CardHeader>
          <CardTitle>Terms of Service</CardTitle>
          <CardDescription>Review the terms before accepting.</CardDescription>
        </CardHeader>
        <CardContent>This one sets 32px.</CardContent>
        <CardFooter>
          <Button>Accept</Button>
        </CardFooter>
      </Card>
    </HStack>
  )
}

Bleed content to the card's edges with a negative margin-inline: calc(-1 * var(--carbon-card-spacing)), then put the same value back as padding to keep the text on the card's inset. When that content sits above a footer, cancel the section gap less the footer's raised strip with margin-block-end: calc(radius.xl - var(--carbon-card-spacing)) on CardContent.

Terms of Service
Review the terms before accepting the agreement.

These terms govern your use of the workspace, including access to shared documents, project files, and collaboration tools.

You are responsible for the content you upload and for ensuring that your team has the appropriate permissions to view or edit it.

We may update features or limits as the service evolves. When those changes materially affect your workflow, we will notify your workspace administrators.

import * as stylex from "@stylexjs/stylex"
import { Button } from "@/components/ui/button"
import {
  Card,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from "@/components/ui/card"
import { VStack } from "@/components/ui/stack"
import { leading, radius, space } from "@/theme/tokens.stylex"

const styles = stylex.create({
  card: { width: "100%", maxWidth: "24rem" },
  content: { marginBlockEnd: `calc(${radius.xl} - var(--carbon-card-spacing))` },
  panel: {
    marginInline: "calc(-1 * var(--carbon-card-spacing))",
    paddingInline: "var(--carbon-card-spacing)",
    paddingBlock: space.s4,
    maxHeight: "12rem",
    overflowY: "auto",
    borderBlockStartWidth: 1,
    borderBlockStartStyle: "solid",
    lineHeight: leading.base,
  },
  footer: { justifyContent: "flex-end", columnGap: space.s2 },
})

export default function CardEdgeToEdge() {
  return (
    <Card xstyle={styles.card}>
      <CardHeader>
        <CardTitle>Terms of Service</CardTitle>
        <CardDescription>Review the terms before accepting the agreement.</CardDescription>
      </CardHeader>
      <CardContent xstyle={styles.content}>
        <VStack gap={4} xstyle={styles.panel}>
          <p>
            These terms govern your use of the workspace, including access to shared documents,
            project files, and collaboration tools.
          </p>
          <p>
            You are responsible for the content you upload and for ensuring that your team has the
            appropriate permissions to view or edit it.
          </p>
          <p>
            We may update features or limits as the service evolves. When those changes materially
            affect your workflow, we will notify your workspace administrators.
          </p>
        </VStack>
      </CardContent>
      <CardFooter xstyle={styles.footer}>
        <Button variant="secondary">Decline</Button>
        <Button>Accept</Button>
      </CardFooter>
    </Card>
  )
}

Image

Put an img first in the card. It runs to the top edge and its corners clip to the card's radius.

Event cover
Featured
Design systems meetup
A practical talk on component APIs, accessibility, and shipping faster.
import * as stylex from "@stylexjs/stylex"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card, CardAction, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
import { space } from "@/theme/tokens.stylex"

const styles = stylex.create({
  card: { width: "100%", maxWidth: "24rem" },
  cover: { width: "100%", aspectRatio: "16 / 9", objectFit: "cover" },
  footer: { columnGap: space.s2 },
})

export default function CardImage() {
  return (
    <Card xstyle={styles.card}>
      <img
        src="https://avatar.vercel.sh/carbon"
        alt="Event cover"
        {...stylex.props(styles.cover)}
      />
      <CardHeader>
        <CardAction>
          <Badge variant="secondary">Featured</Badge>
        </CardAction>
        <CardTitle>Design systems meetup</CardTitle>
        <CardDescription>
          A practical talk on component APIs, accessibility, and shipping faster.
        </CardDescription>
      </CardHeader>
      <CardFooter xstyle={styles.footer}>
        <Button>View event</Button>
        <Button variant="secondary">Add to calendar</Button>
      </CardFooter>
    </Card>
  )
}

interop.css pulls the image up with a negative margin keyed on [data-slot="card"] > img:first-child. Without that stylesheet the image sits inside the card's top padding.

API reference

Pass render to replace a part's element or compose it with another component.

Every part renders a div and takes xstyle and className alongside every div attribute.

Card

PropTypeDefault
size"default" | "sm""default"
xstyleStyleXStyles—
classNamestring—

size sets --carbon-card-spacing and --carbon-card-title-size on the root. Setting either directly overrides it.

CardHeader

A grid of 1fr auto, with a 0.25rem row gap. The second column collapses to nothing without a CardAction.

CardTitle

Reads --carbon-card-title-size at weight 500.

CardDescription

Muted text at the card's own size.

CardAction

Pinned to the header's second column and first row, spanning both rows.

CardContent

The card's inset, and nothing else. Vertical rhythm is the root's row gap.

CardFooter

A tray under the card's body. The body's bottom edge rounds over the tray with a hairline shadow. The footer draws that shadow itself, so the body needs no wrapper. It cancels the root's bottom padding, so put it last.

cardStyles

The style maps behind the component. Spread them onto any element.

KeyWhat it is
baseThe surface: fill, ring, radius, shadow, rhythm
sizesdefault and sm, each a pair of variables
headerThe two-column grid
titleThe title's type
descriptionThe muted description
actionThe header's second column
contentThe body's inset
footerThe tray, its raised strip and negative margins

Data attributes

AttributeOnEffect
data-slot="card"the rootWhere interop.css keys a leading image
data-sizethe rootReflects the size prop
data-slot="card-header"the headerTargets the header from a parent
data-slot="card-title"the titleTargets the title from a parent
data-slot="card-description"the descriptionTargets the description from a parent
data-slot="card-action"the actionTargets the action from a parent
data-slot="card-content"the bodyTargets the body from a parent
data-slot="card-footer"the footerTargets the footer from a parent

CSS variables

VariableOnEffect
--carbon-card-spacingthe rootThe section gap and every part's inset. 16px, 12px at sm
--carbon-card-title-sizethe rootThe title's size. 16px, 14px at sm