Card
A border, a radius, a background and a column with a gap. That is the entire component — the content, the headings and the actions inside it are yours, and the one prop it takes picks which step of the base scale it sits on so a card inside a card still reads as two boxes.
- Stable
- React 19
- Zero JS
- One DOM node
Pro plan
Everything in Base, plus reports and API access.
Pro plan
Everything in Base, plus reports and API access.
Code
<Card><h3>Pro plan</h3><p>Everything in Base, plus reports and API access.</p><div style={{ display: 'flex', gap: '0.5rem' }}> <Button size="sm">Subscribe</Button> <Button variant="ghost" size="sm">Compare</Button></div></Card><div class="wayra-card" data-surface="100"> <h3 style="margin: 0; font-size: 1rem">Pro plan</h3> <p style="margin: 0; font-size: 0.875rem">Everything in Base, plus reports and API access.</p> <div style="display: flex; gap: 0.5rem"> <button class="wayra-button" data-variant="filled" data-size="sm">Subscribe</button> <button class="wayra-button" data-variant="ghost" data-size="sm">Compare</button> </div></div>Children go straight in. There are no slot subcomponents — no Card.Body, no Card.Title,
no Card.Actions — so the heading level, the order of the parts and the markup they use
are all yours to decide. A card that shipped its own title would have to guess whether that
title is an h2 on this page and an h3 on the next one, and it would guess wrong half
the time.
What the card actually imposes
Section titled “What the card actually imposes”Eight declarations, and they are the whole opinion:
.wayra-card { display: flex; flex-direction: column; gap: calc(var(--wayra-size-field) * 2); padding: calc(var(--wayra-size-field) * 5); border: var(--wayra-border) solid var(--wayra-color-base-300); border-radius: var(--wayra-radius-box); background: var(--wayra-color-base-100); color: var(--wayra-color-base-content);}At the default --wayra-size-field of 0.22rem that is a 17.6px pad and a 7px gap
between children. Both ride the field unit rather than being written in pixels, so a theme
that retunes it — every other shipped theme, selva and terra included, sets 0.25rem — resizes the card
with everything else and needs no override of its own.
The column is the part worth knowing. Children stack, evenly spaced, with no margins
involved — so a row of actions inside a card is a flex row you write, and the gap above
is what separates it from the paragraph over it. Nothing here collapses margins, and
nothing here reaches into your markup to space it.
A card also does not set a font-family. The controls in this library each pin their
own; a card is a surface for the page’s text, so it inherits whatever the page is set in.
Surfaces
Section titled “Surfaces”Code
<Card surface="100">Surface 100</Card><Card surface="200">Surface 200</Card><Card surface="300">Surface 300</Card><div class="wayra-card" data-surface="100" style="inline-size: 9rem"> Surface 100</div><div class="wayra-card" data-surface="200" style="inline-size: 9rem"> Surface 200</div><div class="wayra-card" data-surface="300" style="inline-size: 9rem"> Surface 300</div>surface picks which of the three base tokens paints the background, and reaches the DOM
as data-surface. 100 is the resting surface — the same colour the page itself is on —
so a card at 100 reads as a bordered region rather than a raised one. 200 and 300
step away from it.
Away from it, not “darker”. The base scale inverts between the schemes: base-100 is the
lightest colour in light and the darkest in dark, and every step after it moves toward the
content colour. That is why the prop is a step number and not a lightness — one card at
200 is correct in both schemes and in all seven themes, which no hand-picked grey would
be.
Nesting
Section titled “Nesting”Code
<Card surface="100">Outer<Card surface="200"> Inner <Card surface="300">Innermost</Card></Card></Card><div class="wayra-card" data-surface="100"> Outer<div class="wayra-card" data-surface="200"> Inner<div class="wayra-card" data-surface="300"> Innermost</div></div></div>This is the reason the scale exists at all. Two cards on the same step share one background, and the inner one dissolves into the outer one with only its hairline left to prove it is there. Stepping the child up one keeps both boxes readable, and three steps is as deep as the scale goes — a fourth level of nesting is a sign the layout wants a different structure, not a fourth colour.
At 300 the base-300 hairline is the fill’s own colour, so the edge is only the step
between the card and what it sits on: fine on 100 or 200, gone on a 300 parent — keep
a 300 card off base-300, or give that instance a border-color of your own.
Depth is a border, not a shadow
Section titled “Depth is a border, not a shadow”There is no box-shadow anywhere in card.css, and no gradient, no blur and no
translucency either. A card is an opaque rectangle separated from the page by a base-300
hairline and a --wayra-radius-box corner, and that is the complete drawing.
Nothing in the library is raised any more: floating surfaces went flat on 2026-08-29 and the last in-flow ones on 2026-09-24, and a panel is now told apart from the page by its border, or by the scrim under a Modal. So a card is not missing a treatment the overlays have — there is none to be missing. If you want one lifted, the honest way is a shadow of your own on the instance:
<Card style={{ boxShadow: '0 1px 2px rgb(0 0 0 / 8%)' }}>…</Card>Styling contract
Section titled “Styling contract”The class plus data-surface is the public API, and the stylesheet is written against it.
Both boxes below paint identically, and the second one has no React anywhere near it:
<div class="wayra-card" data-surface="200">…</div>
<section class="wayra-card" data-surface="200">…</section>data-surface is optional in hand-written markup: the base rule already paints base-100,
so a bare <div class="wayra-card"> is a resting card. The attribute only matters for the
two steps that override it.
The element is not part of the contract. The stylesheet keys on the class alone, so
<section>, <article> and <li> all take the box — which is the way to give a card real
semantics, since the component itself always renders a <div> and takes no as or
asChild prop.
API reference
Section titled “API reference”CardProps extends ComponentProps<"div">, so id, style, ref, className, role,
aria-* and every data-* land on the box.
| Prop | Type | Default | Description |
|---|---|---|---|
surface |
"100" | "200" | "300" |
"100" |
Which base step the card sits on. Emitted as data-surface. |
children |
ReactNode |
— | The content. Stacked in a column with the card’s gap. |
unstyled |
boolean |
false |
Drops the wayra-card class, keeps data-surface. |
That is the whole surface. There is no color: a card carries no semantic role, so it is
absent from styles/color.css and a data-color on one does nothing. There is no size
and no shape either — a card fills the container it is in and has one step, the same call
Alert makes.
Accessibility
Section titled “Accessibility”- A card is a box, not a landmark. It renders a plain
<div>with noroleand noaria-*of its own, and the test suite asserts that it stays that way. A landmark that appears six times on a page is noise in the landmark list rather than navigation. - Semantics are opt-in and yours. Pass
role="region"with anaria-labelledbypointing at the heading inside it when a card genuinely is a named region — or, better, write the markup by hand as a<section>carrying the class. - Heading structure belongs to the consumer. The card renders no heading, so the outline of the page is exactly the outline you wrote. A grid of cards continues the document’s levels; it does not restart them.
- Nothing here is focusable. A card takes no tab stop and adds nothing to the tab order. A whole card that is clickable needs a real control inside it — the common pattern is a heading whose link stretches over the card with a pseudo-element, which keeps the accessible name on the link instead of on the box.
- The card introduces no colour of its own. It paints
base-contenton whichever base step it sits on, both halves from the theme, so the text contrast inside a card is the page’s contrast and nothing else. The border isbase-300, deliberately faint at 1.27:1 againstbase-100in the default theme — legal, because WCAG 1.4.11 covers boundaries that identify a control, and a card identifies none. The same measurement, and why it was rejected on an Input, is on the Divider page.
Do and don’t
Section titled “Do and don’t”Do
Revenue
48.2K MXN this month.
One heading, at the level the page is already on. The card adds no outline of its own.
Don't
Revenue
48.2K MXN
Two h1s in one box, and one more per card in the grid. The heading list stops being a map of anything.
Do
The inner card steps up to 200, so two boxes read as two boxes.
Don't
Both on the resting surface: one background, and a hairline doing all the work alone.