Component Variants With Style Queries: One Token, Many Looks

Design systems describe components in variants: a callout in info, success, warning and danger tones; a button in primary, secondary and ghost emphasis; a list in comfortable and compact density. The usual implementation is a modifier class per variant on each component instance. That works until the variant should come from context — every callout in an error summary should be danger, every list in a sidebar compact — and the template must now thread the modifier into every child. Style queries let a variant be a custom property that any ancestor can set and every descendant component can read. This page builds a variant system on that idea, with defaults, validation and CMS-friendly tokens. It belongs to Style Queries & Container State in the Mastering Container Queries & Responsive Layouts guide.

Why variants belong in custom properties

A class is local: it styles the element it is on. A custom property is inherited: set on a section, it is visible to everything inside. That difference is exactly what context-driven variants need. An error summary sets --tone: danger once; every callout, icon and link inside picks it up. A sidebar sets --density: compact; every list and card inside tightens.

Before style queries, inherited properties could only feed values — a colour, a size — into declarations. They could not select between whole groups of declarations. Style queries close that gap: @container style(--tone: danger) applies a block of rules when the inherited token has that value, so a variant can change several properties, pseudo-elements and child styles at once.

One ancestor, many variants chosen A section with the declaration --tone: danger contains three callout components. Each callout's container resolves the inherited token and its style query applies the danger variant, with no modifier class on any callout. --tone set once, read everywhere below section.errors { --tone: danger } callout style(--tone: danger) danger variant callout style(--tone: danger) danger variant callout style(--tone: danger) danger variant

The complete implementation

The callout below has four tone variants. Each instance can set its tone directly, or inherit it from any ancestor. A registered property constrains the allowed values and supplies a default.

Live demoCallout tones chosen by an inherited --tone token
Two callouts set their tone directly, a section sets danger for both callouts inside it, and the last one falls back to the registered default, info.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Style query variants</title>
<style>
  /* Constrain the token to known keywords, with a default. Registered
     properties still inherit when inherits is true. */
  @property --tone {
    syntax: "info | success | warning | danger";
    inherits: true;
    initial-value: info;
  }

  body { font: 15px/1.5 system-ui, sans-serif; margin: 1rem; display: grid; gap: 0.75rem; }

  /* The wrapper is the query container; the callout inside reads it.
     A container is needed because an element cannot query itself. */
  .callout-slot { container-name: callout; }

  .callout {
    /* Default look = info, so the component is complete without a query. */
    --accent: #1d4ed8;
    --surface: #eff6ff;
    --icon: "i";
    display: grid;
    grid-template-columns: auto 1fr;
    gap: 0.75rem;
    padding: 0.9rem 1rem;
    border-inline-start: 4px solid var(--accent);
    border-radius: 8px;
    background: var(--surface);
    color: #0f172a;
  }
  .callout::before {
    content: var(--icon);
    display: grid;
    place-items: center;
    inline-size: 1.5rem;
    block-size: 1.5rem;
    border-radius: 50%;
    background: var(--accent);
    color: #ffffff;
    font-weight: 700;
  }

  @container callout style(--tone: success) {
    .callout { --accent: #15803d; --surface: #f0fdf4; --icon: "✓"; }
  }
  @container callout style(--tone: warning) {
    .callout { --accent: #b45309; --surface: #fffbeb; --icon: "!"; }
  }
  @container callout style(--tone: danger) {
    .callout { --accent: #b91c1c; --surface: #fef2f2; --icon: "×"; }
  }
</style>
</head>
<body>
  <!-- Tone set directly on each slot... -->
  <div class="callout-slot" style="--tone: success"><div class="callout">Backup completed.</div></div>
  <div class="callout-slot" style="--tone: warning"><div class="callout">Storage is 90% full.</div></div>

  <!-- ...or once for a whole region. -->
  <section style="--tone: danger" aria-label="Errors">
    <div class="callout-slot"><div class="callout">Payment failed.</div></div>
    <div class="callout-slot"><div class="callout">Address could not be verified.</div></div>
  </section>

  <!-- No tone: the registered initial value, info. -->
  <div class="callout-slot"><div class="callout">Maintenance on Sunday.</div></div>
</body>
</html>

The variant blocks change only custom properties, and the base rule reads them. That keeps each variant tiny — three tokens — and means a new variant never needs to repeat layout declarations. The icon glyph is decorative reinforcement; the tone must also be conveyed in text for screen readers, for example by starting the message with "Error:" or by placing the callout in a region whose accessible name states its purpose.

The key technique: registration makes the vocabulary explicit

Unregistered custom properties accept any value, so a typo such as --tone: dnager silently matches no variant and the callout renders in its default tone. Registering the property with a keyword syntax changes that failure: an invalid value is rejected at computed-value time and the property takes its initial-value, which is a known, designed state.

Typos with and without @property Left, an unregistered --tone with the value dnager matches no style query, leaving an unstyled default. Right, a registered --tone with syntax info or success or warning or danger rejects dnager and uses the initial value info, which has a designed appearance. --tone: dnager unregistered value: "dnager" no style() matches intent silently lost registered keywords invalid → initial-value computed: info designed default shown DevTools shows the computed value, so the typo is visible when debugging.

Registration has one side effect to be aware of: a registered property with inherits: true inherits its computed value. For keyword tokens that is exactly what you want. For tokens holding colours built with light-dark(), registration resolves the colour on the element where it is declared, which breaks nested theme switching — the trap described in Light and Dark Themes With light-dark(). Register the variant keyword, not the colours it selects.

Several variant axes at once

Real components vary along more than one axis: a button has an emphasis (primary, secondary, ghost) and a size (small, medium, large) and sometimes a tone. With classes, the combinations multiply — .btn--primary.btn--small.btn--danger. With tokens, each axis is an independent custom property and each has its own small set of style queries.

@property --emphasis { syntax: "primary | secondary | ghost"; inherits: true; initial-value: secondary; }
@property --size     { syntax: "small | medium | large";       inherits: true; initial-value: medium; }

@container btn style(--emphasis: primary) { .btn { --btn-bg: var(--accent); --btn-fg: #ffffff; } }
@container btn style(--emphasis: ghost)   { .btn { --btn-bg: transparent; --btn-border: transparent; } }
@container btn style(--size: small)       { .btn { --btn-pad: 0.3rem 0.6rem; --btn-font: 0.85rem; } }
@container btn style(--size: large)       { .btn { --btn-pad: 0.8rem 1.4rem; --btn-font: 1.1rem; } }

Because each query sets only the tokens for its own axis, the axes compose without any rule for their combinations: a small ghost button and a large primary button both fall out of the same six rules. Adding a fourth size or a new emphasis is one more rule, not a new row in a combinatorial table.

The discipline that makes this work is that variant rules set tokens, never layout properties directly. The component's base rule reads the tokens once. If two axes ever need to affect the same token — a large primary button with a heavier shadow than either axis alone would give — express that with a combined condition, style(--emphasis: primary) and style(--size: large), rather than by letting two rules fight over the same property.

Variants from a CMS

Content editors choose variants in a CMS, and the natural output is an inline custom property: style="--tone: warning" on a block. That is safer than a free-form class name, because a registered keyword token cannot inject arbitrary styling, and it works for every component inside the block without the template knowing which components those are.

Three practices keep CMS-driven variants robust:

  1. Offer only the registered keywords in the editor's dropdown, so values match the syntax.
  2. Set tokens on wrappers, not on components, because the style query reads a container ancestor. A block wrapper that is also a container is the natural place.
  3. Document which components respond to which tokens. A --tone set on a region affects callouts, badges and alerts alike; editors should know that before using it.

Accessibility notes

Variants that encode meaning — danger, warning, success — must not rely on colour alone (WCAG 1.4.1). Pair each tone with text or an icon that has an accessible name, and prefer semantic structure such as role="alert" for urgent messages that appear dynamically. Check contrast of text on each variant's surface and of the accent against the page for WCAG 1.4.3 and 1.4.11; the colour derivation techniques in Hover and Active States With color-mix() help keep variant palettes consistent. Forced-colours mode replaces the variant colours with system colours, so the border and icon shapes, not their colours, carry the tone there.

Browser support

Style queries for custom properties are supported in Chrome and Edge 111+, Firefox 151+ and Safari 18+. @property is supported in Chrome and Edge 85+, Firefox 128+ and Safari 16.4+; keyword-list syntaxes such as "info | success" are supported where @property is. In an engine without style queries, every callout shows the default info tone; a class-based fallback can provide variants there, as described in Style Query Fallbacks and Support.

FAQ

How is a style-query variant different from a modifier class? A modifier class must be on the component itself. A style-query variant reads a custom property that can be set on the component or any ancestor, so a section, a theme or a CMS block can choose the variant for every component inside it without touching their markup.

Can a component set its own variant property and query it? Not directly. Style queries evaluate against a container ancestor, so the property must be set on the container or above. Set the variant on a wrapper element or have the component's root be a container that its children query.

How do I prevent invalid variant values? Register the property with @property and a syntax listing the allowed keywords, such as syntax: "info | success | warning | danger". An invalid value then falls back to the registered initial value instead of silently matching nothing.

Do style-query variants cost performance? Not in ordinary use. The browser evaluates the query when the property's computed value changes, which for variants is rare. Avoid toggling variant properties on hover or during animation across large subtrees.

Related articles

More pages in the same section.