Pattern gallery

Split cards

Related facts stay grouped in one frame instead of splitting into separate cards.

What this solves

Gestalt hierarchy: panes or rows that share one card read as one comparison, not as unrelated numbers the reader has to mentally join. A hairline or a gap does the grouping work that a full card border would otherwise force onto separate cards.
Use it when
  • Two related breakdowns of the same metric, side by sidewalmart-mvp Admin.tsx: the two-pane usage grid, each pane its own section label and table
  • A repeating list where every other row needs a visual seamwalmart-mvp Home.tsx: the fine-grain catalog snapshot, odd cells take sm:border-l
  • Three or more facets of one dataset, with a card-level actionthe header-action variant below, an Export CSV action riding the card toolbar

Rendered

Fixture data; check both themes.
Two panes, coarse split plus fine-grain hairline

External usage

By organization
  • Acme Distribution128
  • Karlin Auto61
  • Northside Parts17
Days with usage
  • Aug 1142
  • Aug 1238
  • Aug 1351

Catalog snapshot

Titles written9,204
Errors auto-resolved312
Errors manually resolved88
Rows ingested40,119
Three panes, with a card-level toolbar action

External usage

Last 7 days, three ways
By organization
  • Acme Distribution128
  • Karlin Auto61
  • Northside Parts17
By day
  • Aug 1142
  • Aug 1238
  • Aug 1351
By module
  • Part Type84
  • Scrape55
  • Normalize40
  • Content27
patterns/split-cards/example.tsxCopy this into a fresh route and it renders as above.
import { Card, RenderIcon } from "@versable-git/ui";
// Two grains of one idea: a card split into two independently-headed panes,
// and a card whose repeating rows take a hairline divider between columns.
const orgs = [
{ name: "Acme Distribution", runs: 128 },
{ name: "Karlin Auto", runs: 61 },
{ name: "Northside Parts", runs: 17 },
];
const days = [
{ day: "Aug 11", runs: 42 },
{ day: "Aug 12", runs: 38 },
{ day: "Aug 13", runs: 51 },
];
const snapshot = [
{ Icon: "Success", label: "Titles written", value: "9,204" },
{ Icon: "Refresh", label: "Errors auto-resolved", value: "312" },
{ Icon: "Edit", label: "Errors manually resolved", value: "88" },
{ Icon: "Database", label: "Rows ingested", value: "40,119" },
] as const;
export function SplitCards() {
return (
<div className="flex flex-col gap-6">
<Card title="External usage" noAnimate>
<div className="grid gap-8 md:grid-cols-[minmax(0,1.2fr)_minmax(200px,0.8fr)]">
<div>
<div className="text-base-content/45 mb-2 text-xs font-medium tracking-wide uppercase">By organization</div>
<ul className="flex flex-col gap-1">
{orgs.map((o) => (
<li key={o.name} className="flex justify-between text-sm">
<span>{o.name}</span>
<span className="text-base-content/65 tabular-nums">{o.runs}</span>
</li>
))}
</ul>
</div>
<div>
<div className="text-base-content/45 mb-2 text-xs font-medium tracking-wide uppercase">Days with usage</div>
<ul className="flex flex-col gap-1">
{days.map((d) => (
<li key={d.day} className="flex justify-between text-sm">
<span>{d.day}</span>
<span className="text-base-content/65 tabular-nums">{d.runs}</span>
</li>
))}
</ul>
</div>
</div>
</Card>
<Card title="Catalog snapshot" noAnimate>
<div className="grid grid-cols-1 sm:grid-cols-2">
{snapshot.map((s, i) => (
<div
key={s.label}
className={`border-base-300/70 flex items-center gap-3 border-t px-4 py-3 first:border-t-0 sm:nth-[-n+2]:border-t-0 ${i % 2 === 1 ? "sm:border-l" : ""}`}
>
<span className="bg-base-200 text-base-content/50 grid size-7 shrink-0 place-items-center rounded-md">
<RenderIcon Icon={s.Icon} size={15} />
</span>
<span className="text-base-content/75 flex-1 text-sm">{s.label}</span>
<span className="text-sm font-medium tabular-nums">{s.value}</span>
</div>
))}
</div>
</Card>
</div>
);
}

Where it ships

  • walmart-mvp/frontend/src/pages/Admin.tsx:674-730the two-pane grid: gap-based split, each pane with its own section label and table
  • walmart-mvp/frontend/src/pages/Home.tsx:95-113the fine grain: repeating rows in a 2-column grid where odd cells take sm:border-l

App-specific: The coarse split is gap-based rather than a hairline, and pane widths pin the secondary pane with minmax. Real cards feed both panes from one loader payload; the fine-grain rows swap the value for a Skeleton while loading.

@versable-git/ui · composites proven in the apps