
How do you handle the look and feel of a component when an element moves between contexts? In the past we relied on modifier classes or passing down framework props. But combining container style queries with the new advanced attr() capabilities gives us interesting ways to handle our CSS architecture: HTML attributes feeding directly into typed CSS variables, and components adapting themselves purely through CSS.
I’ve been playing a lot with container style queries in the past, same goes for the advanced attr() capabilities. I think both of these additions in CSS are great and go together hand in hand. But now that container style queries are baseline newly available and the attr() capabilities feature is getting closer to full adoption, I feel like I really have to start rewiring my brains when starting on a new green field project. The approach to adaptable components can become a lot cleaner and smarter. So, I wanted to write this article, mostly to just get into that mindset myself. I hope you can take something out of this as well.
There are a few ideas that I’ve been playing with. One of them is mostly about grids and the other one is about card densities. I’ll split the ideas in this article.
One thing worth mentioning before we start: the demos below have no fallbacks in them. I stripped every @supports block out on purpose, because I wanted to see what this stuff looks like when you write it the way you’d write it in a few months from now. That does mean at the moment you’ll need Chrome, Edge or Firefox, the baseline status of these features can be found here:
(At the moment there seems to be missing information on the attr() capabilities on webstatus.dev.)
Understanding the syntax: advanced attr() and style queries
Before jumping into the demos, let’s take a look at the syntax for both of these features.
The new powers of attr()
In the past, attr() in CSS was very limited. We could only use it inside the content property of pseudo-elements (like content: attr(data-tooltip)), and it would only ever return an unquoted string.
We couldn’t use it for widths, padding, colors, numbers, etc.
The CSS Values and Units Module Level 5 opens up the capabilities of this feature. The modern syntax looks like this:
attr( <attribute-name> <type-or-unit>? [, <fallback> ]? )
We can now use attr() on any CSS property, specify the data type or unit expected, and provide a fallback value.
In this article I’ll mostly be using the following capabilities:
- Direct unit casting:
attr(data-gap cqi, 2cqi). Notice thatcqihere is the unit type. If your HTML hasdata-gap="2", the browser takes that raw number and attachescqi(container query inline units) to it, resulting in2cqi. If the attribute is missing, it falls back to2cqi. - Numbers:
attr(data-repeat type(<number>), 3). This parses the attribute as an integer<number>, which is required when passing number counting to functions likerepeat(3, 1fr). - Lengths:
attr(data-minSize type(<length>), 280px). This parses a length with its unit included in the attribute (like280px,34ch,20cqi, ….). - Custom identifiers:
attr(data-density type(<custom-ident>), comfortable). I already had some demos with this before, passing custom-idents is a lot of fun. Instead of returning a quoted string"compact",type(<custom-ident>)returns an unquoted CSS keyword / identifier (compact). That is important and will make sense in a bit, because style queries match against identifiers, not strings.
Container style queries
While container size queries (@container (width >= 400px)) query the dimensions of an element, style queries let you query computed CSS declarations on a parent container:
@container style(--density: compact) {
.card {
/* Style children based on the container's custom property */
}
}
There is one important thing to know here: you do not need container-type to query custom properties. Any parent element can act as a style query container automatically. The child simply looks up the DOM tree to find the nearest ancestor declaring that property.
Ok, that’s enough syntax. Let’s build a thing. The idea in this article is that I guide you through both these features and end up with combining them. Not sure how many people still read articles, but that’s the way I hope to make it click for you 😉.
Grids
Let’s start with layout grids, as that’s the easiest way to get a feel for attr().
Personally I’m not the biggest Tailwind fan, but I did work with it a few times before and have gone through utility class hell. So ask yourself: How many utility classes have you written that only exist to set one number? .grid-cols-2, .grid-cols-3, .grid-cols-4… and then a matching set for the gaps.
I always felt like I wanted a sort of middle ground for utility classes that just work in my HTML, combined with custom things, keeping my markup pretty. So here is an idea for “mini grid framework” but not by using classes.
Fixed columns with data-repeat
In this first demo, the number of columns and the gap between them both come from attributes on the grid element:
<div class="grid" data-repeat="3" data-gap="2">
<article class="card">...</article>
<article class="card">...</article>
<article class="card">...</article>
</div>
And the CSS:
@layer demo {
.grid-section {
container-type: inline-size;
}
.grid {
display: grid;
grid-template-columns: repeat(attr(data-repeat type(<number>), 3), 1fr);
gap: attr(data-gap cqi, 2cqi);
}
}
That’s the whole grid definition. attr(data-repeat type(<number>), 3) reads data-repeat="3" as a real number and drops it straight into repeat(). Change the attribute to 4 and you have a four column grid, no extra selector needed.
The gap is the part I like most here. attr(data-gap cqi, 2cqi) takes data-gap="2" and attaches the cqi unit to it, so the spacing scales with the container’s inline size instead of the viewport. The markup stays free of units, and the component keeps ownership of its own unit system. Which feels like a nice separation of concerns. But this is of course not really adjusting to the container’s width. All in time, this is just an intro.
In the demo I wired two sliders to it. The sliders don’t touch a single CSS property, they only rewrite the attributes using JS (you could perfectly just inspect element of the demo and adjust those attributes yourself). There is also a little code preview above the grid so you can watch the HTML change while you drag.
Fluid columns with data-minSize
Fixed columns might be enough for some dashboards, but for cards I usually want the grid to figure it out by itself. So instead of a column count, the HTML says how narrow a card is allowed to get before things should wrap:
<div class="fluid-grid" data-minSize="320px" data-gap="2">
<article class="card">...</article>
<article class="card">...</article>
<article class="card">...</article>
</div>
@layer demo {
.fluid-grid {
display: grid;
grid-template-columns: repeat(
auto-fit,
minmax(min(attr(data-minSize type(<length>), 280px), 100%), 1fr)
);
gap: attr(data-gap cqi, 2cqi);
}
}
Here attr(data-minSize type(<length>), 280px) parses 320px as an actual length and hands it to minmax(). The min(…, 100%) around it is the old guard against overflow on screens narrower than the minimum, I’ve been using that one for years and this little snippet with attr() will be entering my starting CSS files soon enough.
So whoever writes the markup can now decide how wide cards should be before they wrap, without ever opening the stylesheet. Like I said, not meant to create a full adaptable grid system, but mini framework for fast prototyping.
I’ve been thinking on how I could turn this into a bigger framework without it turning into “just Tailwind with data attributes”. It’s a side-project, I might finish it, I might not.
Now that we’re all on board with attr(). Let’s get over to style queries.
Density controller
In a perfect design system world, a component should be context-aware on its own. The parent container defines the environment, and the child component looks up at its ancestor, sees what density is requested, and styles itself.
To try that out I built a set of squad cards themed around superheroes, a small nod to Dispatch, a fun game that I’ve been playing for a few days.

The markup is straightforward, your everyday card component.
<article class="card">
<img class="avatar" src="avatars/irondude.jpg" alt="Irondude" />
<div class="card-content">
<h2 class="card-title">
Irondude
<span class="badge">Powered Armor</span>
</h2>
<p class="card-desc">
Walks into collapsing buildings so nobody else has to.
</p>
</div>
<div class="card-actions">
<button class="btn btn-secondary">Message</button>
<button class="btn">Dispatch</button>
</div>
</article>
And three density modes:
- compact (tight padding, small avatar, description hidden),
- comfortable (the default)
- spacious (room to breathe).
One multiplier
Before writing any queries, let’s talk maths…
The reflex when building size variants is to write out every override by hand. Smaller padding on the card for .card--compact, smaller gap, smaller avatar, smaller title, smaller badge, smaller buttons… and then the whole ritual again for .card--spacious with bigger numbers. You end up maintaining three disconnected sets of hardcoded values for one component, and when you change the base padding six months later, you get to update all of them. If you remember.
It’s not always an easy sell to designers to make fluid choices, by simple maths. However, if I were to design cards now, this is the direction I’d like to think of: one simple multiplier:
.card {
--density-scale: 1;
gap: calc(1.25rem * var(--density-scale));
padding: calc(1.25rem * var(--density-scale)) calc(1.5rem * var(--density-scale));
border-radius: calc(16px * var(--density-scale));
.avatar {
width: calc(52px * var(--density-scale));
}
.card-title {
font-size: calc(1.15rem * var(--density-scale));
}
.badge {
font-size: calc(0.72rem * var(--density-scale));
}
.btn {
padding: calc(0.55rem * var(--density-scale)) calc(1.1rem * var(--density-scale));
font-size: calc(0.85rem * var(--density-scale));
}
}
Everything that cares about density references --density-scale. Change that one number and the padding, radius, avatar, badge and buttons all move together, so the card keeps its proportions instead of looking squished.
If compact ends up feeling a bit too tiny, you tweak one value instead of twenty. It takes a little while to get right, but you start with the medium sized one (comfortable) and adjust accordingly.
Starting simple: radio buttons and :has()
For the first density demo I kept it dead simple. Three radio buttons, and :has() on the body to set --density on the container:
@layer demo {
.card-container {
--density: comfortable;
}
body:has(#compact:checked) .card-container {
--density: compact;
}
body:has(#spacious:checked) .card-container {
--density: spacious;
}
@container style(--density: compact) {
.card {
--density-scale: 0.72;
}
.card-desc {
display: none;
}
}
@container style(--density: spacious) {
.card {
--density-scale: 1.3;
}
}
}
The cards themselves don’t get a single extra class. They ask their ancestor what density it wants and rescale.
This was a good first draft, but truth be told, this demo doesn’t prove much yet. You could get a similar result with .card-container.compact .card and a descendant selector. It’s a warm-up.
Note: don’t overly use :has() on the root as it can cause performance issues, but for demos sake…
The fun stuff: moving cards around and combining with attr()
Style queries get interesting when elements actually move. So the second demo has three drop zones, and each one declares its own density in the HTML. In reality those dropzones might not be a drag and drop but regions of your application, but it should give you an idea:
<main class="containers">
<section class="card-container" data-density="compact"></section>
<section class="card-container" data-density="comfortable"></section>
<section class="card-container" data-density="spacious"></section>
</main>
This is where type(<custom-ident>) comes back:
@layer demo {
.card-container {
--density: attr(data-density type(<custom-ident>), comfortable);
}
}
No .card-container.compact, no .card-container.spacious. Add a fourth density tomorrow and this line doesn’t change, it is flexible from the start.
The identifier lands in --density, the style queries match, done. (If attr() still only gave us strings, @container style(--density: compact) would never match, since a style query compares identifiers and "compact" is not the same as compact.)
Then the cards get draggable="true" and the usual native drag and drop listeners. Here’s the drop handler in full:
document.addEventListener('drop', (e) => {
const container = e.target.closest('.card-container');
if (!container || !draggedCard) return;
e.preventDefault();
container.classList.remove('drag-over');
if (!container.contains(draggedCard)) {
container.appendChild(draggedCard);
}
});
No classList.add('card--compact'), no setAttribute('data-density', …) on the card, no re-render. The only thing that JavaScript does with styling here is add and remove a drag-over class on the drop zone, purely as eye candy.
The moment the card lands in its new parent, the browser re-evaluates the container tree, the card matches a different style query, and it rescales itself. JavaScript moves DOM nodes, CSS handles the looks. Once again, separation of concerns. First HTML, then CSS then JS.
Putting both ideas in one component
For the last demo I wanted to see how far this goes when you combine everything, think of it as a warm-up for a mini grid framework. One grid where the parent declares its entire configuration in HTML: the minimum column width, the fluid gap, whether the first article should become a hero card, and which accent colour the cards should use.
<div
class="fluid-grid"
data-minSize="280px"
data-gap="2"
data-featured="true"
data-accent="terracotta"
>
<article class="card">
<img src="cabin.jpg" alt="Forest cabin" />
<div class="card-content">
<span class="tag">Habitats</span>
<h3>Forest Micro-Cabins</h3>
</div>
</article>
<!-- more cards… -->
</div>
Two of those attributes go straight into layout values, the other two become identifiers that the children can query:
@layer demo {
.fluid-grid {
--featured: attr(data-featured type(<custom-ident>), false);
--accent: attr(data-accent type(<custom-ident>), terracotta);
display: grid;
grid-template-columns: repeat(
auto-fit,
minmax(min(attr(data-minSize type(<length>), 280px), 100%), 1fr)
);
gap: attr(data-gap cqi, 2cqi);
}
/* layout variation */
@container style(--featured: true) {
.card:first-child {
grid-column: span 2;
grid-row: span 2;
}
}
/* accent distribution */
@container style(--accent: terracotta) {
.card {
--accent-color: oklch(52% 0.16 35);
}
}
@container style(--accent: sage) {
.card {
--accent-color: oklch(48% 0.11 150);
}
}
@container style(--accent: indigo) {
.card {
--accent-color: oklch(48% 0.16 265);
}
}
}
The card then just picks up whatever accent it was handed:
.card {
--card-accent: var(--accent-color, oklch(52% 0.16 35));
border-top: 3px solid var(--card-accent);
.tag {
color: var(--card-accent);
}
}
So a template, a CMS or an editor sets four attributes, attr() turns them into typed values and identifiers, and the style queries pass that state down to the children. Flipping “feature the first post” in a CMS becomes a data-featured="true" in the output, and a card two levels down decides to become a 2x2 hero. Nothing in JavaScript needs to know that a hero card even exists.
A final note on support
Container style queries for custom properties are still fresh off the shelf this year, but have full browser support. So for the density pattern, the drag and drop demo, all of it, you’re good.
Advanced typed attr() is the newer half. The type() part, which is what all of these demos lean on, shipped in Chrome and Edge 133 back in February 2025, and Firefox picked it up in 155 at the end of last month. Safari has it in Technology Preview but not in a stable release yet. So, I’m thinking soon-ish?
There is a fallback pattern for this. It removes the “DRY” part from the code, but not that hard, you keep a plain custom property as the base and upgrade it behind @supports:
.fluid-grid {
/* base values every browser understands */
--featured: false;
--accent: terracotta;
&[data-featured="true"] { --featured: true; }
&[data-accent="sage"] { --accent: sage; }
&[data-accent="indigo"] { --accent: indigo; }
/* upgrade for browsers with typed attr() */
@supports (x: attr(x type(*))) {
--featured: attr(data-featured type(<custom-ident>), false);
--accent: attr(data-accent type(<custom-ident>), terracotta);
}
}
I’m not sure I’ll be using it this way. I kinda just want to use the feature as is.
Conclusion
What I really love about this is how well everything snaps together. Both of these features are useful enough on their own, but sitting next to each other they turn into some sort of pipeline, where the markup declares what it wants and the components further down figure out the rest by themselves. As I mentioned, these features do make me wonder about future architectures, thinking how I can get the best flexible/adaptable system out there.
For example: I don’t mind being honest telling you that it took me a while before container size queries became my go-to by default. You need to practice these things, shift your mindset. Sure you can learn syntax easily, but making it a default choice takes time and repetition.
I don’t have a green field project to try this on just yet, and it might even be a bit too soon. But when I do, I’m sure I’ll be force-reaching for this instead of another set of modifier classes. And you know what? That system is going to be pretty darn cool.

