← All writing
Craft · · 6 min

Style queries

Querying a custom property, the rule that will bite you within an hour, and when a plain custom property was already doing the job.

CSS

The card on a client’s site had five surface variants. Paper, and four tints. Inside it, four things that needed to know which variant they were sitting in: the tag pills, the meta line, the link, and a rule above the footer.

20 selectors. All of the form “when the parent is this, the child is that.” Every new tint meant four more, in four different parts of the stylesheet, and the person adding the tint had no way of knowing that from looking at the card.

@container style() deletes that grid, and it’s now in every browser I care about, so I’ve spent a couple of weeks actually using it rather than reading about it.

What it is, in one block

A container query that asks about a custom property instead of a width.

tag.css
.tag {
  background-color: var(--tag-fill);
  border: 0;
}

@container style(--surface: tinted) {
  .tag {
    background-color: transparent;
    border: 1px solid currentColor;
  }
}
CSS

The card sets --surface: tinted on itself, and stops there. It doesn’t know a tag exists. The tag doesn’t know the card exists, or how many variants there are, or what they’re called. There’s one token in the middle that both of them agree on, and adding a sixth tint is now one line on the card and nothing at all anywhere else.

Three things about it caught me out, and they’re all in the first hour.

You don’t declare a container

Size queries need container-type: inline-size on the parent, and that has real consequences, because it establishes containment and the element stops being sized by its contents.

Style queries need nothing. Every element is already a style container. There is no opt-in, no containment, no layout side effect, and nothing to remember to put on the parent. That took me an embarrassingly long time to believe, because it means the feature costs nothing to adopt, not the usual arrangement with CSS.

It queries the parent, not itself

This is the one that will get you, and it got me inside about ten minutes.

A style query is evaluated against the nearest ancestor container, and since every element is a container, that’s the parent. Not the element the rule matches.

So this does nothing at all:

.card { --surface: tinted; }

@container style(--surface: tinted) {
  .card { border-color: var(--edge-strong); }  /* never applies */
}
CSS

The card sets the property and the query looks at the card’s parent, which knows nothing about it. You cannot style an element based on a value it set on itself, which is exactly the thing everybody tries first because it’s the obvious reading of the syntax.

Custom properties only, and equality only

style(--surface: tinted) works. style(color: red) does not, in anything shipping today. Standard properties are in the spec and nobody has implemented them, so treat the feature as being about your own tokens.

And there are no ranges. style(--columns > 3) isn’t a thing. You get equality against a value, which means the tokens you query need to be a small set of names rather than numbers with meaning. In practice that’s fine and arguably good, because it pushes you toward --density: compact instead of --density: 3, and the first one is legible to whoever reads it next.

One sharp edge in there: the comparison is on the computed value, and an inherited value counts. If you set --surface: tinted at the root by accident, every element on the page matches, and there’s no obvious symptom other than everything being wrong.

The question I’d actually ask first

Here’s the part I want to be honest about, because two weeks of using this has left me more careful about it, not less.

Most of the time you don’t need a style query, because a custom property was already the mechanism. If the child’s response to a variant is a value, the child should just read a token and you can go home:

.card { --tag-fill: var(--wash-paper); }
.card--sage { --tag-fill: var(--wash-sage); }

.tag { background-color: var(--tag-fill); }
CSS

No query, works in every browser going back years, and it’s easier to follow. If that’s your situation and you reach for @container style(), you’ve added a feature to do a job that assignment was doing.

The query earns its place when the response isn’t a value. When the tag changes from filled to outlined, which is two declarations changing in opposite directions. When something gains a pseudo-element it didn’t have. When a layout goes from a row to a stack. You can technically drive all of that through tokens (--tag-border-width: 1px, --tag-background: transparent, and so on), and I have, and what you end up with is a component with 11 custom properties in its API, nine of which exist only so that two of them can be set together.

So the test I’ve settled on: if I’m setting three tokens at once and they’re never meaningfully set apart, that’s not three tokens. It’s one condition wearing three tokens, and it should be a style query.

What it doesn’t fix

Being fair to the 20 selectors I started with.

They were greppable. .card--sage .tag tells you, from the tag’s stylesheet, exactly which parent triggers it, and dev tools show you the whole chain. A style query tells you a token had a value, and finding out who set that token means walking up the tree in the inspector checking computed values, which is genuinely more work.

I think it’s a good trade, because the thing I was actually losing time to was the combinatorics rather than the debugging. But it is a trade, and if your component has two variants rather than five I’d probably just write the four selectors and get on with something else.

Logic moving to the right layer

I’ve been doing a version of this in my templates for years, and that’s what made it click. The card include on this site works out what tone its tags should be and passes it down, in Liquid, at build time, because the CSS couldn’t express it.

Now the CSS can express it, and the template goes back to only knowing about content. Which is a small thing, and it’s the same small thing custom properties gave us in 2016 and container queries gave us in 2023: one more piece of conditional logic moving out of the layer that generates the markup and into the layer that’s supposed to own it.

Read similar posts
6 min

currentColor

I opened a button component with 11 color declarations in it, across four variants, and the correct number was one per variant, because six of those properties already default to the thing they were being set to.

6 min

text-wrap: balance

A card grid where, at exactly one breakpoint, one heading broke with a single word on the second line, and the fix used to be a non-breaking space typed into the content by a person who'd have to remember forever.