Card
Card is the white shell: soft shadow, superellipse corners, full width. It groups one unit of content on the page grid. The parts compose around it. A header holds exactly one trailing control cluster. It is not a toolbar.
import Card, { CardContent, CardHeader, CardTabs, CardAction, CardMenu } from "@haloid/azucar/ui/card";
Anatomy
<Card> {/* the shell; the page grid sizes it */}<CardHeader title={...} muted={...}> {/* the title row; muted is the quiet clause after the title */}{/* one trailing control: CardTabs, CardAction or CardMenu */}</CardHeader><CardContent>...</CardContent> {/* padded body; full-bleed content skips it */}</Card>
The plain form is a header and a padded body. title names the card. muted is the quiet clause after it, in fg-2, that scopes the title: a count, a period, a facility. Type one in the panel. The header renders through Heading level 2. CardContent pads with p-4 pt-0. It assumes a header above it.
<Card> <CardHeader title="Verified accuracy" muted="across 367 records" /> <CardContent> <div className="flex items-center gap-3"> <RadialStat value={78} postfix="%" thresholds={[60, 90]} thickness={7} /> <div className="flex flex-col gap-1"> <Heading level={2}>Out of sync</Heading> <Text variant="secondary">Over a quarter of records don't match the last audit.</Text> </div> </div> </CardContent> </Card>
Dynamic card title
title and muted are ReactNodes, not strings. The page can recompute them from state, and the header becomes a readout for the body. Hover the bars: the title follows the data, and returns to the total when you leave. The chart is full-bleed, so it skips CardContent and sits directly in the card. The header still holds its one control, a CardAction.
function DataIssuesCard() { const [hovered, setHovered] = useState(null); return ( <Card className="w-100"> <CardHeader title={<>{hovered ? hovered.count : 56} data issues</>} muted={hovered ? "on Aug " + (hovered.day + 1) : "in the last 30 days"} > <CardAction onClick={() => {}}>View all</CardAction> </CardHeader> <div className="h-20 px-3 pb-2"> <BarTicker data={[0, 2, 1, 0, 0, 3, 1, 0, 4, 2, 0, 0, 1, 5, 2, 1, 0, 0, 2, 12, 7, 3, 1, 0, 2, 1, 0, 3, 1, 2].map( (count, day) => ({ count, day }), )} valueAccessor={(bucket) => bucket.count} keyAccessor={(bucket) => String(bucket.day)} onHover={(bucket) => setHovered(bucket)} ariaLabel="Data issues found per day over the last 30 days" /> </div> </Card> ); } render(<DataIssuesCard />);
Card tabs
CardTabs is the tab control of the header, with no panels on purpose: the page owns the state and swaps the card body itself. Use Tabs when the tabs and their panels belong together.
RankedTable composes the full anatomy internally: a CardHeader with the title, CardTabs as its trailing control, and the ranked rows as the body. Switch the category and the whole body follows. It is designed to sit directly inside Card. The card fixes the height so the rows scroll inside it.
<Card className="flex h-90 w-120 flex-col overflow-hidden"> <RankedTable ariaLabel="Discrepancies by power gap" header="Discrepancies" defaultSort={{ columnKey: "gap", direction: "desc" }} rowKey={(row) => row.id} categories={[ { key: "facilities", label: "Facilities", rows: [ { id: "tor5", name: "TOR5", records: 367, gap: 134.3 }, { id: "atl2", name: "ATL2", records: 298, gap: 88.1 }, { id: "dfw1", name: "DFW1", records: 512, gap: 61.4 }, { id: "phx4", name: "PHX4", records: 120, gap: 22.9 }, { id: "sea3", name: "SEA3", records: 84, gap: 8.2 }, ], columns: [ { key: "name", header: "Facility", cell: (row) => row.name }, { key: "records", header: "Records", cell: (row) => row.records, sortBy: (row) => row.records, width: "88px", }, { key: "gap", header: "Power gap", cell: (row) => row.gap + " kW", sortBy: (row) => row.gap, width: "96px", }, ], }, { key: "customers", label: "Customers", rows: [ { id: "att", name: "AT&T Global Services", records: 96, gap: 154.6 }, { id: "cisco", name: "Cisco Systems Canada", records: 44, gap: 123.0 }, { id: "cloudflare", name: "Cloudflare, US Inc.", records: 31, gap: 69.2 }, { id: "centrilogic", name: "Centrilogic Inc.", records: 27, gap: 57.1 }, { id: "alea", name: "Alea Software Ltd", records: 19, gap: 54.9 }, ], columns: [ { key: "name", header: "Customer", cell: (row) => row.name }, { key: "records", header: "Records", cell: (row) => row.records, sortBy: (row) => row.records, width: "88px", }, { key: "gap", header: "Power gap", cell: (row) => row.gap + " kW", sortBy: (row) => row.gap, width: "96px", }, ], }, ]} /> </Card>
CardAction and CardMenu
CardAction is one action in the header. The dynamic title card above carries one. Give it href or onClick, never both. A link renders a real link, so new-tab and copy-link work.
CardMenu is a small select for the header, for choices like a time range.
<Card className="w-120"> <CardHeader title="Facility totals"> <CardMenu label="Last 30 days" items={[ { value: "30", label: "Last 30 days" }, { value: "90", label: "Last quarter" }, ]} onSelect={() => {}} /> </CardHeader> <CardContent> <div className="flex gap-12"> <NumberedStat label="Customers" value="31" delta={-2} deltaPostfix="%" /> <NumberedStat label="Records" value="367" delta={-11} deltaPostfix="%" /> <NumberedStat label="Data issues" value="17" delta={-46} deltaPostfix="%" lowerIsBetter /> </div> </CardContent> </Card>
All three controls render through one internal chip, so tabs, actions, and menu triggers align by construction.
Layout
Cards do not size themselves. The page grid does: the overview places its stat cards with grid lg:grid-cols-2 gap-6, and the data issues card fixes its own height with className="flex h-[520px] flex-col overflow-hidden" so the ranking scrolls inside it. The fixed widths in the demos above belong to the demos.
API
// Card: { children; className? }// CardHeader: { title: React.ReactNode; muted?: React.ReactNode; children?; className? } // muted = quiet clause after the title; children = one trailing control cluster// CardContent: { children; className? } // p-4 pt-0, assumes a header above// CardTabs: { value: string; onChange: (value: string) => void; children }// CardTabs.Tab: { value: string; children }// CardAction: { children; disabled? } & ({ href: string } | { onClick: MouseEventHandler })// CardMenu: { label; items: { value: string; label: React.ReactNode }[]; onSelect; disabled? }