Skip to content

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.

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.

Surface 100
Surface 200
Surface 300
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.

Outer
Inner
Innermost
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.

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>

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.

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.

  • A card is a box, not a landmark. It renders a plain <div> with no role and no aria-* 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 an aria-labelledby pointing 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-content on 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 is base-300, deliberately faint at 1.27:1 against base-100 in 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

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

Outer
Inner

The inner card steps up to 200, so two boxes read as two boxes.

Don't

Outer
Inner

Both on the resting surface: one background, and a hairline doing all the work alone.