Skip to content

Component

card

Demo

Live demo, isolated in an iframe with only tokens.css, base.css and card.css. No scripts.

Source

Copy both files, or run npx nojsui add card.

Source for card
<!-- .sk-card — a card that lays itself out from the space it is given, not from
     the size of the window.

     Each card is its own container, so 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. No breakpoints, and nothing that has to know where the card
     was placed.

     The demo puts identical cards in a narrow and a wide column to show it,
     and a third zone sets --sk-density: compact to show the style query. -->
<div class="sk-card__zones">
  <div class="sk-card__zone" data-zone="narrow">
    <p class="sk-card__zone-label">Narrow column</p>

    <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">
          The card asks how much room it has, not how wide the window is.
        </p>
        <p class="sk-card__meta">Layout &middot; 2026</p>
      </div>
    </article>
  </div>

  <div class="sk-card__zone" data-zone="wide">
    <p class="sk-card__zone-label">Wide column — same markup</p>

    <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">
          Past its threshold the media takes a fixed column and the body takes
          the rest. Nothing here knows which zone it is in.
        </p>
        <p class="sk-card__meta">Layout &middot; 2026</p>
      </div>
    </article>
  </div>
</div>

<!-- --sk-density is a consumer-set property, not a component knob: it is read
     by a container style query and applies to everything inside the element
     that sets it. Set it on a section and every card in that section tightens
     up. See the README. -->
<div class="sk-card__zone" data-zone="compact">
  <p class="sk-card__zone-label">Same card, in a section set to compact</p>

  <article class="sk-card">
    <div class="sk-card__media" aria-hidden="true"></div>
    <div class="sk-card__body">
      <h3 class="sk-card__title">Container style queries</h3>
      <p class="sk-card__text">
        Padding and type tighten because an ancestor set a custom property, not
        because this card was told to.
      </p>
      <p class="sk-card__meta">Layout &middot; 2026</p>
    </div>
  </article>
</div>
/* .sk-card — a card that lays itself out from the space it is given.

   THE CONSTRAINT THAT SHAPES THIS FILE: a container query styles the
   container's DESCENDANTS, never the container itself. So the card cannot both
   be the container and change its own `display` or `grid-template-columns` —
   the rule everyone writes first, and it silently does nothing.

   The way out is to let 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. Below the threshold both children are
   100% and wrap into a stack; above it the media takes a fixed column and the
   body takes the rest. One element, no wrapper, and the card is its own
   container.

   Specs:
   - container queries ..... https://drafts.csswg.org/css-contain-3/#container-queries
   - style queries ......... https://drafts.csswg.org/css-contain-3/#style-container
   - container-type ........ https://drafts.csswg.org/css-contain-3/#container-type */

/* Public theme knobs (ADR 0011). Read, never declared.

   --sk-density is deliberately NOT among them: it is a page-level property a
   consumer sets on a section, read here through a style query, and it belongs
   to no single component. See the README and ADR 0019. */
.sk-card {
  --_bg: var(--sk-card-bg, var(--sk-color-surface));
  --_fg: var(--sk-card-fg, var(--sk-color-text));
  --_meta-fg: var(--sk-card-meta-fg, var(--sk-color-text-subtle));
  --_border-color: var(--sk-card-border-color, var(--sk-color-border));
  --_radius: var(--sk-card-radius, var(--sk-radius-lg));
  --_padding: var(--sk-card-padding, var(--sk-space-md));
  --_media-bg: var(--sk-card-media-bg, var(--sk-color-accent-subtle));
  --_media-column: var(--sk-card-media-column, 11rem);

  /* What makes the card ask about its own width. `inline-size` and not `size`:
     querying the block axis would need the card's height to be independent of
     its content, which for a card it never is. */
  container-type: inline-size;
  display: flex;
  flex-wrap: wrap;
  overflow: clip;
  border: var(--sk-border-width) solid var(--_border-color);
  border-radius: var(--_radius);
  background: var(--_bg);
  color: var(--_fg);
}

/* Stacked: the default, and what every engine without container queries gets.
   Both children are full width, so they wrap. */
.sk-card__media {
  flex: 1 1 100%;
  min-block-size: 7rem;
  background: var(--_media-bg);
}

.sk-card__body {
  flex: 1 1 100%;
  display: grid;
  gap: var(--sk-space-2xs);
  align-content: start;
  padding: var(--_padding);
}

/* Side by side, once the card itself is wide enough.

   The threshold is NOT a theme knob, and cannot be one: a @container
   condition cannot read a custom property. Exposing --sk-card-side-by-side-at
   and then hardcoding 28rem in the query anyway would put a property in the
   Theming table that changes nothing, which is worse than not offering it.
   Move the threshold by overriding this rule. */
@container (inline-size > 28rem) {
  .sk-card__media {
    flex: 0 0 var(--_media-column);
    min-block-size: 0;
  }

  .sk-card__body {
    flex: 1 1 14rem;
  }
}

.sk-card__title {
  font-size: var(--sk-text-md);
  letter-spacing: var(--sk-tracking-tight);
}

.sk-card__text {
  margin: 0;
  font-size: var(--sk-text-sm);
}

.sk-card__meta {
  margin: 0;
  color: var(--_meta-fg);
  font-size: var(--sk-text-xs);
}

/* Density, through a container STYLE query. Every element is a style container
   by default, so no container-type is needed and nothing has to be named: a
   consumer sets --sk-density on any ancestor and everything inside tightens.
   Custom properties inherit, so setting it high up reaches cards nested
   anywhere below. ADR 0019. */
@container style(--sk-density: compact) {
  .sk-card__body {
    gap: var(--sk-space-3xs);
    padding: var(--sk-space-xs);
  }

  .sk-card__media {
    min-block-size: 4.5rem;
  }

  .sk-card__text {
    font-size: var(--sk-text-xs);
  }
}

/* ---------------------------------------------------------------------------
   Presentation, not behaviour

   The zones below are the demo's two columns and its compact section. They are
   what proves the card reads its container rather than the viewport — delete
   them when copying .sk-card into your own layout.
--------------------------------------------------------------------------- */

.sk-card__zones {
  display: grid;
  gap: var(--sk-space-lg);
  grid-template-columns: minmax(0, 18rem) minmax(0, 1fr);
  align-items: start;
}

@media (width < 44rem) {
  .sk-card__zones {
    grid-template-columns: minmax(0, 1fr);
  }
}

.sk-card__zone {
  display: grid;
  gap: var(--sk-space-2xs);
}

.sk-card__zone[data-zone="compact"] {
  --sk-density: compact;

  margin-block-start: var(--sk-space-lg);
  max-inline-size: 34rem;
}

.sk-card__zone-label {
  margin: 0;
  color: var(--sk-color-text-subtle);
  font-size: var(--sk-text-2xs);
}

Browser support

Baseline widely available

Works across current and earlier versions of every major engine.

Per-feature support, generated from web-features 3.35.0
FeatureBaselineChromeEdgeFirefoxSafariChrome AndroidFirefox AndroidSafari iOS
Container querieswidely1051051101610511016
Enhancements — the component works without these; they add polish. Their status does not affect the badge above.
FeatureBaselineChromeEdgeFirefoxSafariChrome AndroidFirefox AndroidSafari iOS
Container style queriesnewly1111111511811115118

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.

KeyBehaviour
TabPasses through to whatever you put in the card
Any other keyNo 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 usedBaseline statusBehavior without it
Container querieswidely (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 querieslow (Chrome 111+, Firefox 151+, Safari 18+)--sk-density: compact has no effect and cards stay comfortable. Nothing breaks.

Theming

PropertyDefaultControls
--sk-card-bg--sk-color-surfaceCard background
--sk-card-fg--sk-color-textTitle and body text
--sk-card-meta-fg--sk-color-text-subtleThe metadata line
--sk-card-border-color--sk-color-borderCard border
--sk-card-radius--sk-radius-lgCorner radius
--sk-card-padding--sk-space-mdPadding inside the body
--sk-card-media-bg--sk-color-accent-subtleThe media placeholder’s fill
--sk-card-media-column11remWidth of the media column in the side-by-side layout

--sk-density is not in this table — see Density for why.