component

Card

A versatile card component for grouping related content. Card uses the Panel core component as its surface layer, supporting the same theming system — feedback colors, background styles, border, and radius control — while adding a structured layout with optional header (heading, subheading, leading, trailing), a content area, and an optional footer.

Default

Changelog · v2.4

Design tokens, refreshed

June 3, 2026 · 4 min read

New

Every color, space, and type value now flows from a single source of truth — tweak one token and the change ripples through every component, in every framework, automatically. This release also ships fluid headings and the new numeric text styles.

Helena Rocha
Helena RochaDesign Systems
---
// GENERATED from packages/demos/card/default.demo.tsx — edit the source, not this file.
import Card from "@pindoba/astro-card";
import Avatar from "@pindoba/astro-avatar";
import Badge from "@pindoba/astro-badge";
import Button from "@pindoba/astro-button";
import Stamp from "@pindoba/astro-stamp";
import { Sparkles, ArrowRight } from "@lucide/astro";
import { css } from "@pindoba/styled-system/css";
import { flex } from "@pindoba/styled-system/patterns";
---

<div class={css({ width: "100%", maxWidth: "42rem" })}>
  <Card
    border="default"
    shadow="sm"
    radius="lg"
    header={{
      eyebrow: "Changelog · v2.4",
      heading: "Design tokens, refreshed",
      subheading: "June 3, 2026 · 4 min read",
    }}
    footer={{}}
  >
    <Stamp
      slot="header-leading"
      size="md"
      shape="square"
      feedback="primary"
      emphasis="primary"
    >
      <Sparkles />
    </Stamp>
    <Badge slot="header-trailing" feedback="success">
      New
    </Badge>
    <div slot="footer-leading" class={flex({ gap: "xs", align: "center" })}>
      <Avatar
        name="Helena Rocha"
        src="https://i.pravatar.cc/96?img=32"
        size="sm"
      />
      <div class={css({ display: "flex", flexDirection: "column" })}>
        <span
          class={css({
            fontSize: "sm",
            fontWeight: "semibold",
            color: "panel.text.bold",
            lineHeight: "tight",
          })}
        >
          Helena Rocha
        </span>
        <span
          class={css({
            fontSize: "xs",
            color: "panel.text.muted",
            lineHeight: "tight",
          })}
        >
          Design Systems
        </span>
      </div>
    </div>
    <Button slot="footer-trailing" size="sm">
      Read more <ArrowRight />
    </Button>
    <p class={css({ margin: "0", color: "panel.text" })}>
      Every color, space, and type value now flows from a single source of truth
      — tweak one token and the change ripples through every component, in every
      framework, automatically. This release also ships fluid headings and the
      new numeric text styles.
    </p>
  </Card>
</div>

Feedback

Cards support semantic feedback colors. The heading, subheading, border, and background all follow the selected color palette.

Weekly digest is ready

12 updates since Monday

A neutral card sets the calm default — quiet enough for routine notifications that don't demand attention.

Dark mode has landed

Now available in Settings

Primary leans on the brand color to spotlight announcements, new features, and the action you'd most like someone to take.

Payment received

Invoice #4,128 · R$ 420.00

Success confirms something went right — a completed payment, a passing build, a saved change.

Storage almost full

8.7 GB of 10 GB used

Warning flags something that needs attention soon but isn't broken yet — a soft nudge before it becomes a problem.

Subscription expired

Renew to restore access

Danger marks the highest-stakes states — failures, expirations, and destructive actions that can't be undone.

---
// GENERATED from packages/demos/card/feedback.demo.tsx — edit the source, not this file.
import Card from "@pindoba/astro-card";
import Button from "@pindoba/astro-button";
import Stamp from "@pindoba/astro-stamp";
import {
  Bell,
  Megaphone,
  CircleCheck,
  TriangleAlert,
  OctagonAlert,
} from "@lucide/astro";
import { stack } from "@pindoba/styled-system/patterns";
import { css } from "@pindoba/styled-system/css";
import type { CardFeedback } from "@pindoba/core-card";

const items = [
  {
    feedback: "neutral",
    heading: "Weekly digest is ready",
    subheading: "12 updates since Monday",
    body: "A neutral card sets the calm default — quiet enough for routine notifications that don't demand attention.",
    cta: "View all",
  },
  {
    feedback: "primary",
    heading: "Dark mode has landed",
    subheading: "Now available in Settings",
    body: "Primary leans on the brand color to spotlight announcements, new features, and the action you'd most like someone to take.",
    cta: "Try it now",
  },
  {
    feedback: "success",
    heading: "Payment received",
    subheading: "Invoice #4,128 · R$ 420.00",
    body: "Success confirms something went right — a completed payment, a passing build, a saved change.",
    cta: "View receipt",
  },
  {
    feedback: "warning",
    heading: "Storage almost full",
    subheading: "8.7 GB of 10 GB used",
    body: "Warning flags something that needs attention soon but isn't broken yet — a soft nudge before it becomes a problem.",
    cta: "Upgrade plan",
  },
  {
    feedback: "danger",
    heading: "Subscription expired",
    subheading: "Renew to restore access",
    body: "Danger marks the highest-stakes states — failures, expirations, and destructive actions that can't be undone.",
    cta: "Renew now",
  },
] satisfies readonly {
  feedback: CardFeedback;
  heading: string;
  subheading: string;
  body: string;
  cta: string;
}[];
---

<div class={stack({ gap: "md", direction: "column", width: "100%" })}>
  {items.map((item) => (
    <Card
      feedback={item.feedback}
      border="default"
      header={{
        heading: item.heading,
        subheading: item.subheading,
      }}
      footer={{}}
    >
      <Stamp
        slot="header-leading"
        size="md"
        shape="square"
        feedback={item.feedback}
        emphasis="secondary"
      >
        {item.feedback === "neutral" ? (
          <>
            <Bell />
          </>
        ) : item.feedback === "primary" ? (
          <>
            <Megaphone />
          </>
        ) : item.feedback === "success" ? (
          <>
            <CircleCheck />
          </>
        ) : item.feedback === "warning" ? (
          <>
            <TriangleAlert />
          </>
        ) : (
          <>
            <OctagonAlert />
          </>
        )}
      </Stamp>
      <Button slot="footer-trailing" size="sm" feedback={item.feedback}>
        {item.cta}
      </Button>
      <p class={css({ margin: "0", color: "panel.text" })}>{item.body}</p>
    </Card>
  ))}
</div>

Size

The size prop controls the padding of the header, content, and footer sections, as well as the heading font size.

2X-Small

Deploy to production

staging → main · 14 commits

Queued

Roll out the latest build to every region. Approvals are in and the canary has stayed healthy for 20 minutes.

Queued 2 min ago
X-Small

Deploy to production

staging → main · 14 commits

Queued

Roll out the latest build to every region. Approvals are in and the canary has stayed healthy for 20 minutes.

Queued 2 min ago
Small

Deploy to production

staging → main · 14 commits

Queued

Roll out the latest build to every region. Approvals are in and the canary has stayed healthy for 20 minutes.

Queued 2 min ago
Medium · default

Deploy to production

staging → main · 14 commits

Queued

Roll out the latest build to every region. Approvals are in and the canary has stayed healthy for 20 minutes.

Queued 2 min ago
Large

Deploy to production

staging → main · 14 commits

Queued

Roll out the latest build to every region. Approvals are in and the canary has stayed healthy for 20 minutes.

Queued 2 min ago
---
// GENERATED from packages/demos/card/size.demo.tsx — edit the source, not this file.
import Card from "@pindoba/astro-card";
import Badge from "@pindoba/astro-badge";
import Button from "@pindoba/astro-button";
import Stamp from "@pindoba/astro-stamp";
import { Rocket } from "@lucide/astro";
import { stack, flex } from "@pindoba/styled-system/patterns";
import { css } from "@pindoba/styled-system/css";
import type { CardSize } from "@pindoba/core-card";

const sizes = [
  { size: "2xs", label: "2X-Small" },
  { size: "xs", label: "X-Small" },
  { size: "sm", label: "Small" },
  { size: "md", label: "Medium · default" },
  { size: "lg", label: "Large" },
] as { size: CardSize; label: string }[];
---

<div class={stack({ gap: "xl", direction: "column", width: "100%" })}>
  {sizes.map((s) => (
    <div>
      <span
        class={css({
          textStyle: "label.sm",
          color: "panel.text.muted",
          display: "block",
          marginBottom: "xs",
        })}
      >
        {s.label}
      </span>
      <Card
        size={s.size}
        border="default"
        shadow="sm"
        header={{
          heading: "Deploy to production",
          subheading: "staging → main · 14 commits",
        }}
        footer={{}}
      >
        <Stamp
          slot="header-leading"
          size="md"
          shape="square"
          feedback="primary"
          emphasis="primary"
        >
          <Rocket />
        </Stamp>
        <Badge slot="header-trailing" feedback="warning">
          Queued
        </Badge>
        <span
          slot="footer-leading"
          class={css({ fontSize: "xs", color: "panel.text.muted" })}
        >
          Queued 2 min ago
        </span>
        <div
          slot="footer-trailing"
          class={flex({ gap: "sm", align: "center" })}
        >
          <Button emphasis="secondary" size="sm">
            Cancel
          </Button>
          <Button feedback="primary" size="sm">
            Deploy
          </Button>
        </div>
        <p class={css({ margin: "0", color: "panel.text" })}>
          Roll out the latest build to every region. Approvals are in and the
          canary has stayed healthy for 20 minutes.
        </p>
      </Card>
    </div>
  ))}
</div>

With Buttons and Icons

The card’s header and footer slots accept rich content. Place an icon in the leading position to help users identify the card’s category at a glance, add a ghost button to trailing for contextual actions like settings or a menu, and use the footer trailing for primary and secondary action buttons.

Emphasis
Feedback
Icon in the header

Q3 Roadmap.pdf

Edited 2 hours ago by Felipe

Draft

Outlines the quarter's milestones, owners, and target ship dates across the platform and design-system tracks.

Ghost button in the header

Project settings

Visibility, access, and integrations

Beta

Control who can view and edit this project, and connect the tools your team already uses every day.

Actions in the footer

Helena Rocha

Product designer · São Paulo

Active

Update the personal details your teammates see across the workspace.

---
// GENERATED from packages/demos/card/with-buttons-and-icons.demo.tsx — edit the source, not this file.
import Card from "@pindoba/astro-card";
import Button from "@pindoba/astro-button";
import Badge from "@pindoba/astro-badge";
import {
  FileText,
  Clock,
  Pencil,
  Settings,
  FlaskConical,
  Ellipsis,
  User,
  CircleCheck,
} from "@lucide/astro";
import { stack, flex } from "@pindoba/styled-system/patterns";
import { css } from "@pindoba/styled-system/css";
import type { CardEmphasis, CardFeedback } from "@pindoba/core-card";

const labelClass = css({
  textStyle: "label.sm",
  color: "panel.text.muted",
  display: "block",
  marginBottom: "xs",
});
const topTrailing = { layout: { trailing: { align: "start" } } } as const;
const emphases = [
  "primary",
  "secondary",
  "tertiary",
] as const satisfies readonly CardEmphasis[];
const feedbacks = [
  "neutral",
  "primary",
  "success",
  "warning",
  "danger",
] as const satisfies readonly CardFeedback[];

const emphasis = "secondary" as CardEmphasis;
const feedback = "neutral" as CardFeedback;
---

<div
  class={stack({ gap: "md", width: "100%" })}
  data-ir-root="card/with-buttons-and-icons"
>
  <div class={flex({ gap: "md", flexWrap: "wrap", align: "flex-end" })}>
    <div class={stack({ gap: "2xs" })}>
      <span class={css({ fontSize: "xs", color: "panel.text.muted" })}>
        Emphasis
      </span>
      <div class={flex({ gap: "2xs", flexWrap: "wrap" })}>
        <!--ir:1-->
        {emphases.map((e) => (
          <Button
            size="sm"
            emphasis={emphasis === e ? "primary" : "secondary"}
            data-ir="2"
            data-ir-key={e}
          >
            <Fragment set:html="<!--ir:3-->" />
            {e}
            <Fragment set:html="<!--/ir-->" />
          </Button>
        ))}
        <!--/ir-->
      </div>
    </div>
    <div class={stack({ gap: "2xs" })}>
      <span class={css({ fontSize: "xs", color: "panel.text.muted" })}>
        Feedback
      </span>
      <div class={flex({ gap: "2xs", flexWrap: "wrap" })}>
        <!--ir:4-->
        {feedbacks.map((f) => (
          <Button
            size="sm"
            feedback={f}
            emphasis={feedback === f ? "primary" : "secondary"}
            data-ir="5"
            data-ir-key={f}
          >
            <Fragment set:html="<!--ir:6-->" />
            {f}
            <Fragment set:html="<!--/ir-->" />
          </Button>
        ))}
        <!--/ir-->
      </div>
    </div>
  </div>
  <div class={stack({ gap: "xl", direction: "column" })}>
    <div>
      <span class={labelClass}>Icon in the header</span>
      <Card
        emphasis={emphasis}
        feedback={feedback}
        header={{
          heading: "Q3 Roadmap.pdf",
          subheading: "Edited 2 hours ago by Felipe",
          ...topTrailing,
        }}
        data-ir="7"
      >
        <Fragment slot="heading-leading">
          <FileText size={20} />
        </Fragment>
        <Fragment slot="subheading-leading">
          <Clock size={14} />
        </Fragment>
        <Badge slot="header-trailing" feedback="warning">
          <Pencil />
          Draft
        </Badge>
        <p class={css({ margin: "0", color: "panel.text" })}>
          Outlines the quarter's milestones, owners, and target ship dates
          across the platform and design-system tracks.
        </p>
      </Card>
    </div>

    <div>
      <span class={labelClass}>Ghost button in the header</span>
      <Card
        emphasis={emphasis}
        feedback={feedback}
        header={{
          heading: "Project settings",
          subheading: "Visibility, access, and integrations",
          ...topTrailing,
        }}
        data-ir="8"
      >
        <Fragment slot="header-leading">
          <Settings size={20} />
        </Fragment>
        <div
          slot="header-trailing"
          class={flex({ gap: "sm", align: "center" })}
        >
          <Badge feedback="primary" emphasis="secondary">
            <FlaskConical />
            Beta
          </Badge>
          <Button aria-label="More options" emphasis="ghost" shape="circle">
            <Ellipsis />
          </Button>
        </div>
        <p class={css({ margin: "0", color: "panel.text" })}>
          Control who can view and edit this project, and connect the tools your
          team already uses every day.
        </p>
      </Card>
    </div>

    <div>
      <span class={labelClass}>Actions in the footer</span>
      <Card
        emphasis={emphasis}
        feedback={feedback}
        header={{
          heading: "Helena Rocha",
          subheading: "Product designer · São Paulo",
          ...topTrailing,
        }}
        footer={{}}
        data-ir="9"
      >
        <Fragment slot="heading-leading">
          <User size={20} />
        </Fragment>
        <Badge slot="header-trailing" feedback="success">
          <CircleCheck />
          Active
        </Badge>
        <div slot="footer-trailing" class={flex({ gap: "sm" })}>
          <Button emphasis="secondary">Cancel</Button>
          <Button>Save changes</Button>
        </div>
        <p class={css({ margin: "0", color: "panel.text" })}>
          Update the personal details your teammates see across the workspace.
        </p>
      </Card>
    </div>
  </div>
</div>

<script>
  import { hydrate } from "./_client/with-buttons-and-icons.ts";

  document
    .querySelectorAll('[data-ir-root="card/with-buttons-and-icons"]')
    .forEach((root) => hydrate([root]));
</script>

When the header has a trailing element, the card pins it to the top-right by default (layout.trailing.align: "start"), so contextual actions align with the first line of a multi-line heading instead of centering. Override it per card via header={{ layout: { trailing: { align: "center" } } }}, and use header={{ layout: { trailing: { justify: "end" } } }} to flush a fixed-width trailing region (like a metadata path) to the right edge.

In a narrow card, header and footer actions wrap instead of running past the card’s edge. The header’s trailing drops onto its own line under the heading once the two can’t share one, and several trailing items (a filter beside a Table’s search, actions beside a pagination) wrap onto more lines, still hugging the right edge. A flank never shrinks below its widest item, so a single Button keeps its size. Style the header’s flanks with the card-level passThrough.leading / passThrough.trailing, and the footer’s with footer.passThrough.leading / .trailing. On a card-based component those go in its card prop (card={{ passThrough: { trailing: { style } } }}).

States

selected renders a dashed border to mark the active card (visible when the card also has a border), and muted dims and desaturates a locked or unavailable card. Both are purely visual — no more reaching for passThrough.root.style with borderStyle / opacity / filter.

Default

Main branch

The card in its resting state.

Selected

Main branch

selected renders a dashed border to mark the active card.

Muted

Locked branch

muted dims and desaturates a locked or unavailable card.

---
// GENERATED from packages/demos/card/states.demo.tsx — edit the source, not this file.
import Card from "@pindoba/astro-card";
import { css } from "@pindoba/styled-system/css";
import { grid } from "@pindoba/styled-system/patterns";

const labelClass = css({
  textStyle: "label.sm",
  color: "panel.text.muted",
  display: "block",
  marginBottom: "xs",
});
---

<div class={grid({ columns: { base: 1, sm: 3 }, gap: "lg" })}>
  <div>
    <span class={labelClass}>Default</span>
    <Card border="default" header={{ heading: "Main branch" }}>
      <p class={css({ margin: "0", color: "panel.text" })}>
        The card in its resting state.
      </p>
    </Card>
  </div>

  <div>
    <span class={labelClass}>Selected</span>
    <Card selected border="default" header={{ heading: "Main branch" }}>
      <p class={css({ margin: "0", color: "panel.text" })}>
        <code>selected</code> renders a dashed border to mark the active card.
      </p>
    </Card>
  </div>

  <div>
    <span class={labelClass}>Muted</span>
    <Card muted border="default" header={{ heading: "Locked branch" }}>
      <p class={css({ margin: "0", color: "panel.text" })}>
        <code>muted</code> dims and desaturates a locked or unavailable card.
      </p>
    </Card>
  </div>
</div>

Inset content

By default a card is flush: one surface, with the header, body and footer stacked on it. Give the content its own background and the card switches to inset mode — the card’s background becomes a shell, and the body renders as a single inset panel sitting on it. The header and footer stay on the shell, so the panel reads as the focus of the card while the chrome recedes.

  • contentBackground is the shorthand: contentBackground="surface.peak" on a background="surface.base" card is all it takes.
  • content is the full surface object for the panel — background, border, shadow (both default to "none"), emphasis and feedback (inherit the card’s when unset), translucent, radius and padding. content.background wins over contentBackground when both are set. Without a background the content stays flush; content.padding still applies there.
  • content.padding — unset, the panel pads by a derived amount (the section padding minus the shell gutter and the panel border), so an inset card is as compact as its flush sibling. Pass a size to set it yourself, or "none" for edge-to-edge content that brings its own row padding (a Table, ListBox, TreeView or List does).
  • Radius — the panel’s corners come from the radii.inner.<radius>.<gutter> token, so they stay concentric with the card’s own corners at every radius and size. An explicit content.radius opts out of the token.

Deployments

Production · last 24 hours

  • main

    #a91f · Iris Okafor

    Live
  • fix/rate-limit

    #7c02 · Tomás Reyes

    Building
  • feat/search-v2

    #e3b8 · Mei Lindqvist

    Failed
Updated 2 minutes ago

Storage

695 GB of 1 TB used

  • Media uploads

    412 GB · 58%

  • Database backups

    186 GB · 26%

  • Build cache

    97 GB · 14%

Release 4.2

3 of 5 checks passed

  • Changelog draftedDone
  • Migrations reviewedDone
  • Load testRunning
Freeze on Thursday
Owner: Platform
---
// GENERATED from packages/demos/card/inset.demo.tsx — edit the source, not this file.
import Card from "@pindoba/astro-card";
import Badge from "@pindoba/astro-badge";
import Button from "@pindoba/astro-button";
import Stamp from "@pindoba/astro-stamp";
import { Rocket, HardDrive, ArrowRight } from "@lucide/astro";
import { css } from "@pindoba/styled-system/css";

const deployments = [
  { id: "a91f", branch: "main", author: "Iris Okafor", status: "Live" },
  {
    id: "7c02",
    branch: "fix/rate-limit",
    author: "Tomás Reyes",
    status: "Building",
  },
  {
    id: "e3b8",
    branch: "feat/search-v2",
    author: "Mei Lindqvist",
    status: "Failed",
  },
] as const;
const statusFeedback = {
  Live: "success",
  Building: "warning",
  Failed: "danger",
} as const;
const volumes = [
  { name: "Media uploads", used: "412 GB", share: "58%" },
  { name: "Database backups", used: "186 GB", share: "26%" },
  { name: "Build cache", used: "97 GB", share: "14%" },
];
const rowStyle = css({
  display: "flex",
  alignItems: "center",
  justifyContent: "space-between",
  gap: "sm",
  paddingBlock: "2xs",
});
const listStyle = css({
  listStyle: "none",
  margin: "0",
  padding: "0",
  display: "flex",
  flexDirection: "column",
  gap: "3xs",
});
const primaryText = css({ margin: "0", color: "panel.text.bold" });
const mutedText = css({
  margin: "0",
  color: "panel.text.muted",
  fontSize: "sm",
});
---

<div
  class={css({
    display: "grid",
    gridTemplateColumns: "repeat(auto-fit, minmax(min(100%, 32rem), 1fr))",
    gap: "md",
    width: "100%",
    alignItems: "start",
  })}
>
  <Card
    background="surface.base"
    contentBackground="surface.peak"
    border="default"
    header={{
      heading: "Deployments",
      subheading: "Production · last 24 hours",
    }}
    footer={{ leading: "Updated 2 minutes ago" }}
  >
    <Stamp slot="header-leading" emphasis="ghost" size="md">
      <Rocket />
    </Stamp>
    <Button slot="header-trailing" emphasis="ghost" size="sm">
      View all
    </Button>
    <Button
      slot="footer-trailing"
      emphasis="primary"
      feedback="primary"
      size="sm"
    >
      Open pipeline <ArrowRight />
    </Button>
    <ul class={listStyle}>
      {deployments.map((deploy) => (
        <li class={rowStyle}>
          <div>
            <p class={primaryText}>{deploy.branch}</p>
            <p class={mutedText}>
              #{deploy.id} · {deploy.author}
            </p>
          </div>
          <Badge feedback={statusFeedback[deploy.status]}>
            {deploy.status}
          </Badge>
        </li>
      ))}
    </ul>
  </Card>

  <Card
    background="surface.base"
    content={{ background: "surface.peak", border: "muted", padding: "sm" }}
    border="default"
    header={{
      heading: "Storage",
      subheading: "695 GB of 1 TB used",
    }}
    footer={{}}
  >
    <Stamp slot="header-leading" emphasis="ghost" size="md">
      <HardDrive />
    </Stamp>
    <Button slot="footer-trailing" emphasis="secondary" size="sm">
      Manage volumes
    </Button>
    <ul class={listStyle}>
      {volumes.map((volume) => (
        <li class={rowStyle}>
          <p class={primaryText}>{volume.name}</p>
          <p class={mutedText}>
            {volume.used} · {volume.share}
          </p>
        </li>
      ))}
    </ul>
  </Card>

  <Card
    border="default"
    divided
    header={{ heading: "Release 4.2", subheading: "3 of 5 checks passed" }}
    footer={{ leading: "Freeze on Thursday", trailing: "Owner: Platform" }}
  >
    <ul class={listStyle}>
      <li class={rowStyle}>
        <span>Changelog drafted</span>
        <Badge feedback="success">Done</Badge>
      </li>
      <li class={rowStyle}>
        <span>Migrations reviewed</span>
        <Badge feedback="success">Done</Badge>
      </li>
      <li class={rowStyle}>
        <span>Load test</span>
        <Badge feedback="warning">Running</Badge>
      </li>
    </ul>
  </Card>
</div>

In an inset card a transparent header or footer sits on the panel’s outer edge: it has no padding of its own, and the shell’s gutter is the only spacing — edge to header, header to panel, panel to footer, footer to edge. The heading starts on the panel’s left edge and the box of a trailing button ends on its right edge. The panel’s own padding then indents the content text past that line; the header and the content text deliberately don’t share a left edge.

Give the header or footer a surface of its own (header={{ background: … }}, footer={{ background: … }}) and it becomes a tile: a small panel on the shell with the same gutter, all four corners from the content’s inner-radius token, and padding that matches the panel — so a tile’s text does line up with the panel’s text.

<Card
  background="surface.base"
  contentBackground="surface.peak"
  header={{ heading: "Usage", background: "surface.hill" }}
  footer={{ trailing: "Billed monthly" }}
>
  …
</Card>

Divided

divided draws a hairline (a real border in the palette’s muted border colour) between the header, content and footer of a flush card. It has no effect on an inset card, which already separates its content, or under rhythm, where a 1px rule would knock the card off the raster.

Runtime surface updates

In Svelte, React, and Vue the card’s surface follows its props — flip emphasis or feedback in state and it repaints. Astro renders once on the server, so the same card gets a runtime path instead: render it with the reactive prop (which serializes the surface config into the HTML) and call updateCard() from @pindoba/astro-card/runtime:

import { updateCard } from "@pindoba/astro-card/runtime";

updateCard(cardElement, { emphasis: "primary", feedback: "danger" });

The patch re-runs the card’s connect in the browser and swaps the surface classes on the root and on the header/footer parts that inherit from the card — parts with an explicit surface of their own stay put, exactly like a re-render. Only the surface axes (emphasis, feedback, background, translucent, border, shadow, interactive) are patchable; layout never moves. The same functions work from vanilla JavaScript via @pindoba/core-card.

Emphasis
Feedback

Runtime surface

Props are reactive by nature here

Here the surface follows the props directly — the buttons above just assign state and the card re-renders. The Astro tab produces the same visual change without a framework runtime via updateCard().

<Card emphasis feedback>
---
// GENERATED from packages/demos/card/reactive.demo.tsx — edit the source, not this file.
import Card from "@pindoba/astro-card";
import Button from "@pindoba/astro-button";
import { flex, stack } from "@pindoba/styled-system/patterns";
import { css } from "@pindoba/styled-system/css";
import type { CardEmphasis, CardFeedback } from "@pindoba/core-card";

const emphases = ["secondary", "primary", "tertiary"] as CardEmphasis[];
const feedbacks = [
  "neutral",
  "primary",
  "success",
  "warning",
  "danger",
] as CardFeedback[];
const labelClass = css({
  textStyle: "label.sm",
  color: "panel.text.muted",
  display: "block",
  marginBottom: "xs",
});

const emphasis = "secondary" as CardEmphasis;
const feedback = "neutral" as CardFeedback;
---

<div class={stack({ gap: "md" })} data-ir-root="card/reactive">
  <div class={stack({ gap: "sm" })}>
    <div>
      <span class={labelClass}>Emphasis</span>
      <div class={flex({ gap: "sm", wrap: "wrap" })}>
        <!--ir:1-->
        {emphases.map((value) => (
          <Button
            size="sm"
            emphasis="secondary"
            data-ir="2"
            data-ir-key={value}
          >
            <Fragment set:html="<!--ir:3-->" />
            {value}
            <Fragment set:html="<!--/ir-->" />
          </Button>
        ))}
        <!--/ir-->
      </div>
    </div>
    <div>
      <span class={labelClass}>Feedback</span>
      <div class={flex({ gap: "sm", wrap: "wrap" })}>
        <!--ir:4-->
        {feedbacks.map((value) => (
          <Button
            size="sm"
            emphasis="secondary"
            data-ir="5"
            data-ir-key={value}
          >
            <Fragment set:html="<!--ir:6-->" />
            {value}
            <Fragment set:html="<!--/ir-->" />
          </Button>
        ))}
        <!--/ir-->
      </div>
    </div>
  </div>

  <Card
    emphasis={emphasis}
    feedback={feedback}
    border="default"
    header={{
      heading: "Runtime surface",
      subheading: "Props are reactive by nature here",
    }}
    footer={{ trailing: "<Card emphasis feedback>" }}
    data-ir="7"
  >
    <p class={css({ margin: "0", color: "panel.text" })}>
      Here the surface follows the props directly — the buttons above just
      assign state and the card re-renders. The Astro tab produces the same
      visual change without a framework runtime via <code>updateCard()</code>.
    </p>
  </Card>
</div>

<script>
  import { hydrate } from "./_client/reactive.ts";

  document
    .querySelectorAll('[data-ir-root="card/reactive"]')
    .forEach((root) => hydrate([root]));
</script>

Media

The media prop puts an image on the card — full-bleed, radius-aware, and framework-consistent. Give it a src/alt for the built-in <img> (lazy-loaded, object-fit: cover), or provide custom content (a <picture>, video, carousel) via the media slot (Astro/Vue) or media.children (Svelte/React) — the wrapper still handles bleed, clipping, and corners.

  • placement — top (default) and bottom are full-bleed blocks; start/end pin the media as a full-height column beside the body (set the column with width, default 33%); background covers the whole card behind the content and turns on a darkening scrim by default so text stays legible (opt out with scrim: false, or opt in on any placement).
  • aspect — auto (intrinsic), square, video (16∶9), portrait (3∶4), or wide (21∶9); fixed ratios keep card grids aligned.
  • fit — cover (default) or contain.
  • inset — renders the media inside the card padding with a corner radius concentric to the card’s own instead of bleeding to the edges (top/bottom placements).

The wrapper, image, and scrim are all addressable via passThrough.media / passThrough.mediaImg / passThrough.mediaScrim.

Sunlight through a forest canopy

Field notes

Under the canopy

A walk through the reserve

The media region bleeds to the card edges and inherits the card's top corners.

Night expedition

Placement “background”

Content sits on a darkening scrim so text stays readable over the photo.

A river bend seen from above

River survey

Placement “start”

The image spans the card's full height while the body flows beside it — no wrapper markup needed.

Coastline at dusk

Coastal preview

Inset media

New

inset keeps the image inside the card padding with a corner radius concentric to the card's own.

---
// GENERATED from packages/demos/card/media.demo.tsx — edit the source, not this file.
import Card from "@pindoba/astro-card";
import Badge from "@pindoba/astro-badge";
import Button from "@pindoba/astro-button";
import { css } from "@pindoba/styled-system/css";
import { grid } from "@pindoba/styled-system/patterns";
---

<div
  class={grid({
    columns: { base: 1, md: 2 },
    gap: "md",
    width: "100%",
    maxWidth: "52rem",
  })}
>
  <Card
    border="default"
    media={{
      src: "https://picsum.photos/seed/pindoba-forest/800/450",
      alt: "Sunlight through a forest canopy",
      aspect: "video",
    }}
    header={{
      eyebrow: "Field notes",
      heading: "Under the canopy",
      subheading: "A walk through the reserve",
    }}
    footer={{}}
  >
    <Button slot="footer-trailing" size="sm" emphasis="secondary">
      Read more
    </Button>
    <p class={css({ color: "panel.text", margin: "0" })}>
      The media region bleeds to the card edges and inherits the card's top
      corners.
    </p>
  </Card>

  <Card
    radius="lg"
    media={{
      src: "https://picsum.photos/seed/pindoba-dunes/800/500",
      alt: "",
      placement: "background",
    }}
    header={{
      heading: "Night expedition",
      subheading: "Placement “background”",
    }}
    passThrough={{ root: { style: css.raw({ minHeight: "14rem" }) } }}
    footer={{}}
  >
    <Button slot="footer-trailing" size="sm" feedback="primary">
      Book now
    </Button>
    <p class={css({ color: "panel.text", margin: "0" })}>
      Content sits on a darkening scrim so text stays readable over the photo.
    </p>
  </Card>

  <Card
    border="default"
    media={{
      src: "https://picsum.photos/seed/pindoba-river/400/600",
      alt: "A river bend seen from above",
      placement: "start",
      width: "14rem",
    }}
    header={{ heading: "River survey", subheading: "Placement “start”" }}
    passThrough={{ root: { style: css.raw({ gridColumn: "1 / -1" }) } }}
  >
    <p class={css({ color: "panel.text", margin: "0" })}>
      The image spans the card's full height while the body flows beside it — no
      wrapper markup needed.
    </p>
  </Card>

  <Card
    border="default"
    media={{
      src: "https://picsum.photos/seed/pindoba-coast/800/340",
      alt: "Coastline at dusk",
      aspect: "wide",
      inset: true,
    }}
    header={{
      heading: "Coastal preview",
      subheading: "Inset media",
    }}
    passThrough={{ root: { style: css.raw({ gridColumn: "1 / -1" }) } }}
  >
    <Badge slot="header-trailing" feedback="success">
      New
    </Badge>
    <p class={css({ color: "panel.text", margin: "0" })}>
      <code>inset</code> keeps the image inside the card padding with a corner
      radius concentric to the card's own.
    </p>
  </Card>
</div>

Corner slot

The corner slot floats a control in the card’s top-right corner — a dismiss button, an overflow menu, a status pill — and works even on a headerless card, sitting over the content rather than inside a header row. It’s inset by the card’s own padding so it lands in the true padded corner. Pass it as the corner prop (React/Svelte/Vue) or the corner named slot (Astro/Vue).

The corner slot floats a control in the card's top-right corner — here a dismiss button — and works even with no header, sitting over the content instead of inside a header row.

---
// GENERATED from packages/demos/card/corner.demo.tsx — edit the source, not this file.
import Card from "@pindoba/astro-card";
import Button from "@pindoba/astro-button";
import { X } from "@lucide/astro";
import { css } from "@pindoba/styled-system/css";
---

<div class={css({ width: "100%", maxWidth: "32rem" })}>
  <Card border="default" radius="lg">
    <Button
      slot="corner"
      emphasis="ghost"
      shape="circle"
      size="sm"
      aria-label="Dismiss"
    >
      <X size={16} />
    </Button>
    <p class={css({ margin: "0", color: "panel.text" })}>
      The <code>corner</code> slot floats a control in the card's top-right
      corner — here a dismiss button — and works even with no header, sitting
      over the content instead of inside a header row.
    </p>
  </Card>
</div>

Card-based components

Several components render their own Card instead of a bare container: Table, ListBox, Tree View, Calendar, Dialog, Command Palette and List. They all share one contract, the card prop:

  • card={{ … }} takes the Card props above — heading, subheading, eyebrow, leading, trailing, corner, footer, size, background, contentBackground / content, divided, passThrough… With no card prop the component still renders a card, using its own surface props as the card’s defaults, so it looks the way it always did.
  • Chrome relocates into the frame. Each component moves its own controls into the card header and footer — the table’s toolbar and pagination, the calendar’s month label and navigation, the dialog’s title and close button. Your trailing and footer.leading / footer.trailing entries come first; the component’s chrome is appended after them. A heading you pass wins over one the component derives (a table caption, a calendar month label).
  • The heading names the widget. When the card renders a heading, the component’s main element is aria-labelledby it unless you pass aria-label / aria-labelledby yourself.
  • card={false} opts out and renders the bare root with the pre-card DOM — for a component that already sits inside a surface that frames it. Composites do this internally: Select, Combobox, Menu and the Command Palette pass card: false to their ListBox, and InputDatetime to its Calendar.
  • Nesting dissolves. A card-based component that is the only child of another card’s content dissolves into it (data-card-flushable): its border, shadow, fill and corners drop so you don’t get a card inside a card. Pass card={{ rootProps: { "data-card-flush": "false" } }} to keep its own surface.
  • Stable selectors. The card root carries data-card-kind — "table", "listbox", "tree-view", "calendar" or "list" — for tests and custom CSS. Dialog and Command Palette render their card inside the <dialog> element, which remains their root.
<Table
  {data}
  {columns}
  card={{
    heading: "Invoices",
    trailing: exportButton,
    contentBackground: "surface.peak",
  }}
/>

Card frame

For component authors: resolveCardFrame(frame, chrome) from @pindoba/core-card is the pure function every card-based component uses to merge the consumer’s card prop with its own chrome. frame is the consumer’s CardFrameProps; chrome is what the component contributes — heading, subheading, trailing, footerLeading, footerTrailing, deterministic headingId / subheadingId, its surface defaults and root attributes. It returns cardProps for the Card plus the header and footer, whose slots are ordered arrays (consumer entries first, then chrome) that CardFrame renders in order with even spacing. Pass each action as a separate array entry — spacing is automatic once a slot holds more than one. Each framework’s card package exports a CardFrame component that renders the resolved frame.

On-call · week 38

Platform team rotation

  • Mon Priya Natarajan 08:00 – 16:00
  • Tue Jonas Weber 08:00 – 16:00
  • Wed Aiyana Brooks 12:00 – 20:00
Times shown in UTC
<script lang="ts">
  // How a card-based component renders its frame: the consumer's `card` prop
  // (`CardFrameProps`) is resolved together with the component's own chrome
  // (`resolveCardFrame`), then `CardFrame` renders it. The component's chrome
  // lands AFTER the consumer's trailing/footer entries.
  import { CardFrame, type CardFrameProps } from "@pindoba/svelte-card";
  import { resolveCardFrame } from "@pindoba/core-card";
  import type { Snippet } from "svelte";
  import Button from "@pindoba/svelte-button";
  import Stamp from "@pindoba/svelte-stamp";
  import { css } from "@pindoba/styled-system/css";
  import LucideCalendarClock from "@lucide/svelte/icons/calendar-clock";
  import LucideChevronLeft from "@lucide/svelte/icons/chevron-left";
  import LucideChevronRight from "@lucide/svelte/icons/chevron-right";

  const shifts = [
    { day: "Mon", engineer: "Priya Natarajan", hours: "08:00 – 16:00" },
    { day: "Tue", engineer: "Jonas Weber", hours: "08:00 – 16:00" },
    { day: "Wed", engineer: "Aiyana Brooks", hours: "12:00 – 20:00" },
  ];

  let week = $state(38);

  // The consumer's `card` prop.
  const consumerCard: CardFrameProps = {
    background: "surface.base",
    contentBackground: "surface.peak",
    border: "default",
    leading: calendarStamp,
    subheading: "Platform team rotation",
    trailing: swapShift,
    footer: { leading: "Times shown in UTC" },
  };

  // What the component's connect would do: resolve the consumer's frame with
  // its own chrome (heading, header actions, footer actions, heading id).
  const frame = $derived(
    resolveCardFrame<Snippet>(consumerCard, {
      heading: `On-call · week ${week}`,
      trailing: weekNav,
      footerTrailing: exportButton,
      headingId: "demo-on-call-heading",
    }),
  );

  // Day | engineer | window on one grid, so the columns line up across rows
  // and the time window never wraps.
  const listStyle = css({
    listStyle: "none",
    margin: "0",
    padding: "0",
    display: "grid",
    gridTemplateColumns: "auto minmax(0, 1fr) auto",
    columnGap: "sm",
  });
  const rowStyle = css({
    display: "grid",
    gridTemplateColumns: "subgrid",
    gridColumn: "1 / -1",
    alignItems: "baseline",
    paddingBlock: "xs",
    color: "panel.text",
    "&:not(:first-child)": {
      borderTopWidth: "1px",
      borderTopStyle: "solid",
      borderTopColor: "panel.border.muted",
    },
  });
  const dayText = css({ color: "panel.text.muted", fontWeight: "medium" });
  const hoursText = css({
    color: "panel.text.muted",
    fontVariantNumeric: "tabular-nums",
    whiteSpace: "nowrap",
  });
</script>

{#snippet calendarStamp()}
  <Stamp emphasis="ghost" size="md"><LucideCalendarClock /></Stamp>
{/snippet}

{#snippet swapShift()}
  <Button emphasis="ghost" size="sm">Swap shift</Button>
{/snippet}

<!-- The "component chrome": what a card-based component would contribute. -->
{#snippet weekNav()}
  <Button
    aria-label="Previous week"
    emphasis="ghost"
    shape="circle"
    size="sm"
    onclick={() => (week -= 1)}
  >
    <LucideChevronLeft />
  </Button>
  <Button
    aria-label="Next week"
    emphasis="ghost"
    shape="circle"
    size="sm"
    onclick={() => (week += 1)}
  >
    <LucideChevronRight />
  </Button>
{/snippet}

{#snippet exportButton()}
  <Button emphasis="secondary" size="sm">Export .ics</Button>
{/snippet}

<div class={css({ width: "100%", maxWidth: "42rem" })}>
  <CardFrame {frame}>
    <ul aria-labelledby={frame.headingId} class={listStyle}>
      {#each shifts as shift (shift.day)}
        <li class={rowStyle}>
          <span class={dayText}>{shift.day}</span>
          <span>{shift.engineer}</span>
          <span class={hoursText}>{shift.hours}</span>
        </li>
      {/each}
    </ul>
  </CardFrame>
</div>

Custom Styling

The passThrough prop provides escape hatches for advanced customization: style accepts any Panda CSS SystemStyleObject applied to any slot, and props forwards arbitrary HTML attributes.

Welcome

Your workspace is ready

A few things you can do right away

Invite teammates and assign roles
Import or generate your design tokens
Browse the component gallery
Takes about 5 minutes
---
// GENERATED from packages/demos/card/custom.demo.tsx — edit the source, not this file.
import Card from "@pindoba/astro-card";
import Button from "@pindoba/astro-button";
import { Check, ArrowRight } from "@lucide/astro";
import { stack, flex } from "@pindoba/styled-system/patterns";
import { css } from "@pindoba/styled-system/css";

const steps = [
  "Invite teammates and assign roles",
  "Import or generate your design tokens",
  "Browse the component gallery",
];
---

<div class={css({ width: "100%", maxWidth: "38rem" })}>
  <Card
    feedback="primary"
    border="default"
    radius="lg"
    shadow="md"
    header={{
      eyebrow: "Welcome",
      heading: "Your workspace is ready",
      subheading: "A few things you can do right away",
    }}
    footer={{}}
    passThrough={{
      root: {
        style: {
          backgroundImage:
            "radial-gradient(130% 150% at 100% 0%, token(colors.primary.surface.ground), token(colors.primary.surface.peak))",
          borderColor: "primary.border.bold",
        },
      },
      heading: { style: { color: "primary.text.bold" } },
    }}
  >
    <span
      slot="footer-leading"
      class={css({ color: "primary.text.muted", fontSize: "sm" })}
    >
      Takes about 5 minutes
    </span>
    <Button slot="footer-trailing" feedback="primary" size="sm">
      Take the tour <ArrowRight />
    </Button>
    <div class={stack({ gap: "sm", direction: "column" })}>
      {steps.map((step) => (
        <div class={flex({ gap: "xs", align: "center" })}>
          <span
            class={flex({
              align: "center",
              justify: "center",
              color: "primary.text.accent",
            })}
          >
            <Check size={18} />
          </span>
          <span class={css({ color: "panel.text" })}>{step}</span>
        </div>
      ))}
    </div>
  </Card>
</div>

The Card primitive composes with Avatar, Badge, Button, and Input to express a wide range of patterns. The demos below cover the most common compositions; everything else is a recombination of the same slots.

Message

Avatar in header.leading, timestamp as eyebrow, action buttons in header.trailing, emoji reactions and reply control in the footer. layout: { root: { align: "start" }, leading: { align: "center" } } pins the actions to the top while keeping the avatar centered on the text block.

Emphasis
Feedback

Build #2,847 passed

Helena Rocha

Helena Rocha

@helena · 2h ago

Bundle shaved 4.2 KB after migrating the icon set to ESM, all 312 tests green, and Lighthouse held at 98 across the board. Pulling staging tomorrow morning — anyone want to pair on the rollout plan over coffee?

---
// GENERATED from packages/demos/card/message.demo.tsx — edit the source, not this file.
import Card from "@pindoba/astro-card";
import Avatar from "@pindoba/astro-avatar";
import Stamp from "@pindoba/astro-stamp";
import Button from "@pindoba/astro-button";
import {
  Reply,
  Archive,
  Sparkles,
  Rocket,
  Coffee,
  Star,
  Flame,
  Paperclip,
} from "@lucide/astro";
import { stack, flex } from "@pindoba/styled-system/patterns";
import { css } from "@pindoba/styled-system/css";
import type { CardEmphasis, CardFeedback } from "@pindoba/core-card";

const emphases = [
  "primary",
  "secondary",
  "tertiary",
] as const satisfies readonly CardEmphasis[];
const feedbacks = [
  "neutral",
  "primary",
  "success",
  "warning",
  "danger",
] as const satisfies readonly CardFeedback[];

const emphasis = "secondary" as CardEmphasis;
const feedback = "neutral" as CardFeedback;
---

<div class={stack({ gap: "md", width: "100%" })} data-ir-root="card/message">
  <div class={flex({ gap: "md", flexWrap: "wrap", align: "flex-end" })}>
    <div class={stack({ gap: "2xs" })}>
      <span class={css({ fontSize: "xs", color: "panel.text.muted" })}>
        Emphasis
      </span>
      <div class={flex({ gap: "2xs", flexWrap: "wrap" })}>
        <!--ir:1-->
        {emphases.map((e) => (
          <Button
            size="sm"
            emphasis={emphasis === e ? "primary" : "secondary"}
            data-ir="2"
            data-ir-key={e}
          >
            <Fragment set:html="<!--ir:3-->" />
            {e}
            <Fragment set:html="<!--/ir-->" />
          </Button>
        ))}
        <!--/ir-->
      </div>
    </div>
    <div class={stack({ gap: "2xs" })}>
      <span class={css({ fontSize: "xs", color: "panel.text.muted" })}>
        Feedback
      </span>
      <div class={flex({ gap: "2xs", flexWrap: "wrap" })}>
        <!--ir:4-->
        {feedbacks.map((f) => (
          <Button
            size="sm"
            feedback={f}
            emphasis={feedback === f ? "primary" : "secondary"}
            data-ir="5"
            data-ir-key={f}
          >
            <Fragment set:html="<!--ir:6-->" />
            {f}
            <Fragment set:html="<!--/ir-->" />
          </Button>
        ))}
        <!--/ir-->
      </div>
    </div>
  </div>
  <div class={stack({ gap: "lg", direction: "column", width: "100%" })}>
    <Card
      emphasis={emphasis}
      feedback={feedback}
      border="default"
      shadow="sm"
      header={{
        heading: "Helena Rocha",
        subheading: "@helena · 2h ago",
        eyebrow: "Build #2,847 passed",
        layout: {
          root: { align: "start" },
          leading: { align: "center" },
        },
      }}
      footer={{}}
      data-ir="7"
    >
      <Avatar
        slot="header-leading"
        name="Helena Rocha"
        src="https://i.pravatar.cc/96?img=32"
        size="lg"
      />
      <div slot="header-trailing" class={flex({ gap: "2xs", align: "center" })}>
        <Button
          aria-label="Reply"
          emphasis="ghost"
          shape="circle"
          size="sm"
          feedback="primary"
        >
          <Reply />
        </Button>
        <Button aria-label="Archive" emphasis="ghost" shape="circle" size="sm">
          <Archive />
        </Button>
      </div>
      <div
        slot="footer-leading"
        class={flex({ gap: "2xs", align: "center", flex: "1" })}
      >
        <Stamp size="sm" shape="circle" emphasis="secondary" feedback="primary">
          <Sparkles />
        </Stamp>
        <Stamp size="sm" shape="circle" emphasis="secondary" feedback="success">
          <Rocket />
        </Stamp>
        <Stamp size="sm" shape="circle" emphasis="secondary" feedback="warning">
          <Coffee />
        </Stamp>
        <Stamp size="sm" shape="circle" emphasis="secondary" feedback="warning">
          <Star />
        </Stamp>
        <Stamp size="sm" shape="circle" emphasis="secondary" feedback="danger">
          <Flame />
        </Stamp>
      </div>
      <Button
        slot="footer-trailing"
        aria-label="Attach file"
        emphasis="secondary"
        shape="square"
        radius="inner"
        size="sm"
      >
        <Paperclip />
      </Button>
      <p class={css({ margin: "0", color: "panel.text" })}>
        Bundle shaved 4.2 KB after migrating the icon set to ESM, all 312 tests
        green, and Lighthouse held at 98 across the board. Pulling staging
        tomorrow morning — anyone want to pair on the rollout plan over coffee?
      </p>
    </Card>
  </div>
</div>

<script>
  import { hydrate } from "./_client/message.ts";

  document
    .querySelectorAll('[data-ir-root="card/message"]')
    .forEach((root) => hydrate([root]));
</script>

Date display

Eyebrow + oversized heading via headingTextStyle="numeric.lg", paired with a small profile row in the footer.

Emphasis
Feedback

Today

Aug 14

Olivia Reis
Stand-up at 10amOlivia Reis

Tomorrow

Aug 15

Felipe Ramos
Design critiqueFelipe Ramos

Friday

Aug 18

Marina Cruz
Sprint demoMarina Cruz
---
// GENERATED from packages/demos/card/date-display.demo.tsx — edit the source, not this file.
import Card from "@pindoba/astro-card";
import Avatar from "@pindoba/astro-avatar";
import Button from "@pindoba/astro-button";
import { css } from "@pindoba/styled-system/css";
import { flex, stack } from "@pindoba/styled-system/patterns";
import type { CardEmphasis, CardFeedback } from "@pindoba/core-card";

const dates = [
  {
    eyebrow: "Today",
    heading: "Aug 14",
    person: { name: "Olivia Reis", src: "https://i.pravatar.cc/96?img=23" },
    label: "Stand-up at 10am",
  },
  {
    eyebrow: "Tomorrow",
    heading: "Aug 15",
    person: { name: "Felipe Ramos", src: "https://i.pravatar.cc/96?img=15" },
    label: "Design critique",
  },
  {
    eyebrow: "Friday",
    heading: "Aug 18",
    person: { name: "Marina Cruz", src: "https://i.pravatar.cc/96?img=44" },
    label: "Sprint demo",
  },
];
const emphases = [
  "primary",
  "secondary",
  "tertiary",
] as const satisfies readonly CardEmphasis[];
const feedbacks = [
  "neutral",
  "primary",
  "success",
  "warning",
  "danger",
] as const satisfies readonly CardFeedback[];

const emphasis = "secondary" as CardEmphasis;
const feedback = "neutral" as CardFeedback;
---

<div
  class={stack({ gap: "md", width: "100%" })}
  data-ir-root="card/date-display"
>
  <div class={flex({ gap: "md", flexWrap: "wrap", align: "flex-end" })}>
    <div class={stack({ gap: "2xs" })}>
      <span class={css({ fontSize: "xs", color: "panel.text.muted" })}>
        Emphasis
      </span>
      <div class={flex({ gap: "2xs", flexWrap: "wrap" })}>
        <!--ir:1-->
        {emphases.map((e) => (
          <Button
            size="sm"
            emphasis={emphasis === e ? "primary" : "secondary"}
            data-ir="2"
            data-ir-key={e}
          >
            <Fragment set:html="<!--ir:3-->" />
            {e}
            <Fragment set:html="<!--/ir-->" />
          </Button>
        ))}
        <!--/ir-->
      </div>
    </div>
    <div class={stack({ gap: "2xs" })}>
      <span class={css({ fontSize: "xs", color: "panel.text.muted" })}>
        Feedback
      </span>
      <div class={flex({ gap: "2xs", flexWrap: "wrap" })}>
        <!--ir:4-->
        {feedbacks.map((f) => (
          <Button
            size="sm"
            feedback={f}
            emphasis={feedback === f ? "primary" : "secondary"}
            data-ir="5"
            data-ir-key={f}
          >
            <Fragment set:html="<!--ir:6-->" />
            {f}
            <Fragment set:html="<!--/ir-->" />
          </Button>
        ))}
        <!--/ir-->
      </div>
    </div>
  </div>
  <div
    class={css({
      display: "grid",
      gridTemplateColumns: "repeat(3, minmax(0, 1fr))",
      gap: "md",
      width: "100%",
    })}
  >
    <!--ir:7-->
    {dates.map((d) => (
      <Card
        emphasis={emphasis}
        feedback={feedback}
        border="default"
        shadow="sm"
        header={{
          eyebrow: d.eyebrow,
          heading: d.heading,
          headingTextStyle: "numeric.lg",
        }}
        data-ir="8"
        data-ir-key={d.heading}
      >
        <div class={flex({ gap: "xs", align: "center", marginTop: "lg" })}>
          <Avatar
            name={d.person.name}
            src={d.person.src}
            size="sm"
            data-ir="9"
          />
          <div class={css({ display: "flex", flexDirection: "column" })}>
            <span
              class={css({
                fontSize: "sm",
                fontWeight: "semibold",
                color: "panel.text.bold",
                lineHeight: "tight",
              })}
            >
              <!--ir:10-->
              {d.label}
              <!--/ir-->
            </span>
            <span
              class={css({
                fontSize: "xs",
                color: "panel.text.muted",
                lineHeight: "tight",
              })}
            >
              <!--ir:11-->
              {d.person.name}
              <!--/ir-->
            </span>
          </div>
        </div>
      </Card>
    ))}
    <!--/ir-->
  </div>
</div>

<script>
  import { hydrate } from "./_client/date-display.ts";

  document
    .querySelectorAll('[data-ir-root="card/date-display"]')
    .forEach((root) => hydrate([root]));
</script>

Members

Avatar cluster on top, then a centered <Banner layout={{ content: { align: "center" } }}> inside the content carries the heading and subheading.

Emphasis
Feedback
Member 1Member 2Member 3Member 4

42 active tokens

8 added this week

Spot which design tokens are pulling weight across the codebase.

Member 1Member 2Member 3

16 components

2 in beta

Building blocks ready to ship — plus the ones still cooking.

Member 1Member 2Member 3Member 4

94 contributors

+5 this month

Designers and engineers nudging Pindoba forward, one PR at a time.

---
// GENERATED from packages/demos/card/members.demo.tsx — edit the source, not this file.
import Card from "@pindoba/astro-card";
import Avatar from "@pindoba/astro-avatar";
import Banner from "@pindoba/astro-banner";
import Button from "@pindoba/astro-button";
import { grid, stack, flex } from "@pindoba/styled-system/patterns";
import { css } from "@pindoba/styled-system/css";
import type { CardEmphasis, CardFeedback } from "@pindoba/core-card";

const groups = [
  {
    heading: "42 active tokens",
    subheading: "8 added this week",
    body: "Spot which design tokens are pulling weight across the codebase.",
    faces: [
      "https://i.pravatar.cc/96?img=30",
      "https://i.pravatar.cc/96?img=31",
      "https://i.pravatar.cc/96?img=32",
      "https://i.pravatar.cc/96?img=33",
    ],
  },
  {
    heading: "16 components",
    subheading: "2 in beta",
    body: "Building blocks ready to ship — plus the ones still cooking.",
    faces: [
      "https://i.pravatar.cc/96?img=40",
      "https://i.pravatar.cc/96?img=41",
      "https://i.pravatar.cc/96?img=42",
    ],
  },
  {
    heading: "94 contributors",
    subheading: "+5 this month",
    body: "Designers and engineers nudging Pindoba forward, one PR at a time.",
    faces: [
      "https://i.pravatar.cc/96?img=50",
      "https://i.pravatar.cc/96?img=51",
      "https://i.pravatar.cc/96?img=52",
      "https://i.pravatar.cc/96?img=53",
    ],
  },
];
const emphases = [
  "primary",
  "secondary",
  "tertiary",
] as const satisfies readonly CardEmphasis[];
const feedbacks = [
  "neutral",
  "primary",
  "success",
  "warning",
  "danger",
] as const satisfies readonly CardFeedback[];

const emphasis = "secondary" as CardEmphasis;
const feedback = "neutral" as CardFeedback;
---

<div class={stack({ gap: "md", width: "100%" })} data-ir-root="card/members">
  <div class={flex({ gap: "md", flexWrap: "wrap", align: "flex-end" })}>
    <div class={stack({ gap: "2xs" })}>
      <span class={css({ fontSize: "xs", color: "panel.text.muted" })}>
        Emphasis
      </span>
      <div class={flex({ gap: "2xs", flexWrap: "wrap" })}>
        <!--ir:1-->
        {emphases.map((e) => (
          <Button
            size="sm"
            emphasis={emphasis === e ? "primary" : "secondary"}
            data-ir="2"
            data-ir-key={e}
          >
            <Fragment set:html="<!--ir:3-->" />
            {e}
            <Fragment set:html="<!--/ir-->" />
          </Button>
        ))}
        <!--/ir-->
      </div>
    </div>
    <div class={stack({ gap: "2xs" })}>
      <span class={css({ fontSize: "xs", color: "panel.text.muted" })}>
        Feedback
      </span>
      <div class={flex({ gap: "2xs", flexWrap: "wrap" })}>
        <!--ir:4-->
        {feedbacks.map((f) => (
          <Button
            size="sm"
            feedback={f}
            emphasis={feedback === f ? "primary" : "secondary"}
            data-ir="5"
            data-ir-key={f}
          >
            <Fragment set:html="<!--ir:6-->" />
            {f}
            <Fragment set:html="<!--/ir-->" />
          </Button>
        ))}
        <!--/ir-->
      </div>
    </div>
  </div>
  <div
    class={css({
      display: "grid",
      gridTemplateColumns: "repeat(3, minmax(0, 1fr))",
      gap: "md",
      width: "100%",
    })}
  >
    <!--ir:7-->
    {groups.map((g) => (
      <Card
        emphasis={emphasis}
        feedback={feedback}
        border="default"
        shadow="sm"
        passThrough={{
          root: { style: { textAlign: "center" } },
          content: {
            style: {
              display: "flex",
              flexDirection: "column",
              alignItems: "center",
              gap: "sm",
            },
          },
        }}
        data-ir="8"
        data-ir-key={g.heading}
      >
        <div
          class={grid({
            columns: 2,
            gap: "2xs",
            width: "fit-content",
          })}
        >
          <!--ir:9-->
          {g.faces.slice(0, 4).map((src, i) => (
            <Avatar
              name={`Member ${i + 1}`}
              src={src}
              size="sm"
              data-ir="10"
              data-ir-key={src}
            />
          ))}
          <!--/ir-->
        </div>
        <Banner
          emphasis={emphasis}
          feedback={feedback}
          layout={{ content: { align: "center" } }}
          heading={g.heading}
          headingTextStyle="heading.xs"
          subheading={g.subheading}
          data-ir="11"
        />
        <p
          class={css({
            margin: "0",
            fontSize: "sm",
            color: "panel.text.muted",
          })}
        >
          <!--ir:12-->
          {g.body}
          <!--/ir-->
        </p>
      </Card>
    ))}
    <!--/ir-->
  </div>
</div>

<script>
  import { hydrate } from "./_client/members.ts";

  document
    .querySelectorAll('[data-ir-root="card/members"]')
    .forEach((root) => hydrate([root]));
</script>

Contact list

Header with avatar preview and counter, scrollable list in content via maxHeight + overflowY, primary action pinned in footer.

Emphasis
Feedback
Pinned 1Pinned 2Pinned 3+38

Invite to project

Workspace members42 active
Helena Rocha

Helena Rocha

helena@pindoba.dev

Felipe Ramos

Felipe Ramos

felipe@pindoba.dev

Marina Cruz

Marina Cruz

marina@pindoba.dev

Olivia Reis

Olivia Reis

olivia@pindoba.dev

Bruno Carvalho

Bruno Carvalho

bruno@pindoba.dev

Camila Andrade

Camila Andrade

camila@pindoba.dev

Lucas Mendes

Lucas Mendes

lucas@pindoba.dev

Beatriz Lima

Beatriz Lima

beatriz@pindoba.dev

Rafael Costa

Rafael Costa

rafael@pindoba.dev

Sofia Almeida

Sofia Almeida

sofia@pindoba.dev

---
// GENERATED from packages/demos/card/contact-list.demo.tsx — edit the source, not this file.
import Card from "@pindoba/astro-card";
import Banner from "@pindoba/astro-banner";
import Avatar from "@pindoba/astro-avatar";
import Group from "@pindoba/astro-group";
import Button from "@pindoba/astro-button";
import { Plus, UserPlus, Mail } from "@lucide/astro";
import { stack, flex } from "@pindoba/styled-system/patterns";
import { css } from "@pindoba/styled-system/css";
import type { CardEmphasis, CardFeedback } from "@pindoba/core-card";

const members = [
  {
    name: "Helena Rocha",
    email: "helena@pindoba.dev",
    src: "https://i.pravatar.cc/96?img=32",
  },
  {
    name: "Felipe Ramos",
    email: "felipe@pindoba.dev",
    src: "https://i.pravatar.cc/96?img=15",
  },
  {
    name: "Marina Cruz",
    email: "marina@pindoba.dev",
    src: "https://i.pravatar.cc/96?img=44",
  },
  {
    name: "Olivia Reis",
    email: "olivia@pindoba.dev",
    src: "https://i.pravatar.cc/96?img=23",
  },
  {
    name: "Bruno Carvalho",
    email: "bruno@pindoba.dev",
    src: "https://i.pravatar.cc/96?img=12",
  },
  {
    name: "Camila Andrade",
    email: "camila@pindoba.dev",
    src: "https://i.pravatar.cc/96?img=47",
  },
  {
    name: "Lucas Mendes",
    email: "lucas@pindoba.dev",
    src: "https://i.pravatar.cc/96?img=8",
  },
  {
    name: "Beatriz Lima",
    email: "beatriz@pindoba.dev",
    src: "https://i.pravatar.cc/96?img=49",
  },
  {
    name: "Rafael Costa",
    email: "rafael@pindoba.dev",
    src: "https://i.pravatar.cc/96?img=11",
  },
  {
    name: "Sofia Almeida",
    email: "sofia@pindoba.dev",
    src: "https://i.pravatar.cc/96?img=21",
  },
];
const emphases = [
  "primary",
  "secondary",
  "tertiary",
] as const satisfies readonly CardEmphasis[];
const feedbacks = [
  "neutral",
  "primary",
  "success",
  "warning",
  "danger",
] as const satisfies readonly CardFeedback[];

const emphasis = "secondary" as CardEmphasis;
const feedback = "neutral" as CardFeedback;
---

<div
  class={stack({ gap: "md", width: "100%" })}
  data-ir-root="card/contact-list"
>
  <div class={flex({ gap: "md", flexWrap: "wrap", align: "flex-end" })}>
    <div class={stack({ gap: "2xs" })}>
      <span class={css({ fontSize: "xs", color: "panel.text.muted" })}>
        Emphasis
      </span>
      <div class={flex({ gap: "2xs", flexWrap: "wrap" })}>
        <!--ir:1-->
        {emphases.map((e) => (
          <Button
            size="sm"
            emphasis={emphasis === e ? "primary" : "secondary"}
            data-ir="2"
            data-ir-key={e}
          >
            <Fragment set:html="<!--ir:3-->" />
            {e}
            <Fragment set:html="<!--/ir-->" />
          </Button>
        ))}
        <!--/ir-->
      </div>
    </div>
    <div class={stack({ gap: "2xs" })}>
      <span class={css({ fontSize: "xs", color: "panel.text.muted" })}>
        Feedback
      </span>
      <div class={flex({ gap: "2xs", flexWrap: "wrap" })}>
        <!--ir:4-->
        {feedbacks.map((f) => (
          <Button
            size="sm"
            feedback={f}
            emphasis={feedback === f ? "primary" : "secondary"}
            data-ir="5"
            data-ir-key={f}
          >
            <Fragment set:html="<!--ir:6-->" />
            {f}
            <Fragment set:html="<!--/ir-->" />
          </Button>
        ))}
        <!--/ir-->
      </div>
    </div>
  </div>
  <div class={stack({ gap: "lg", direction: "column", width: "100%" })}>
    <Card
      emphasis={emphasis}
      feedback={feedback}
      border="default"
      shadow="sm"
      header={{
        heading: "Invite to project",
      }}
      footer={{}}
      data-ir="7"
    >
      <Group slot="header-leading" variant="stack">
        <Avatar
          name="Pinned 1"
          src="https://i.pravatar.cc/96?img=60"
          size="sm"
        />
        <Avatar
          name="Pinned 2"
          src="https://i.pravatar.cc/96?img=61"
          size="sm"
        />
        <Avatar
          name="Pinned 3"
          src="https://i.pravatar.cc/96?img=62"
          size="sm"
        />
        <Avatar size="sm" name="+38" />
      </Group>
      <Button slot="header-trailing" emphasis="secondary" size="sm">
        Add member <UserPlus />
      </Button>
      <Button
        slot="footer-trailing"
        feedback="primary"
        emphasis="primary"
        size="sm"
      >
        <Mail /> Send invites
      </Button>
      <div class={flex({ justify: "space-between", align: "center" })}>
        <span
          class={css({
            fontSize: "sm",
            fontWeight: "semibold",
            color: "panel.text.bold",
          })}
        >
          Workspace members
        </span>
        <span
          class={css({
            fontSize: "xs",
            color: "panel.text.muted",
            fontWeight: "medium",
          })}
        >
          42 active
        </span>
      </div>

      <div
        class={css({
          display: "flex",
          flexDirection: "column",
          gap: "sm",
          marginTop: "sm",
          maxHeight: "320px",
          overflowY: "auto",
          paddingRight: "2xs",
        })}
      >
        <!--ir:8-->
        {members.map((m) => (
          <Banner
            emphasis={emphasis}
            feedback={feedback}
            size="sm"
            heading={m.name}
            subheading={m.email}
            data-ir="9"
            data-ir-key={m.email}
          >
            <Avatar
              slot="leading"
              name={m.name}
              src={m.src}
              size="md"
              data-ir="10"
            />
            <Button
              slot="trailing"
              aria-label="Add contact"
              emphasis="secondary"
              shape="circle"
              size="sm"
            >
              <Plus />
            </Button>
          </Banner>
        ))}
        <!--/ir-->
      </div>
    </Card>
  </div>
</div>

<script>
  import { hydrate } from "./_client/contact-list.ts";

  document
    .querySelectorAll('[data-ir-root="card/contact-list"]')
    .forEach((root) => hydrate([root]));
</script>

Brand

A minimal card with no header or footer — content is centered via passThrough.content.style. interactive enables hover feedback.

Emphasis
Feedback

Folha

Coqueiro

Raiz

---
// GENERATED from packages/demos/card/brand.demo.tsx — edit the source, not this file.
import Card from "@pindoba/astro-card";
import Banner from "@pindoba/astro-banner";
import Button from "@pindoba/astro-button";
import { Leaf, TreePalm, Sprout } from "@lucide/astro";
import { css } from "@pindoba/styled-system/css";
import { stack, flex } from "@pindoba/styled-system/patterns";
import type { CardEmphasis, CardFeedback } from "@pindoba/core-card";

const brands = [
  { name: "Folha", icon: "leaf" },
  { name: "Coqueiro", icon: "palmtree" },
  { name: "Raiz", icon: "sprout" },
];
const emphases = [
  "primary",
  "secondary",
  "tertiary",
] as const satisfies readonly CardEmphasis[];
const feedbacks = [
  "neutral",
  "primary",
  "success",
  "warning",
  "danger",
] as const satisfies readonly CardFeedback[];

const emphasis = "secondary" as CardEmphasis;
const feedback = "neutral" as CardFeedback;
---

<div class={stack({ gap: "md", width: "100%" })} data-ir-root="card/brand">
  <div class={flex({ gap: "md", flexWrap: "wrap", align: "flex-end" })}>
    <div class={stack({ gap: "2xs" })}>
      <span class={css({ fontSize: "xs", color: "panel.text.muted" })}>
        Emphasis
      </span>
      <div class={flex({ gap: "2xs", flexWrap: "wrap" })}>
        <!--ir:1-->
        {emphases.map((e) => (
          <Button
            size="sm"
            emphasis={emphasis === e ? "primary" : "secondary"}
            data-ir="2"
            data-ir-key={e}
          >
            <Fragment set:html="<!--ir:3-->" />
            {e}
            <Fragment set:html="<!--/ir-->" />
          </Button>
        ))}
        <!--/ir-->
      </div>
    </div>
    <div class={stack({ gap: "2xs" })}>
      <span class={css({ fontSize: "xs", color: "panel.text.muted" })}>
        Feedback
      </span>
      <div class={flex({ gap: "2xs", flexWrap: "wrap" })}>
        <!--ir:4-->
        {feedbacks.map((f) => (
          <Button
            size="sm"
            feedback={f}
            emphasis={feedback === f ? "primary" : "secondary"}
            data-ir="5"
            data-ir-key={f}
          >
            <Fragment set:html="<!--ir:6-->" />
            {f}
            <Fragment set:html="<!--/ir-->" />
          </Button>
        ))}
        <!--/ir-->
      </div>
    </div>
  </div>
  <div
    class={css({
      display: "grid",
      gridTemplateColumns: "repeat(3, minmax(0, 1fr))",
      gap: "md",
      width: "100%",
    })}
  >
    <!--ir:7-->
    {brands.map((b) => (
      <Card
        emphasis={emphasis}
        feedback={feedback}
        interactive
        border="default"
        shadow="sm"
        passThrough={{
          content: {
            style: {
              minHeight: "240px",
              display: "flex",
              flexDirection: "column",
              alignItems: "center",
              justifyContent: "center",
              gap: "lg",
            },
          },
        }}
        data-ir="8"
        data-ir-key={b.name}
      >
        <div
          class={css({
            display: "flex",
            alignItems: "center",
            justifyContent: "center",
            color: "panel.text.bold",
          })}
        >
          <!--ir:9-->
          {b.icon === "leaf" ? (
            <>
              <Leaf size={48} />
            </>
          ) : (
            <>
              <!--ir:10-->
              {b.icon === "palmtree" ? (
                <>
                  <TreePalm size={48} />
                </>
              ) : (
                <>
                  <Sprout size={48} />
                </>
              )}
              <!--/ir-->
            </>
          )}
          <!--/ir-->
        </div>
        <Banner
          emphasis={emphasis}
          feedback={feedback}
          layout={{ content: { align: "center" } }}
          heading={b.name}
          headingTextStyle="heading.md"
          data-ir="11"
        />
      </Card>
    ))}
    <!--/ir-->
  </div>
</div>

<script>
  import { hydrate } from "./_client/brand.ts";

  document
    .querySelectorAll('[data-ir-root="card/brand"]')
    .forEach((root) => hydrate([root]));
</script>

Slot surfaces

Header and footer are both panel-backed, so each can carry its own background, feedback, and emphasis independent of the card body. Use it for stepped meta rows, filled hero strips, footer action zones, or success-tinted confirmation banners.

Helena Rocha

Helena Rocha

Pinned this morning

A subtly stepped header sets the meta block apart from the body without shouting — useful for activity feeds and threaded conversations.

Welcome aboard

Your workspace is set up and ready to roll.

Filled feedback surfaces work as hero strips — the header carries its own palette while the body stays in the card's neutral context.

Daily digest

284 events processed

The footer is panel-backed too — a stepped strip is a quiet way to mark the action zone or summary footer without a hard divider line.

Payment received

Order #4,128

A feedback-tinted footer doubles as a confirmation strip — success here, but warning and danger work the same way for nudges and alerts.

---
// GENERATED from packages/demos/card/slot-surfaces.demo.tsx — edit the source, not this file.
import Card from "@pindoba/astro-card";
import Avatar from "@pindoba/astro-avatar";
import Button from "@pindoba/astro-button";
import Stamp from "@pindoba/astro-stamp";
import { Ellipsis, Rocket, Check, ArrowRight } from "@lucide/astro";
import { css } from "@pindoba/styled-system/css";
import { flex } from "@pindoba/styled-system/patterns";
---

<div
  class={css({
    display: "grid",
    gridTemplateColumns: "repeat(2, minmax(0, 1fr))",
    gap: "md",
    width: "100%",
  })}
>
  <Card
    border="default"
    shadow="sm"
    header={{
      heading: "Helena Rocha",
      subheading: "Pinned this morning",
      background: "surface.base",
    }}
  >
    <Avatar
      slot="header-leading"
      name="Helena Rocha"
      src="https://i.pravatar.cc/96?img=32"
      size="md"
    />
    <Button
      slot="header-trailing"
      aria-label="More options"
      emphasis="ghost"
      shape="circle"
      size="sm"
    >
      <Ellipsis />
    </Button>
    <p class={css({ margin: "0", color: "panel.text" })}>
      A subtly stepped header sets the meta block apart from the body without
      shouting — useful for activity feeds and threaded conversations.
    </p>
  </Card>

  <Card
    border="default"
    shadow="sm"
    header={{
      heading: "Welcome aboard",
      subheading: "Your workspace is set up and ready to roll.",
      feedback: "primary",
      emphasis: "primary",
      background: "surface.peak",
    }}
    footer={{}}
  >
    <Stamp
      slot="header-leading"
      size="md"
      shape="square"
      feedback="primary"
      background="surface.ground"
      emphasis="primary"
    >
      <Rocket />
    </Stamp>
    <Button
      slot="footer-trailing"
      feedback="primary"
      emphasis="primary"
      size="sm"
    >
      Get started <ArrowRight />
    </Button>
    <p class={css({ margin: "0", color: "panel.text" })}>
      Filled feedback surfaces work as hero strips — the header carries its own
      palette while the body stays in the card's neutral context.
    </p>
  </Card>

  <Card
    border="default"
    shadow="sm"
    header={{
      heading: "Daily digest",
      subheading: "284 events processed",
    }}
    footer={{
      background: "surface.base",
    }}
  >
    <div slot="footer-trailing" class={flex({ gap: "xs", align: "center" })}>
      <Button emphasis="ghost" size="sm">
        Skip
      </Button>
      <Button emphasis="primary" feedback="primary" size="sm">
        Open report
      </Button>
    </div>
    <p class={css({ margin: "0", color: "panel.text" })}>
      The footer is panel-backed too — a stepped strip is a quiet way to mark
      the action zone or summary footer without a hard divider line.
    </p>
  </Card>

  <Card
    border="default"
    shadow="sm"
    header={{
      heading: "Payment received",
      subheading: "Order #4,128",
    }}
    footer={{
      feedback: "success",
      background: "surface.peak",
    }}
  >
    <Stamp slot="header-leading" size="md" shape="circle" feedback="success">
      <Check />
    </Stamp>
    <Button
      slot="footer-trailing"
      emphasis="ghost"
      feedback="success"
      size="sm"
    >
      View receipt <ArrowRight />
    </Button>
    <p class={css({ margin: "0", color: "panel.text" })}>
      A feedback-tinted footer doubles as a confirmation strip — success here,
      but warning and danger work the same way for nudges and alerts.
    </p>
  </Card>
</div>

Eyebrow

The card header supports an eyebrow — a small kicker line above the heading. It is delegated to the underlying Banner component, so it inherits panel-aware text color and auto-flips to contrast under emphasis="primary". Pass it as a string or as { content, leading, trailing } to add an icon.

<Card header={{
  eyebrow: "Wednesday",
  heading: "Dec 14",
}} />
  • Components
  • Blocks