A big thing you need to contend with when working on design systems is anticipating all the different ways someone may use a component. The idea here is that you then preemptively provide sensible behavior and safeguards to keep things working well.
An example of this is a “Simple List” component I was helping to make. It’s a workhorse component, and not without its challenges.
Each of the Simple List component’s list items features slots for a title, a short description, a price, and a few actions. The title can also have a leading photo and trailing badges placed before and after the title, respectively:
The grid layout for this is relatively straightforward, especially if you use named grid areas:
- Code language
- css
grid-template-areas: 'leading-visual title title price' 'leading-visual badges badges price' 'leading-visual description description actions';
The area where we’re focusing our attention on for this post is the slot for trailing badges.
A short title and a single badge make for a clean-looking lockup:
However, this is the real world, and the real world is messy. What do you do if the component consumer has a long title with a lot of badges?
Yech. That doesn’t look as nice.
One thing we could do to avoid this outcome is conditionally place the badges next to the title only if three or fewer badges are used.
Previously, you might have had to rely on some gnarly JavaScript to detect, and then adjust this. Fortunately, contemporary CSS lets us avoid it.
The right tool for the jobpermalink
Doing this sort of work in CSS is the right move!
CSS exists to control the visuals of the web, and this potential problem is a visual concern.
CSS is also more fault-tolerant than JavaScript. This means the entire experience won’t be ruined if something conspires to affect your website or web app loading properly.
Content will be shown if CSS experiences an issue being applied. While it won’t look the way you want it to, it will still be readable by the people requesting it. And that’s the important bit.
Quantity queriespermalink
Heydon Pickering developed the quantity query technique all the way back in 2015.
Quantity queries are a way to conditionally style something depending on how many items of the same type are present at the same DOM level.
This technique is a fiendishly clever idea, and a great example of realizing CSS’ full potential.
We can use quantity queries to detect the number of badges present in the DOM on a per-component instance basis:
- Code language
- css
/* Apply styling instructions to the badges if there are 3 or fewer badges present */ .simple-list-item-badge:last-child:nth-child(-n + 3) { … }
:has() then allows us to conditionally adjust the parent container’s grid layout, instead of just the badges themselves. That means moving the badges up to be placed alongside the title on the top row for this scenario:
- Code language
- css
/* Apply styling instructions to the parent list item container, provided three or fewer badges are present */ .simple-list-item:has( .simple-list-item-badge:last-child:nth-child(-n + 3) ) { grid-template-columns: var(--size-leading-visual) max-content 1fr max-content; grid-template-areas: 'leading-visual title badges price' 'leading-visual description description actions'; }
Going the extra mile with container queriespermalink
If you’re not familiar, container queries allow you to adjust a parent element and its children based on qualities the parent possesses, such as its current width. They are powerful stuff, and another example of CSS elegantly and efficiently taking on something that previously had only been the domain of JavaScript.
We can instil even more portability and resiliency for our simple list component with container queries, in terms of handling our badge count issue.
With regards to code, all we’re doing is embracing another older technique towards building web experiences: going mobile-first. We set the component to default to the badges having their own grid row. This handles our baseline concern of not a lot of horizontal space being available:
- Code language
- css
grid-template-areas: 'leading-visual leading-visual' 'title title' 'badges badges' 'description description' 'price actions';
So, what does this get us? Good things! With this approach, the component is now set up to be able to shows badges flush with the list item title only if there is enough horizontal space available to accommodate the badge count. This is regardless of where the component is placed and how much horizontal space is available, and is accomplished via a container query:
- Code language
- css
@container simple-list (width > 38rem) { .simple-list-item:has( .simple-list-item-badge:last-child:nth-child(-n + 3) ) { grid-template-areas: 'leading-visual title badges price' 'leading-visual description description actions'; } }
This approach is really good for design systems in particular.
You can’t be everywhere all the time to know where your components are being placed, how much content is being placed in them, how much available space there is where the component instance is used, and if it all actually looks good.
Now:
- Your designers are happy because it doesn’t look bad,
- Your engineers are happy because your neatly-separated concerns take care of corner-case problems for them, and
- Your project managers are happy because there’s less velocity-slowing bugs they need to contend with.
Seeing it in actionpermalink
Let’s put this all together now and see how it works!
Flaws with this approachpermalink
There are three big-picture things to be aware of here:
- Author-supplied content,
- Older browser support, and
- Localization and internationalization.
Author-supplied content
One area where this approach may fall apart is a repeat of the original problem: we have no way of knowing the length of content per badge the person using the component will supply.
To my knowledge, CSS does not have a way to detect when arbitrary clusters of characters of an arbitrary count collide with a container whose width is dynamic.
Yes, there are things like inline display calculation and word break instructions. However, none of these approaches allow us to handle the situation where we want an unknown number of badges, each with an unknown character count, to look good across a wide range of layouts and viewport widths. Given that, choosing to break to a new line after three badges have been used is a bit of a magic number. This said, I still think it’s a good approach.
Be a detective if you adopt this approach for your own component needs, and get answers to these questions:
- What’s the average badge count and text length for existing, pre-componentitized content?
- What are the outliers?
- And why are they that way?
These answers can give you a good idea of what your component implementation magic number needs to be.
Browser support
The other thing to be mindful of is that :has()
and container queries are relatively new. Consider organizations that have to support older browsers, or knows that the people who use its services are slow or reluctant to upgrade. Enterprise and government services come to mind here.
Fortunately, we can use @supports
to have our cake and eat it too by providing a solid, less glamorous, but ultimately readable default, and then enhance that experience for browsers that can support newer platform features:
- Code language
- css
.simple-list { /* Basic formatting */ /* Set up a container query only if the browser supports it */ @supports (container-type: inline-size) { container: simple-list / inline-size; } } .simple-list-item { /* Mobile-first layout */ display: grid; grid-template-columns: 1fr 1fr; grid-template-rows: auto; grid-template-areas: 'leading-visual leading-visual' 'title title' 'badges badges' 'description description' 'price actions'; @supports (container-type: inline-size) { @container simple-list (width > 38rem) { /* Adjustments for when there is more horizontal space */ } } }
For our use case that means starting with a mobile-friendly treatment, where all the badges on their own row. Then we can have badges on the same row as the title only if the browser supports it and there is enough horizontal space.
Other languages
The same word can have drastically different character counts depending on the language it is written in. Don’t believe me? Translate your homepage’s content into German. If your website or web app supports multiple languages, your magic number might not work across every language you provide. Fortunately, CSS has us covered here.
We can use an attribute selector to target a parent lang declaration declared higher up in the DOM:
- Code language
- css
[lang="en"] .simple-list-item:has( .simple-list-item-badge:last-child:nth-child(-n + 3) ) { /* Styling instructions for when there's three or fewer badges in English */ }
Typically lang
is declared on the <html>
element, but it will also work for attributes declared inside the body element. This can be helpful for experiences that support multiple languages being used on a single page or view.
Granted, the weakness with this approach is it requires using the lang attribute in the first place. That’s something we should all be doing, regardless.
We can then adjust our badge count on a per-language basis to better adapt to the average word length the language uses:
- Code language
- css
[lang="en"] .simple-list-item:has( .simple-list-item-badge:last-child:nth-child(-n + 3) ) { /* Styling instructions for when there's three or fewer badges in English */ } [lang="de"] .simple-list-item:has( .simple-list-item-badge:last-child:nth-child(-n + 3) ) { /* Styling instructions for when there's only one badge in German */ }
Note that we can’t use CSS Custom Properties within pseudo classes yet. The previous code can become a lot more terse once this capability is added to the platform, in that our magic number could be codified as a Custom Property and updated on a per-language basis.
Another suggestion here would be to also use logical properties. This way your component can easily adapt to right-to-left and top-to-bottom languages as well.
Putting it all togetherpermalink
So, here you have it! A resilient, self-contained, content-aware component that can adapt to any language you throw at it.
Newer CSS features like :has()
, grid, container queries, @supports
, and logical properties are great for building hyper-resilient components. They’re even more powerful when combined with the established techniques we know and rely on.
I’m excited for what the future of CSS holds, and hopefully after reading this you are too!
Thank you to Matteo Fogli and Temani Afif for their feedback on newer, more terse quantity query syntax.