Component
card
Demo
tokens.css,
base.css and card.css. No scripts.
Source
Copy both files, or run npx nojsui add card.
Browser support
Works across current and earlier versions of every major engine.
| Feature | Baseline | Chrome | Edge | Firefox | Safari | Chrome Android | Firefox Android | Safari iOS |
|---|---|---|---|---|---|---|---|---|
| Container queries | widely | 105 | 105 | 110 | 16 | 105 | 110 | 16 |
| Feature | Baseline | Chrome | Edge | Firefox | Safari | Chrome Android | Firefox Android | Safari iOS |
|---|---|---|---|---|---|---|---|---|
| Container style queries | newly | 111 | 111 | 151 | 18 | 111 | 151 | 18 |
Usage
A card that lays itself out from the space it is given, not from the size of the window. The same markup is a stacked card in a narrow column and a side-by-side one in a wide column — on the same page, at the same time.
<article class="sk-card">
<div class="sk-card__media" aria-hidden="true"></div>
<div class="sk-card__body">
<h3 class="sk-card__title">Container queries</h3>
<p class="sk-card__text">…</p>
</div>
</article>
There are no breakpoints to configure and nothing that has to know where the card was placed. Drop it in a sidebar and it stacks; drop it in a main column and it goes side by side.
How it works
A container query cannot style its own container
This is the constraint that shapes the whole file, and the rule everyone writes first does nothing:
/* Does not work. */
.sk-card { container-type: inline-size; }
@container (inline-size > 28rem) {
.sk-card { grid-template-columns: 11rem 1fr; } /* never applies */
}
A container query styles the container’s descendants, never the container itself — otherwise a rule could change the size it was queried on. The usual workaround is an extra wrapper element so the card can respond to its width.
This component avoids the wrapper by letting the children carry the layout.
The card is a flex-wrap: wrap row that never changes. Each child owns its own
flex basis, and the query changes those:
.sk-card__media,
.sk-card__body { flex: 1 1 100%; } /* stacked: both full width, so they wrap */
@container (inline-size > 28rem) {
.sk-card__media { flex: 0 0 11rem; } /* side by side */
.sk-card__body { flex: 1 1 14rem; }
}
One element, no wrapper, and the card is its own container.
The threshold is not a theme knob
A @container condition cannot read a custom property, so 28rem is
inlined. Exposing --sk-card-side-by-side-at and then hardcoding the value in
the query anyway would put a property in the Theming table that changes
nothing, which is worse than not offering it. To move the threshold, override
the rule.
Density
--sk-density is read here through a container style query:
@container style(--sk-density: compact) { … }
Set it on any ancestor and everything inside tightens up:
.settings-panel { --sk-density: compact; }
It is deliberately not a --sk-card-* knob and is not in the Theming table
below. It is a page-level property that belongs to no single component — the
point is that one declaration on a section reaches every component in it, not
just cards. ADR 0019 records the convention.
Two things make this work without any setup: every element is a style container
by default, so nothing needs container-type or a container name; and custom
properties inherit, so setting --sk-density high up reaches cards nested
anywhere below it.
Keyboard contract
None. A card is a container for content, not a control. Anything focusable you put inside keeps its own behaviour and tab order.
| Key | Behaviour |
|---|---|
Tab | Passes through to whatever you put in the card |
| Any other key | No effect on the card |
Verified manually in VoiceOver and NVDA: yes — the card is announced as an
article with its heading; the media element is aria-hidden and skipped.
Accessibility notes
The card is an <article>, which is a landmark-adjacent role that assistive
technology can navigate by. Give it a heading — the __title — so it is
announced as something rather than as an empty region.
.sk-card__media is decorative and empty, so it carries aria-hidden="true".
If you put a real image in it, remove that and give the <img> proper alt
text instead; leaving aria-hidden on a wrapper that now contains meaningful
content would hide it.
The layout switch changes nothing about the reading order. The media comes
before the body in source order in both layouts, so what a screen reader
announces is identical either way. That is a property worth preserving if you
reorder anything: order on a flex child changes the visual sequence without
changing the announced one, which is how this pattern usually goes wrong.
Degradation
| Feature used | Baseline status | Behavior without it |
|---|---|---|
| Container queries | widely (Chrome 105+, Firefox 110+, Safari 16+) | The card is always the stacked layout. Complete and usable — it simply stops adapting. This is the floor. |
| Container style queries | low (Chrome 111+, Firefox 151+, Safari 18+) | --sk-density: compact has no effect and cards stay comfortable. Nothing breaks. |
Theming
| Property | Default | Controls |
|---|---|---|
--sk-card-bg | --sk-color-surface | Card background |
--sk-card-fg | --sk-color-text | Title and body text |
--sk-card-meta-fg | --sk-color-text-subtle | The metadata line |
--sk-card-border-color | --sk-color-border | Card border |
--sk-card-radius | --sk-radius-lg | Corner radius |
--sk-card-padding | --sk-space-md | Padding inside the body |
--sk-card-media-bg | --sk-color-accent-subtle | The media placeholder’s fill |
--sk-card-media-column | 11rem | Width of the media column in the side-by-side layout |
--sk-density is not in this table — see Density for why.