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.
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.
---
// 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>Cards support semantic feedback colors. The heading, subheading, border, and background all follow the selected color palette.
A neutral card sets the calm default — quiet enough for routine notifications that don't demand attention.
Primary leans on the brand color to spotlight announcements, new features, and the action you'd most like someone to take.
Success confirms something went right — a completed payment, a passing build, a saved change.
Warning flags something that needs attention soon but isn't broken yet — a soft nudge before it becomes a problem.
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>The size prop controls the padding of the header, content, and footer sections, as well as the heading font size.
Roll out the latest build to every region. Approvals are in and the canary has stayed healthy for 20 minutes.
Roll out the latest build to every region. Approvals are in and the canary has stayed healthy for 20 minutes.
Roll out the latest build to every region. Approvals are in and the canary has stayed healthy for 20 minutes.
Roll out the latest build to every region. Approvals are in and the canary has stayed healthy for 20 minutes.
Roll out the latest build to every region. Approvals are in and the canary has stayed healthy for 20 minutes.
---
// 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>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.
Outlines the quarter's milestones, owners, and target ship dates across the platform and design-system tracks.
Control who can view and edit this project, and connect the tools your team already uses every day.
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 } } }}).
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.
The card in its resting state.
selected renders a dashed border to mark the active card.
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>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).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.---
// 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 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.
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.
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().
---
// 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>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.
The media region bleeds to the card edges and inherits the card's top corners.
Content sits on a darkening scrim so text stays readable over the photo.
The image spans the card's full height while the body flows beside it — no wrapper markup needed.
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>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>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.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).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.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.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",
}}
/>
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.
<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>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.
---
// 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.
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.
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>Eyebrow + oversized heading via headingTextStyle="numeric.lg", paired with a small profile row in the footer.
---
// 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>Avatar cluster on top, then a centered <Banner layout={{ content: { align: "center" } }}> inside the content carries the heading and subheading.
Spot which design tokens are pulling weight across the codebase.
Building blocks ready to ship — plus the ones still cooking.
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>Header with avatar preview and counter, scrollable list in content via maxHeight + overflowY, primary action pinned in footer.
---
// 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>A minimal card with no header or footer — content is centered via passThrough.content.style. interactive enables hover feedback.
---
// 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>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.
A subtly stepped header sets the meta block apart from the body without shouting — useful for activity feeds and threaded conversations.
Filled feedback surfaces work as hero strips — the header carries its own palette while the body stays in the card's neutral context.
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.
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>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",
}} />