Skip to content

Card

A discrete record in a collection, with five tones and a composable header, title, description and footer.

LayoutstarterFeaturedlayoutcontainersurfacecollection

Live preview

full widthLive preview — open it in a new tab for the full-height version.
Open the preview in a new tab

Source

The exact file rendered in the preview above.

import Link from 'next/link'
import { ArrowUpRight } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import { DemoGrid, DemoStage } from './_kit'

export default function CardDemo() {
  return (
    <DemoStage>
      <DemoGrid columns={2}>
        <Card>
          <CardHeader>
            <div className="min-w-0">
              <CardTitle>Outline</CardTitle>
              <CardDescription>The default. A line, not a shadow.</CardDescription>
            </div>
            <Badge tone="accent">Default</Badge>
          </CardHeader>
          <CardFooter>
            <Button size="sm" variant="outline">
              Configure
            </Button>
          </CardFooter>
        </Card>

        <Card tone="raised">
          <CardTitle>Raised</CardTitle>
          <CardDescription className="mt-1">
            Elevation for cards that float above a busy surface.
          </CardDescription>
        </Card>

        <Card tone="sunken">
          <CardTitle>Sunken</CardTitle>
          <CardDescription className="mt-1">
            Recedes — useful for secondary information inside a panel.
          </CardDescription>
        </Card>

        <Card tone="accent">
          <CardTitle>Accent</CardTitle>
          <CardDescription className="mt-1">
            Tinted, for the recommended option in a set.
          </CardDescription>
        </Card>

        <Card as={Link} href="/components/panel" interactive className="group block">
          <CardHeader>
            <CardTitle>Interactive</CardTitle>
            <ArrowUpRight className="size-4 shrink-0 text-ink-subtle transition-colors group-hover:text-accent" />
          </CardHeader>
          <CardDescription className="mt-1">
            The whole card is the link, so the hit area matches the affordance.
          </CardDescription>
        </Card>

        <Card padding="lg">
          <CardTitle>Large padding</CardTitle>
          <CardDescription className="mt-1">
            Padding steps with the density axis — try Compact and Relaxed.
          </CardDescription>
        </Card>
      </DemoGrid>
    </DemoStage>
  )
}

components/demos/card.tsx

import Link from 'next/link'
import { ArrowUpRight } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import { DemoGrid, DemoStage } from './_kit'

export default function CardDemo() {
  return (
    <DemoStage>
      <DemoGrid columns={2}>
        <Card>
          <CardHeader>
            <div className="min-w-0">
              <CardTitle>Outline</CardTitle>
              <CardDescription>The default. A line, not a shadow.</CardDescription>
            </div>
            <Badge tone="accent">Default</Badge>
          </CardHeader>
          <CardFooter>
            <Button size="sm" variant="outline">
              Configure
            </Button>
          </CardFooter>
        </Card>

        <Card tone="raised">
          <CardTitle>Raised</CardTitle>
          <CardDescription className="mt-1">
            Elevation for cards that float above a busy surface.
          </CardDescription>
        </Card>

        <Card tone="sunken">
          <CardTitle>Sunken</CardTitle>
          <CardDescription className="mt-1">
            Recedes — useful for secondary information inside a panel.
          </CardDescription>
        </Card>

        <Card tone="accent">
          <CardTitle>Accent</CardTitle>
          <CardDescription className="mt-1">
            Tinted, for the recommended option in a set.
          </CardDescription>
        </Card>

        <Card as={Link} href="/components/panel" interactive className="group block">
          <CardHeader>
            <CardTitle>Interactive</CardTitle>
            <ArrowUpRight className="size-4 shrink-0 text-ink-subtle transition-colors group-hover:text-accent" />
          </CardHeader>
          <CardDescription className="mt-1">
            The whole card is the link, so the hit area matches the affordance.
          </CardDescription>
        </Card>

        <Card padding="lg">
          <CardTitle>Large padding</CardTitle>
          <CardDescription className="mt-1">
            Padding steps with the density axis — try Compact and Relaxed.
          </CardDescription>
        </Card>
      </DemoGrid>
    </DemoStage>
  )
}

components/ui/card.tsx

import type { HTMLAttributes, ReactNode, ElementType } from 'react'
import { cn } from '@/lib/cn'

/**
 * Card & Panel
 *
 * Two containment primitives with deliberately different jobs:
 *
 *   Card  — a discrete, often interactive record in a collection.
 *   Panel — a titled region of a page, with an optional header action row.
 *
 * Keeping them separate is what stops the library from degenerating into
 * "everything is a rounded box with a shadow".
 */

export interface CardProps extends HTMLAttributes<HTMLDivElement> {
  as?: ElementType
  /** Forwarded when `as` renders a link. */
  href?: string
  /** `outline` is the default; `raised` adds elevation; `sunken` insets. */
  tone?: 'outline' | 'raised' | 'sunken' | 'ghost' | 'accent'
  /** Adds hover affordance. Only use when the whole card is a link/button. */
  interactive?: boolean
  padding?: 'none' | 'sm' | 'md' | 'lg'
}

const cardTones = {
  outline: 'bg-surface border border-line',
  raised: 'bg-surface-raised border border-line shadow-sm',
  sunken: 'bg-surface-sunken border border-line-subtle',
  ghost: 'bg-transparent border border-transparent',
  accent: 'bg-accent-soft border border-accent-line',
} as const

const cardPadding = {
  none: 'p-0',
  sm: 'p-3',
  md: 'p-card',
  lg: 'p-6 sm:p-8',
} as const

export function Card({
  as: Tag = 'div',
  tone = 'outline',
  interactive = false,
  padding = 'md',
  className,
  children,
  ...props
}: CardProps) {
  return (
    <Tag
      className={cn(
        'rounded-lg',
        cardTones[tone],
        cardPadding[padding],
        interactive &&
          'transition-[border-color,box-shadow,background-color] duration-150 ease-standard hover:border-line-strong hover:shadow-sm',
        className,
      )}
      {...props}
    >
      {children}
    </Tag>
  )
}

export function CardHeader({ className, children, ...props }: HTMLAttributes<HTMLDivElement>) {
  return (
    <div className={cn('flex items-start justify-between gap-4', className)} {...props}>
      {children}
    </div>
  )
}

export function CardTitle({
  as: Tag = 'h3',
  className,
  children,
  ...props
}: HTMLAttributes<HTMLHeadingElement> & { as?: ElementType }) {
  return (
    <Tag className={cn('text-md leading-snug font-semibold text-ink-strong', className)} {...props}>
      {children}
    </Tag>
  )
}

export function CardDescription({
  className,
  children,
  ...props
}: HTMLAttributes<HTMLParagraphElement>) {
  return (
    <p className={cn('text-sm leading-normal text-ink-muted', className)} {...props}>
      {children}
    </p>
  )
}

export function CardFooter({ className, children, ...props }: HTMLAttributes<HTMLDivElement>) {
  return (
    <div
      className={cn(
        'mt-4 flex flex-wrap items-center gap-3 border-t border-line-subtle pt-4',
        className,
      )}
      {...props}
    >
      {children}
    </div>
  )
}

export interface PanelProps extends Omit<HTMLAttributes<HTMLElement>, 'title'> {
  title: ReactNode
  description?: ReactNode
  /** Rendered on the right of the panel header. */
  action?: ReactNode
  /** Removes body padding — for tables and lists that manage their own. */
  flush?: boolean
  as?: ElementType
  headingLevel?: 'h2' | 'h3' | 'h4'
}

export function Panel({
  title,
  description,
  action,
  flush = false,
  as: Tag = 'section',
  headingLevel: Heading = 'h3',
  className,
  children,
  ...props
}: PanelProps) {
  return (
    <Tag
      className={cn('overflow-hidden rounded-lg border border-line bg-surface', className)}
      {...props}
    >
      <div className="flex flex-wrap items-start justify-between gap-3 border-b border-line-subtle bg-surface-sunken px-4 py-3">
        <div className="min-w-0">
          <Heading className="text-sm font-semibold text-ink-strong">{title}</Heading>
          {description ? <p className="mt-0.5 text-xs text-ink-muted">{description}</p> : null}
        </div>
        {action ? <div className="flex shrink-0 items-center gap-2">{action}</div> : null}
      </div>
      <div className={cn(flush ? '' : 'p-card')}>{children}</div>
    </Tag>
  )
}

Demo source — adapt to your project. Foundry is not published as a package.

Usage

For one item among several of the same kind. Card is deliberately distinct from Panel — keeping them separate is what stops a design system degenerating into "everything is a rounded box with a shadow".

  • Only set `interactive` when the entire card is a link or button; a hover affordance on a non-clickable card is a lie.
  • Render it as a link with `as={Link}` so the whole surface is one focus stop rather than a nest of them.
  • Accent tone marks the recommended option in a set — use it once.

Variants and states

Every entry below is a genuine difference in behaviour or layout, and every one of them is visible in the preview above.

  • Outline
  • Raised
  • Sunken
  • Ghost
  • Accent
  • Interactive

Accessibility

One target
A card containing several links needs each link labelled; prefer a single primary link per card.
Headings
CardTitle defaults to `h3`; set `as` so the page outline stays correct.

Foundry implements published ARIA patterns and is tested against them. No WCAG certification is claimed — see the accessibility documentation for what is and is not covered.