What a card is made of
Posted 19 August
Seven divs and a border, until you tell it otherwise.
A bordered surface, and six regions inside it.
src/components/rahti_ui/card.rs
A header, a body, a footer. None of the three is required and none of them is ordered — a card is whatever regions you put in it, separated by the card's own `gap-6`.
Four hundred and twelve sessions, up eleven percent on May.
Three regions, `gap-6` between them, and no margins anywhere. The card pads top and bottom; the regions pad left and right. Nothing pads both, which is what the next-but-one section is about.
The one piece of layout a Card actually performs. The header is a grid of two rows, and it grows a second column when — and only when — something inside it carries `data-slot="card-action"`.
The header is one column until it contains a CardAction. Then it is two.
No description, and the action still spans both rows — `row-span-2 row-start-1` does not ask whether the second row is filled.
Two selectors and one attribute between them. The header carries `has-data-[slot=card-action]:grid-cols-[1fr_auto]`, which Tailwind compiles to `:has([data-slot=card-action])`; the action carries `col-start-2 row-span-2 row-start-1 justify-self-end`. Take the `data-slot` off the action and the column never appears — the action's placement utilities then refer to a column that was never created.
`col-start-2` is a grid property, so it only applies to a grid *item*: the action has to be a direct child of the header. That is why every part in this library keeps its `<script>` inside its own element rather than beside it — a fragment would put a `<pp-fragment>` between the action and the header. ButtonGroup had to do the opposite, because it counts children with `:first-child` and a `display: none` script is still a child.
The canonical shadcn example, built from this library's own Field and Input. A card holds the form; it is not one — the `<form>`, if you want one, goes inside the content region or around the whole card.
shadcn's own login card, part for part. The footer is the one override: `flex-col gap-2` over `flex items-center`, which `twMerge` settles in the browser — `flex-col` wins the direction, `items-center` survives because nothing here contradicts it.
shadcn renders seven `<div>`s and leaves the meaning to the application, which is the right default and the wrong outcome if nobody ever changes it. A card title is almost always a heading, and a `<div>` is not one at any level.
Posted 19 August
Seven divs and a border, until you tell it otherwise.
`element` is shadcn's `asChild` without the child, and each part has its own short list of tags it answers to — a Card may be `article`, `section` or `li`; a title may be `h1`–`h4` or `span`. A tag outside the part's list is ignored, and trips a `debug_assert` in development so it is not silently dropped.
`header` and `footer` are only correct inside a sectioning element. In a `<div>` card they would be the *page's* header and footer, which is why the card above is an `<article>` first. The `aria_labelledby` points at the title's id: an article with no accessible name is announced as a region nobody is told the purpose of.
A header can have a rule under it and a footer one above it, and each brings its own padding. Both are self-referential arbitrary variants — nothing about a parent, nothing about a child, everything about the element carrying them.
`[.border-b]:pb-6` compiles to `:is(.border-b)` — it fires on the header itself, and only when the header is given the border.
The rule and its `pb-6` are in the served HTML.
The two cards look the same and are not built the same way. The first uses `class="border-b"` on the tag, and an override turns the class attribute into `{twMerge(…)}` — so the border *and* the `pb-6` it triggers arrive when PulsePoint mounts, not when the HTML does. On a bordered header that is a visible reflow rather than a colour arriving late.
The second is `card_variants`, `card_header_variants` and `card_footer_variants` on native elements: the finished string is joined on the server and the card is styled on arrival, JavaScript or no JavaScript. The `data-slot` attributes are written by hand there, which is the part it is easy to forget — on a CardAction they are load-bearing.
The asymmetry in the class lists is not an oversight. It is the whole mechanism for a table, an image or a list that has to reach the card's edge.
One utility off, and the list runs the full width of the card while the card's own `py-6` is untouched. That only works because the horizontal padding was never on the card: `py-6` on the surface, `px-6` on each region, and nothing anywhere that pads both.
It is also why a bare `<div>` written between two card parts looks broken — it has no padding, because there was never any to inherit. Use a CardContent, or write `px-6` yourself.
There is no required part and no required order. A card with a body and nothing else is a card; so is one that is only a header.
Content only. Still a card.
`items-start` is why the title does not stretch into the empty second row.
`gap-6` and no margins is what makes a region cheap to leave out: there is nothing to collapse and nothing to cancel. The header's two rows are always declared — `grid-rows-[auto_auto]` — and `auto-rows-min` sizes an empty one to nothing.
The case where the card's element matters for more than semantics.
First
Second
Third
`element="li"` so the `<ul>` contains list items and nothing else — a screen reader is then told there are three of them. It is also what keeps `sm:grid-cols-3` honest: each card is a real child of the grid, because a card with no override and no binding renders as one literal element with no wrapper around it.
`class` is `className`: laid over the part's own, and a conflicting utility wins. There is no Rust tailwind-merge, so the resolution happens in the browser and the served class attribute is a `{twMerge(…)}` expression until it does.
Written out, because there is no size prop to hide it.
Every one of those overrides is a `twMerge` call resolved in the browser, so this card carries no utilities at all until PulsePoint mounts. That is the standing cost of `.class(…)` everywhere in this library, and it is worth knowing before reaching for it on a card that is above the fold.
Which shadcn card this is, and the one prop that did not survive the crossing.
shadcn ships the card twice. Its Base UI style writes the design into `cn-card`, `cn-card-header`, `cn-card-title` … marker classes that an external stylesheet defines, so copying it here would copy names with no rules behind them. The registry style spells the same card in utilities, and that is what this file copies — the same choice ButtonGroup and Field made.
One thing is lost with the markers, and it is named in the module docs rather than pretended away: the Base UI card takes `size="default" | "sm"`, a `data-size` attribute the `cn-card` stylesheet reads to tighten every gap and every padding at once. There is no utility spelling of it in the registry style, so there is none here — the section above is what that prop was hiding, written out.
Everything else is shadcn's, class for class. Seven parts, seven `data-slot`s, no variants, no state, and — apart from a `<script>` that exists only so an overridden part can be mounted — no JavaScript.
A Card is a component, and a component is a PulsePoint scope. Three ways across it, and only one of them is the tag.
The action is a direct child of the header, and it carries its own `data-slot` — both by hand, because nothing is doing it for us here.
A `{…}` written between two component tags is compiled in the block it was *written* in, which is the page's — that part works. What does not is a page reaching into a part it did not render: an attribute binding on a `<Card>` tag is compiled in the card's own scope, finds nothing, and stays on the element as literal text.
So a card the page drives is built with `card()`, `card_header()` and the rest, and rendered into the page's own block — that is the section at the bottom of this page. Every `render` in this library carries no `<script>`, which is exactly what keeps the whole card inside one scope.
The third way is the one above: `card_variants` and its six siblings, on elements the page owns outright. Reach for it when the *content* of a part is the page's state — and write the `data-slot` yourself, because on a CardAction it is the selector the header's second column depends on.
Not one tag in here. `<Card>` is a component, and a component is a PulsePoint scope of its own — children handed to it are compiled against *its* scope, which has no `picked`. So a card a page drives is built with `card()` and rendered into the page's own block, and so is every part of it. Every `render` in this library carries no `<script>`, which is what keeps the whole card inside one scope.
Picked: {picked}
The click handler and the `data-state` binding are on the card's own element, which is what a `render` gives you — no fragment between it and the grid, so `sm:grid-cols-2` still sees two items.
`data-state` rather than `data-selected`, and that is not a style preference. The browser runtime rewrites any binding whose attribute name ends in an HTML boolean-attribute word — `selected`, `open`, `disabled`, `checked` — into a boolean attribute, and truncates the name to `data-` when the expression is falsy. The served HTML is correct either way; the damage happens at hydration.