← All writing
Craft · · 9 min

Style queries

On the container query that needs no container, the inheritance bug it hands you for free, and the times a plain custom property was already doing the job.

CSS

A card component at work has 5 surface variants, and 4 things inside it that need to know which one they’re sitting in: the tag pills, the meta line, the link, and the hairline above the footer.

That’s 20 selectors, every one of them some version of “when the parent is this, the child is that.” They’re spread across 4 files, because the tag rules live in the tag’s file and the link rules live in the link’s file, which is exactly where they belong right up until you need to find all 20 at once. A designer asked for a sixth tint before the holidays and the tint wasn’t the work. The work was going and finding the 4 places that had to hear about it, none of which the person adding a tint has any reason to know exist.

@container style() deletes the whole grid of them. It’s been in all three engines since the back half of 2024, which I know because I looked it up when it landed and then didn’t touch it for a year.

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 any of them are called. There’s one name in the middle that both of them agree on, and the sixth tint is one line on the card and nothing at all anywhere else.

Nothing to declare

A size query needs container-type: inline-size on a parent. That’s a real commitment, because containment means the element stops being sized by its contents and things quietly get wider than you meant.

A style query needs none of that. Every element is already a style container, all the way up to the root. No opt-in, no containment, no layout consequence, nothing to remember to put on a parent, and not a single change to the markup. That took me a minute to believe. Adopting a CSS feature and adding zero elements to the page is not the usual arrangement.

When container queries went cross-browser I wrote that every component you wanted to query needed one more div wrapped around it, and that the div, not enthusiasm, is what sets the pace of adoption. I was about a third of the way through that component library at the time.

It’s asking the parent

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; }

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

The card sets the property, the query goes and looks at the card’s parent, and the parent has never heard of it. You can’t style an element on a value it set on itself, which is the first thing everybody tries because it’s the obvious reading of the syntax.

If the card’s own appearance has to change, that’s still a class on the card. The query is for everything underneath it. Which is a funny little inversion of the 2023 problem: size queries made you add a div around the component, and style queries make you keep the modifier class you already had on it.

Paper has to say it’s paper

Custom properties inherit. That’s the whole reason this works through arbitrary nesting. The tag can be four levels down inside a footer inside a body inside the card, and its parent’s computed --surface is still tinted, because the value came down the tree with everything else.

It’s also the gotcha, and it’ll get you inside an hour.

Put an untinted card on a tinted band. The band sets --surface: tinted for its own children. The card doesn’t set anything because untinted is the default and the default is the absence of a value, right? So the card inherits tinted, and every tag inside it comes out outlined when it should have been filled. No error, nothing looks wrong anywhere else on the page, and one card is just silently incorrect 😐

The fix is that absence stops counting as a value. Every surface names itself out loud, including the plain one:

surfaces.css
/* every surface names itself, including the default one */
.card { --surface: paper; }
.card--tinted { --surface: tinted; }

.band--tinted { --surface: tinted; }
CSS

The card re-declaring --surface: paper is what stops the band’s value at the card’s edge. I write that rule into a component now before I write anything that queries it, because finding it from the other end is 20 minutes in DevTools reading computed values off a column of elements that all look identical.

One question, and it’s a yes or no

style(--surface: tinted) works. style(color: red) doesn’t, in any browser today. Standard properties are in the spec and nobody has implemented them, so in practice the feature is about your own tokens and nothing else.

And there are no ranges. style(--columns > 3) isn’t a thing you can write. You get equality against a value, which means the tokens worth querying are a small set of names instead of numbers with meaning. I’ve come around to thinking that’s good, actually. It pushes you toward --density: compact over --density: 3, and the first one is legible to the next person who opens the file, which is the same bet the ugly class names were making.

You probably wanted a variable

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 reads a token and you can go home:

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

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

No query, works in every browser going back years, and it’s easier to follow. The argument for custom properties back in 2016 was that a parent could set a value and a child could use it without either of them naming the other, which is the same decoupling I’m out here celebrating. If that’s your situation, the query is a feature doing a job assignment was already doing.

It earns its place when the response isn’t a value. When the tag goes from filled to outlined, which is two declarations moving 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 and --tag-background: transparent and so on. I’ve done it, and what you get is a component whose API is 11 custom properties. Most of them exist only so that two others can be set at the same time.

So the test I’ve landed on is whether the tokens ever move apart. If I’m setting three of them at once and I have never once wanted to set one without the others, that isn’t three tokens. It’s one condition wearing three tokens. It should be a query.

Which is roughly what I said about typing the same color five times last summer, coming at it from the other side. That was a component with too many values in it that should have been one value. This is a component with too many values in it that should have been one condition.

In fairness to the 20 selectors

They were findable, and that counts for a lot. .card--tinted .tag tells you, from inside the tag’s own stylesheet, precisely which parent turns it on, and DevTools hands you the matched rule with the chain sitting right there. A style query tells you a token had a value. Working out who set that token means walking up the tree checking computed values on elements that all look the same. That’s more work than reading a selector.

I think it’s a good trade because what I was losing time to was the permutations and not the debugging. But it is a trade. It’s worth naming instead of pretending the new thing is free. If a component has 2 variants instead of 5, write the 4 selectors and move on.

What I was actually waiting for

The card is one line per variant now. The 4 child files know nothing about the card, and a seventh tint is a token and a name.

The timing has stuck with me. In 2023 I said container queries cost you one div per component, and that the div was what set the pace of adoption, not enthusiasm. I still think that’s true, and three years later I’m still not finished with that sweep. Style queries cost zero divs and changed no markup. I read about them, understood them, and then let them sit for over a year while writing more of the exact selectors they delete.

So the div wasn’t the thing. I had a working answer, and that was actually in the way, since a grid of 20 selectors in 4 files isn’t broken. It’s expensive in the way that never comes due all at once, which is the kind of expensive nobody schedules, right up until a designer asks for a sixth tint and you sit there totaling what it’s going to cost to say yes.

Read similar posts
2 min

light-dark()

Two color values in one declaration, picked by the color scheme, which removes most of the reason a theme needed a second block of custom properties at all.

2 min

scrollbar-gutter

The layout shifts by fifteen pixels when a modal opens, everybody blames the modal, and the actual cause is the scrollbar disappearing along with it.