Tyler Sticka digs in here in the best possible way: by making a test page and literally measuring performance. Maybe 1,000 icons is a little bit of an edge case, but hey, 250 rows of data with four icons in each gets you there. Tyler covers the nuances carefully in the post. The different techniques tested: inline <svg>, same-document sprite <symbol>s, external-document sprite <symbol>s, <img> with an external source, <img> with a data URL, <img> with a filter, <div> with a background-image of an external source, <div> with a background-image of a data URL, and a <div> with a mask. Phew! That’s a lot — and they are all useful techniques in their own right.
Which technique won? Inline <svg>, unless the SVGs are rather complex, then <img> is better. That’s what I would have put my money on. I’ve been on that train for a while now.
Many business websites need a multilingual setup. As with anything development-related, implementing one in an easy, efficient, and maintainable way is desirable. Designing and developing to be ready for multiple languages, whether it happens right at launch or is expected to happen at any point in the future, is smart.
Changing the language and content is the easy part. But when you do that, sometimes the language you are changing to has a different direction. For example, text (and thus layout) in English flows left-to-right while text (and thus layout) in Arabic goes right-to-left.
In this article, I want to build a multilingual landing page and share some CSS techniques that make this process easier. Hopefully the next time you’ll need to do the same thing, you’ll have some implementation techniques to draw from.
We’ll cover six major points. I believe that the first five are straightforward. The sixth includes multiple options that you need to think about first.
1. Start with the HTML markup
The lang and dir attributes will define the page’s language and direction.
Then we can use these attributes in selectors to do the the styling. lang and dir attributes are on the HTML tag or a specific element in which the language varies from the rest of the page. Those attributes help improve the website’s SEO by showing the website in the right language for users who search for it in case that each language has a separate HTML document.
Also, we need to ensure that the charset meta tag is included and its value is UTF-8 since it’s the only valid encoding for HTML documents which also supports all languages.
<meta charset="utf-8">
I’ve prepared a landing page in three different languages for demonstration purposes. It includes the HTML, CSS, and JavaScript we need.
2. CSS Custom Properties are your friend
Changing the direction may lead to inverting some properties. So, if you used the CSS property left in a left-to-right layout, you probably need right in the right-to-left layout, and so on. And changing the language may lead to changing font families, font sizes, etc.
These multiple changes may cause unclean and difficult to maintain code. Instead, we can assign the value to a custom property, then change the value when needed. This is also great for responsiveness and other things that might need a toggle, like dark mode. We can change the font-size, margin, padding, colors, etc., in the blink of an eye, where the values then cascade to wherever needed.
Here are some of the CSS custom properties that we are using in this example:
While styling our page, we may add/change some of these custom properties, and that is entirely natural. Although this article is about multi-directional websites, here’s a quick example that shows how we can re-assign custom property values by having one set of values on the <body>, then another set when the <body> contains a .dark class:
That’s the general idea. We’re going to use custom properties in the same sort of way, though for changing language directions.
3) CSS pseudo-classes and selectors
CSS has a few features that help with writing directions. The following two pseudo-classes and attribute are good examples that we can put to use in this example.
The :lang() pseudo-class
We can use :lang() pseudo-class to target specific languages and apply CSS property values to them individually, or together. For example, in this example, we can change the font size when the :lang pseudo-class switches to either Arabic or Japanese:
Once we do that, we also need to change the writing-mode property from its horizontal left-to-right default direction to vertical right-to-left direction account:
The :attr() pseudo-class helps makes the “content” of the pseudo-elements like ::before or ::after “dynamic” in a sense, where we can drop the dir HTML attribute into the CSS content property using the attr() function. That way, the value of dir determines what we’re selecting and styling.
<div dir="ltr"></div>
<div dir="rtl"></div>
div::after {
content: attr(dir);
}
The power is the ability to use any custom data attribute. Here, we’re using a custom data-name attribute whose value is used in the CSS:
This makes it relatively easy to change the content after switching that language without changing the style. But, back to our design. The three-up grid of cards has a yellow “special” or “best” off mark beside an image.
Assign a “special offer” or “best offer” value to it.
Finally, we can use the data-offer attribute in our style:
.offers__item::after {
content: attr(data-offer);
/* etc. */
}
Select by the dir attribute
Many languages are left-to-right, but some are not. We can specify what should be different in the [dir='rtl']. This attribute must be on the element itself or we can use nesting to reach the wanted element. Since we’ve already added the dir attribute to our HTML tag, then we can use it in nesting. We will use it later on our sample page.
4. Prepare the web fonts
In a multilingual website, we may also want to change the font family between languages because perhaps a particular font is more legible for a particular language.
Fallback fonts
We can benefit from the fallback by writing the right-to-left font after the default one.
font-family: 'Roboto', 'Tajawal', sans-serif;
This helps in cases where the default font doesn’t support right-to-left. That snippet above is using the Roboto font, which doesn’t support Arabic letters. If the default font supports right-to-left (like the Cairo font), and the design needs it to be changed, then this is not a perfect solution.
font-family: 'Cairo', 'Tajawal', sans-serif; /* won't work as expected */
Let’s look at another way.
Using CSS variables and the :lang() pseudo-class
We can mix the previous two technique where we change the font-family property value using custom properties that are re-assigned by the :lang pseudo class.
html {
--font-family: 'Roboto', sans-serif;
}
html:lang(ar){
--font-family: 'Tajawal', sans-serif;
}
html:lang(jp){
--font-family: 'Noto Sans JP', sans-serif;
}
5. CSS Logical Properties
In CSS days past, we used to use left and right to define offsets along the x-axis, and the top and bottom properties to to define offsets along the y-axis. That makes direction switching a headache. Fortunately, CSS supports logical properties that define direction‐relative equivalents of the older physical properties. They support things like positioning, alignment, margin, padding, border, etc.
If the writing mode is horizontal (like English), then the logical inline direction is along the x-axis and the block direction refers to the y-axis. Those directions are flipped in a vertical writing mode, where inline travels the y-axis and and block flows along the x-axis.
Writing Mode
x-axis
y-axis
horizontal
inline
block
vertical
block
inline
In other words, the block dimension is the direction perpendicular to the writing mode and the inline dimension is the direction parallel to the writing mode. Both inline and block levels have start and end values to define a specific direction. For example, we can use margin-inline-start instead of margin-left. This mean the margin direction automatically inverts when the page direction is rtl. It’s like our CSS is direction-aware and adapts when changing contexts.
There is another article on CSS-Tricks, Building Multi-Directional Layouts from Ahmad El-Alfy, that goes into the usefulness of building websites in multiple languages using logical properties.
This is exactly how we can handle margins, padding and borders. We’ll use them in the footer section to change which border gets the rounded edge.
The top-tight edge of the border is rounded in a default ltr writing mode.
As long as we’re using the logical equivalent of border-top-right-radius, CSS will handle that change for us.
.footer {
border-start-end-radius: 120px;
}
Now, after switching to the rtl direction, it’ll work fine.
The “call to action” section is another great place to apply this:
You might be wondering exactly how the block and inline dimensions reverse when the writing mode changes. Back to the Japanese version, the text is from vertical, going from top-to-bottom. I added this line to the code:
/* The "About" section when langauge is Japanese */
html:lang(jp) .about__text {
margin-block-end: auto;
width: max-content;
}
Although I added margin to the “block” level, it is applied it to the left margin. That’s because the text rotated 90 degrees when the language switched and flows in a vertical direction.
6. Other layout considerations
Even after all this prep, sometimes where elements move to when the direction and language change is way off. There are multiple factors at play, so let’s take a look.
Position
Using an absolute or fixed position to move elements may affect how elements shift when changing directions. Some designs need it. But I’d still recommend asking yourself: do I really need this?
Fro example, the newsletter subscription form in the footer section of our example can be implemented using position. The form itself takes the relative position, while the button takes the absolute position.
<header class="hero relative">
<!-- etc. -->
<div class="hero__social absolute">
<div class="d-flex flex-col">
<!-- etc. -->
</div>
</div>
</header>
Note that an .absolute class is in there that applies position: absolute to the hero section’s social widget. Meanwhile, the hero itself is relatively positioned.
How we move the social widget halfway down the y-axis:
In the Arabic, we can fix the ::before pseudo-class position that is used in the background using the same technique we use in the footer form. That said, there are multiple issues we need to fix here:
The clip-path direction
The background linear-gradient
The coffee-cup image direction
The social media box’s position
Let’s use a simple flip trick instead. First, we wrap the hero content, and social content in two distinct wrapper elements instead of one:
Yeah, that’s all. This simple trick is also helpful if the hero’s background is an image.
transform: translate()
This CSS property and value function helps move the element on one or more axes. The difference between ltr and rtl is that the x-axis is the inverse/negative value of the current value. So, we can store the value in a variable and change it according to the language.
html {
--about-img-background-move: -20%;
}
html[dir='rtl']{
--about-img-background-move: 20%;
}
We can do the same thing for the background image in the another section:
Margins are used to extend or reduce spaces between elements. It accepts negative values, too. For example, a positive margin-top value (20%) pushes the content down, while a negative value (e.g. -20%) pulls the content up.
If margins values are negative, then the top and left margins move the item up or to the left. However, the right and bottom margins do not. Instead, they pull content that is located in the right of the item towards the left, and the content underneath the item up. For example, if we apply a negative top margin and negative bottom margin together on the same item, the item is moved up and pull the content below it up into the item.
The result of the above code should be something like this:
Let’s add these negative margins to the #d2 element:
#d2 {
margin-top: -40px;
margin-bottom: -70px;
}
Notice how the second box in the diagram moves up, thanks to a negative margin-top value, and the green box also moves up an overlaps the second box, thanks to a negative margin-bottom value.
The next thing you might be asking: But what is the difference between transform: translate and the margins?
When moving an element with a negative margin, the initial space that is taken by the element is no longer there. But in the case of translating the element using a transform, the opposite is true. In other words, a negative margin leads pulls the element up, but the transform merely changes its position, without losing the space reserved for it.
You can see that, although the element is pulled up, its initial space is still there according to the natural document flow.
Flexbox
The display: flex provides a quick way to control the how the elements are aligned in their container. We can use align-items and justify-content to align child elements at the parent level.
In our example, we can use flexbox in almost every section to make the column layout. Plus, we can use it in the “offers” section to center the set of those yellow “special” and “best” marks:
If the flex-direction value is row, then we can benefit from controlling the width for each element. In the “hero” section, we need to set the image on the angled slope of the background where the color transitions from dark gray to yellow.
Both elements take up a a total of 89.83% of the parent container’s width. Since we didn’t specify justify-content on the parent, it defaults to start, leaving the remaining width at the end.
We can combine the flexbox with any of the previous techniques we’ve seen, like transforms and margins. This can help us to reduce how many position instances are in our code. Let’s use it with a negative margin in the “call to action” section to locate the image.
Because we didn’t specify the flex-wrap and flex-basis properties, the image and the text both fit in the parent. However, since we used a negative margin, the image is pulled to the left, along with its width. This saves extra space for the text. We also want to use a logical property, inline-start, instead of left to handle switching to the rtl direction.
Grid
Finally, we can use a grid container to positing the elements. CSS Grid is powerful (and different than flexbox) in that it lays things along both the x-axis and the y-axis as opposed to only one of them.
Suppose that in the “offers” section, the role of the “see all” button is to get extra data that to display on the page. Here’s JavaScript code to repeat the current content:
Our page is far from being the best example of how CSS Grid works. While I was browsing the designs online, I found a design that uses the following structure:
Notice how CSS Grid makes the responsive layout without media queries. And as you might expect, it works well for changing writing modes where we adjust where elements go on the grid based on the current writing mode.
Wrapping up
Here is the final version of the page. I ensured to implement the responsiveness with a mobile-first approach to show you the power of the CSS variables. Be sure to open the demo in full page mode as well.
I hope these techniques help make creating multilingual designs easier for you. We looked at a bunch of CSS properties we can use to apply styles to specific languages. And we looked at different approaches to do that, like selecting the :lang pseudo-class and data attributes using the attr() function. As part of this, we covered what logical properties are in CSS and how they adapt to a document’s writing mode—which is so much nicer than having to write additional CSS rulesets to swap out physical property units that otherwise are unaffected by the writing mode.
We also checked out a number of different positioning and layout techniques, looking specifically at how different techniques are more responsive and maintainable than others. For example, CSS Grid and Flexbox are equipped with features that can re-align elements inside of a container based on changing conditions.
Clearly, there are lots of moving pieces when working with a multilingual site. There are probably other requirements you need to consider when optimizing a site for specific languages, but the stuff we covered here together should give you all of the layout-bending superpowers you need to create robust layouts that accommodate any number of languages and writing modes.
The idea that it’s alright to do whatever unethical thing is currently the industry norm is widespread in tech, and dangerous.
It stood out to me because I had been thinking about certain practices that are widespread, accepted, and yet strike me as deeply problematic. These practices involve tracking users.
And ends with zero minced words:
We should stop stop tracking users because it’s wrong.
I take notice here, as I’m largely complicit when it comes to some degree of user tracking. For example, I have Google Analytics on this site. And pertinent to the topic: I have for well over a decade. I mention that not to prove that it’s OK, but almost to question it more, because it’s such a widespread long-term industry standard that is rarely questioned.
Because I have Google Analytics¹ on this site, I can take zoomed-out looks at the long-term traffic on this site. Here’s a 10-year period:
I realize that even this screenshot of a chart may be abhorrent to some, as it was collected from users who did not explicitly consent.
Or I can see how year-over-year mobile traffic on this site has gone down nearly 6%.
Weird.
I don’t send any personal information to Google Analytics. I don’t know who did what — I can only see anonymous aggregate data. Not only is it literally against Google policy to do so:
The Analytics terms of service, which all Analytics customers must adhere to, prohibits sending personally identifiable information (PII) to Analytics (such as names, social security numbers, email addresses, or any similar data), or data that permanently identifies a particular device.
… but I have a much clearer ethical line in my head there — that’s not something I’m comfortable with. Even when I’ve implemented user tracking that does tie a particular user to a particular action, it’s still anonymized such that it’s impossible for me to tell from using that tool who has done what.
But I understand that even this “anonymous” tracking is what is being questioned here. For example, just because what I send is anonymous, it doesn’t mean that attempts can’t be made to try to figure out exactly who is doing what by whoever has that data.
I’m generally fine with anonymous tracking, but the receiving parties go to great lengths to deanonymize it which may be where concern stems from.
Switching the focus to email, I do use MailChimp to send the email newsletter on this site, and I haven’t done anything special with the settings to increase or decrease how much tracking happens when a newsletter is sent. As such, I can see data, like how many people I send to, how many open it, and how many clicks happened:
As I write this, I’m poking around in the reporting section to see what else I can see. Ughghk, guess what? I can literally see exactly who opened the email (by the person’s email address) and which links they clicked. I didn’t even realize that until now, but wow, that’s very super personally identifiable analytics information. I’m going to look into how I can turn that off because it does cross an ethical line for me.
There is also a brand new mini-war happening with email tracking (not the first, as I remember the uproar when Gmail started proxying images through their own servers, thus “breaking” the accuracy tracker pixel images). This time, it’s Apple doing more aggressive blocking, and companies like MailChimp having to tell customers it is going to mess with their analytics:
Apple Mail in macOS Monterey
Warning on the MailChimp reporting screen
I’m interested not just in the ethical concerns and my long-time complacency with industry norms, but also as someone who very literally sells advertising. I can tell you these things are true:
I have meetings about pricing where the decisions are based on the historical performance of what is being sold, meaning impressions and clicks.
The vast majority of first conversations between bag-of-money-holding advertisers and publishers like me, the very first questions I’m asked are about performance metrics.
That feels largely OK to me. When I go to the store to buy walnuts, I want to know how many walnuts I’m going to get for my dollar. I expect the store to price the walnuts based on normal economic factors, like how much they cost and the supply/demand for walnuts. The advertising buyers are the walnut buyers — they want to know what kind of performance an ad is likely to get for their dollar.
What if I said: I don’t know? I don’t know how many people see these ads. I don’t know how many people click these ads. I don’t know where they are from. I don’t know anything at all. And more, you aren’t allowed to know either. You can give me a URL to send them to, but it cannot have tracking params on it and we won’t be tracking the clicks on it.
Would I lose money? I gotta tell you readers: yes. In the short-term, anyway. It’s hard enough to land advertisers as it is. Coming off as standoffish and unwilling to tell them how many walnuts they are going to get for their dollar is going to make them roll their eyes and move on. Long-term, I bet it could be done. Tell advertisers (and the world) up front, very clearly, your stance on user tracking and how it means that you don’t have and won’t provide numbers via tracking. Lean on supply and demand entirely. Price spots at $X to start. If other people have interest in the spot, raise the price until it stops selling, lower the price if it does. I bet it could be done.
To be honest, I’m not ready to tip my apple cart yet. I have a mortgage. I have employees to pay. I absolutely do not have a war chest to dip into to ride out a major income shortage. If I lost most of my advertising income I would just… fail. Close up shop. Be forced to make other dramatic life changes to deal with it. And I just don’t want to. It doesn’t feel like rolling the dice, because that implies I might win big. But if I were to take a hardline stance with advertisers, telling them that I provide zero data, “winning big” is merely getting back to the baseline for me.
I write all this just to help me think about it. I don’t want to sound like I’m being defensive. If I come across that way, I’d blame my own inertia for following what have felt like industry standards for so long, and being indoctrinated that those practices are just fine. I don’t feel like I’m crossing major ethical boundaries at the moment, but I’d rather be someone who questions myself and takes action when appropriate rather than tying a bandana over my eyes.
I have tried other analytics services, like Plausible, that are more specifically privacy-focused.
What’s that @noflip business? That’s what they are calling a “CSS decorator” and I think that’s a fine term for it. Really they are just CSS comments, but clearly there is more going on here as those look like programmatic statements that have functionality.
Without some kind of CSS processing, those comments will do nothing. Off the top of my head, I’m not 100% sure what CSS processor is in use here, but I think it’s reasonable to assume that when it runs, it produces a “right-to-left” stylesheet that turns float: left into float: right and text-align: left into text-align: right.
I think it’s worth noting that it’s probably smarter these days to use the natively supported text-align: start so that you don’t have to rely on CSS processing and alternate stylesheets to help you. I don’t think there is a “logical” equivalent for float, unfortunately, but there may be a way to refactor the layout (using grid?) such that “flipping” is unnecessary. Although, wrapping elements around an element is pretty unique to float, so there might not be a simple alternative here.
Searching around a little, it seems like the source of /* @noflip */is CSSJanus.
The repo suggests it’s made by Wikimedia, so I think that’s a solved case. It looks like the tech has made it’s way to other things, like a plugin for styled-components, a plugin for Sublime Text, and Salesforce even used it in their design system.
There is another processor called css-flip (archived, from Twitter) that looks like it did the exact same thing, and the README shows just how many properties might need this:
It would have hugely surprised me if there wasn’t a PostCSS plugin for this, and a little searching turned up postcss-rtl, but alas, it’s also been deprecated by the owner.
This all started with talking about “CSS decorators” though, which I guess we’re defining as “CSS comments that have processor directives in them.” The one I personally use the most is this:
/* prettier-ignore */
.cool {
linear-gradient(
to left,
pink
pink 20%
red 20%
red
)
}
I love Prettier, but if I take the time to format a bit of CSS myself for readability, I’ll chuck a /* prettier-ignore */ on the previous line so it doesn’t mess with it.
We cannot talk about web development without talking about Responsive Design. It’s just a given these days and has been for many years. Media queries are a part of Responsive Design and they aren’t going anywhere. Since the introduction of media queries (literally decades ago), CSS has evolved to the points that there are a lot of tricks that can help us drastically reduce the usage of media queries we use. In some cases, I will show you how to replace multiple media queries with only one CSS declaration. These approaches can result in less code, be easier to maintain, and be more tied to the content at hand.
Let’s first take a look at some widely used methods to build responsive layouts without media queries. No surprises here — these methods are related to flexbox and grid.
In the above demo, flex: 400px sets a base width for each element in the grid that is equal to 400px. Each element wraps to a new line if there isn’t enough room on the currently line to hold it. Meanwhile, the elements on each line grow/stretch to fill any remaining space in the container that’s leftover if the line cannot fit another 400px element, and they shrink back down as far as 400px if another 400px element can indeed squeeze in there.
Let’s also remember that flex: 400px is a shorthand equivalent to flex: 1 1 400px (flex-grow: 1, flex-shrink: 1, flex-basis: 400px).
Similar to the previous method, we are setting a base width—thanks to repeat(auto-fit, minmax(400px, 1fr))—and our items wrap if there’s enough space for them. This time, though we’re reaching for CSS Grid. That means the elements on each line also grow to fill any remaining space, but unlike the flexbox configuration, the last row maintains the same width as the rest of the elements.
So, we improved one of requirements and solved another, but also introduced a new issue since our items cannot shrink below 400px which may lead to some overflow.
✔️ Only one line of code
✔️ Consistent element widths in the footer
❌ Control the number of items per row
❌ Items grow, but do not shrink
❌ Control when the items wrap
Both of the techniques we just looked at are good, but we also now see they come with a few drawbacks. But we can overcome those with some CSS trickery.
Control the number of items per row
Let’s take our first example and change flex: 400px to flex: max(400px, 100%/3 - 20px).
Resize the screen and notice that each row never has more than three items, even on a super wide screen. We have limited each line to a maximum of three elements, meaning each line only contains between one and three items at any given time.
Let’s dissect the code:
When the screen width increases, the width of our container also increases, meaning that 100%/3 gets bigger than 400px at some point.
Since we are using the max() function as the width and are dividing 100% by 3 in it, the largest any single element can be is just one-third of the overall container width. So, we get a maximum of three elements per row.
When the screen width is small, 400px takes the lead and we get our initial behavior.
You might also be asking: What the heck is that 20px value in the formula?
It’s twice the grid template’s gap value, which is 10px times two. When we have three items on a row, there are two gaps between elements (one on each on the left and right sides of the middle element), so for N items we should use max(400px, 100%/N - (N - 1) * gap). Yes, we need to account for the gap when defining the width, but don’t worry, we can still optimize the formula to remove it!
We can use max(400px, 100%/(N + 1) + 0.1%). The logic is: we tell the browser that each item has a width equal to 100%/(N + 1) so N + 1 items per row, but we add a tiny percentage ( 0.1%)—thus one of the items wraps and we end with only N items per row. Clever, right? No more worrying about the gap!
Now we can control the maximum number of items per row which give us a partial control over the number of items per row.
The same can also be applied to the CSS Grid method as well:
Note that here I have introduced custom properties to control the different values.
We’re getting closer!
✔️ Only one line of code
✔️ Consistent element widths in the footer
⚠️ Partial control of the number of items per row
❌ Items grow, but do not shrink
❌ Control when the items wrap
Items grow, but do not shrink
We noted earlier that using the grid method could lead to overflow if the base width is bigger than the container width. To overcome this we change:
max(400px, 100%/(N + 1) + 0.1%)
…to:
clamp(100%/(N + 1) + 0.1%, 400px, 100%)
Breaking this down:
When the screen width is big, 400px is clamped to 100%/(N + 1) + 0.1%, maintaining our control of the maximum number of items per row.
When the screen width is small, 400px is clamped to 100% so our items never exceed the container width.
We’re getting even closer!
✔️ Only one line of code
✔️ Consistent element widths in the footer
⚠️ Partial control of the number of items per row
✔️ Items grow and shrink
❌ Control when the items wrap
Control when the items wrap
So far, we’ve had no control over when elements wrap from one line to another. We don’t really know when it happens because it depends a number of things, like the base width, the gap, the container width, etc. To control this, we are going to change our last clamp() formula, from this:
I can hear you screaming about that crazy-looking math, but bear with me. It’s easier than you might think. Here’s what’s happening:
When the screen width (100vw) is greater than 400px, (400px - 100vw) results in a negative value, and it gets clamped down to 100%/(N + 1) + 0.1%, which is a positive value. This gives us N items per row.
When the screen width (100vw) is less than 400px, (400px - 100vw) is a positive value and multiplied by a big value that’s clamped to the 100%. This results in one full-width element per row.
Hey, we made our first media query without a real media query! We are updating the number of items per row from N to 1 thanks to our clamp() formula. It should be noted that 400px behave as a breakpoint in this case.
What about: from N items per row to M items per row?
We can totally do that by updating our container’s clamped width:
I think you probably get the trick by now. When the screen width is bigger than 400px we fall into the first rule (N items per row). When the screen width is smaller than 400px, we fall into the second rule (M items per row).
There we go! We can now control the number of items per row and when that number should change—using only CSS custom properties and one CSS declaration.
✔️ Only one line of code
✔️ Consistent element widths in the footer
✔️ Full control of the number of items per row
✔️ Items grow and shrink
✔️ Control when the items wrap
More examples!
Controlling the number of items between two values is good, but doing it for multiple values is even better! Let’s try going from N items per row to M items per row, down to one item pre row.
A clamp() within a clamp()? Yes, it starts to get a big lengthy and confusing but still easy to understand. Notice the W1 and W2 variables in there. Since we are changing the number of items per rows between three values, we need two “breakpoints” (from N to M, and from M to 1).
Here’s what’s happening:
When the screen width is smaller than W2, we clamp to 100%, or one item per row.
When the screen width is larger than W2, we clamp to the first clamp().
In that first clamp, when the screen width is smaller than W1, we clamp to 100%/(M + 1) + 0.1%), or M items per row.
In that first clamp, when the screen width is bigger than W1, we clamp to 100%/(N + 1) + 0.1%), or N items per row.
We made two media queries using only one CSS declaration! Not only this, but we can adjust that declaration thanks to the CSS custom properties, which means we can have different breakpoints and a different number of columns for different containers.
How many media queries do we have in the above example? Too many to count but we will not stop there. We can have even more by nesting another clamp() to get from N columns to M columns to P columns to one column. (😱)
from N columns to M columns to P columns to 1 column
As I mentioned at the very beginning of this article, we have a responsive layout without any single media queries while using just one CSS declaration—sure, it’s a lengthy declaration, but still counts as one.
A small summary of what we have:
✔️ Only one line of code
✔️ Consistent element widths in the footer
✔️ Full control of the number of items per row
✔️ Items grow and shrink
✔️ Control when the items wrap
✔️ Easy to update using CSS custom properties
Let’s simulate container queries
Everyone is excited about container queries! What makes them neat is they consider the width of the element instead of the viewport/screen. The idea is that an element can adapt based on the width of its parent container for more fine-grain control over how elements respond to different contexts.
Container queries aren’t officially supported anywhere at the time of this writing, but we can certainly mimic them with our strategy. If we change 100vw with 100% throughout the code, things are based on the .container element’s width instead of the viewport width. As simple as that!
Resize the below containers and see the magic in play:
The number of columns change based on the container width which means we are simulating container queries! We’re basically doing that just by changing viewport units for a relative percentage value.
More tricks!
Now that we can control the number of columns, let’s explore more tricks that allow us to create conditional CSS based on either the screen size (or the element size).
Conditional background color
A while ago someone on StackOverflow askedif it is possible to change the color of an element based on its width or height. Many said that it’s impossible or that it would require a media query.
But I have found a trick to do it without a media query:
We have a linear gradient layer with a width equal to max(0px,100px - 100%) and a height equal to 1px. The height doesn’t really matter since the gradient repeats by default. Plus, it’s a one color gradient, so any height will do the job.
100% refers to the element’s width. If 100% computes to a value bigger than 100px, the max() gives us 0px, which means that the gradient does not show, but the comma-separated red background does.
If 100% computes to a value smaller than 100px, the gradient does show and we get a green background instead.
In other words, we made a condition based on the width of the element compared to 100px!
This demo supports Chrome, Edge, and Firefox at the time of writing.
The same logic can be based on an element’s height instead by rearranging where that 1px value goes: 1px max(0px,100px - 100%). We can also consider the screen dimension by using vh or vw instead of %. We can even have more than two colors by adding more gradient layers.
To show/hide an element based on the screen size, we generally reach for a media query and plop a classic display: none in there. Here is another idea that simulates the same behavior, only without a media query:
Based on the screen width (100vw), we either get clamped to a 0px value for the max-height and max-width (meaning the element is hidden) or get clamped to 100% (meaning the element is visible and never greater than full width). We’re avoiding using a percentage for the max-height since it fails. That’s why we’re using a big pixel value (1000px).
Notice how the green elements disappear on small screens:
It should be noted that this method is not equivalent to the toggle of the display value. It’s more of a trick to give the element 0×0 dimensions, making it invisible. It may not be suitable for all cases, so use it with caution! It’s more a trick to be used with decorative elements where we won’t have accessibility issues. Chris wrote about how to hide content responsibly.
It’s important to note that I am using 0px and not 0 inside clamp() and max(). The latter makes invalidates property. I won’t dig into this but I have answered a Stack Overflow question related to this quirk if you want more detail.
Changing the position of an element
The following trick is useful when we deal with a fixed or absolutely positioned element. The difference here is that we need to update the position based on the screen width. Like the previous trick, we still rely on clamp() and a formula that looks like this: clamp(X1,(100vw - W)*1000, X2).
Basically, we are going to switch between the X1 and X2 values based on the difference, 100vw - W, where W is the width that simulates our breakpoint.
Let’s take an example. We want a div placed on the left edge (top: 50%; left:0) when the screen size is smaller than 400px, and place it somewhere else (say top: 10%; left: 40%) otherwise.
First, I have defined the condition with a CSS custom property to avoid the repetition. Note that I also used it with the background color switching trick we saw earlier—we can either use (100vw - 400px) or (400px - 100vw), but pay attention to the calculation later as both don’t have the same sign.
Then, within each clamp(), we always start with the smallest value for each property. Don’t incorrectly assume that we need to put the small screen value first!
Finally, we define the sign for each condition. I picked (100vw - 400px), which means that this value will be negative when the screen width is smaller than 400px, and positive when the screen width is bigger than 400px. If I need the smallest value of clamp() to be considered below 400px then I do nothing to the sign of the condition (I keep it positive) but if I need the smallest value to be considered above 400px I need to invert the sign of the condition. That’s why you see (100vw - 400px)*-1000 with the top property.
OK, I get it. This isn’t the more straightforward concept, so let’s do the opposite reasoning and trace our steps to get a better idea of what we’re doing.
For top, we have clamp(10%,(100vw - 400px)*-1000,50%) so…
if the screen width (100vw) is smaller than 400px, then the difference (100vw - 400px) is a negative value. We multiply it with another big negative value (-1000 in this case) to get a big positive value that gets clamped to 50%: That means we’re left with top: 50% when the screen size is smaller than 400px.
if the screen width (100vw) is bigger than 400px, we end with: top: 10% instead.
The same logic applies to what we’re declaring on the left property. The only difference is that we multiply with 1000 instead of -1000 .
Here’s a secret: You don’t really need all that math. You can experiment until you get it perfect values, but for the sake of the article, I need to explain things in a way that leads to consistent behavior.
It should be noted that a trick like this works with any property that accepts length values (padding, margin, border-width, translate, etc.). We are not limited to changing the position, but other properties as well.
Demos!
Most of you are probably wondering if any of these concepts are at all practical to use in a real-world use case. Let me show you a few examples that will (hopefully) convince you that they are.
Progress bar
The background color changing trick makes for a great progress bar or any similar element where we need to show a different color based on progression.
This demo supports Chrome, Edge, and Firefox at the time of writing.
That demo is a pretty simple example where I define three ranges:
Red: [0% 30%]
Orange: [30% 60%]
Green: [60% 100%]
There’s no wild CSS or JavaScript to update the color. A “magic” background property allows us to have a dynamic color that changes based on computed values.
Editable content
It’s common to give users a way to edit content. We can update color based on what’s entered.
In the following example, we get a yellow “warning” when entering more than three lines of text, and a red “warning” if we go above six lines. This can a way to reduce JavaScript that needs to detect the height, then add/remove a particular class.
This demo supports Chrome, Edge, and Firefox at the time of writing.
Timeline layout
Timelines are great patterns for visualizing key moments in time. This implementation uses three tricks to get one without any media queries. One trick is updating the number of columns, another is hiding some elements on small screens, and the last one is updating the background color. Again, no media queries!
When the screen width is below 600px, all of the pseudo elements are removed, changing the layout from two columns to one column. Then the color updates from a blue/green/green/blue pattern to a blue/green/blue/green one.
Responsive card
Here’s a responsive card approach where CSS properties update based on the viewport size. Normally, we might expect the layout to transition from two columns on large screens to one column on small screens, where the card image is stacked either above or below the content. In this example, however, we change the position, width, height, padding, and border radius of the image to get a totally different layout where the image sits beside the card title.
Speech bubbles
Need some nice-looking testimonials for your product or service? These responsive speech bubbles work just about anywhere, even without media queries.
Fixed button
You know those buttons that are sometimes fixed to the left or right edge of the screen, usually for used to link up a contact for or survey? We can have one of those on large screens, then transform it into a persistent circular button fixed to the bottom-right corner on small screens for more convenient taps.
Fixed alert
One more demo, this time for something that could work for those GDPR cookie notices:
Conclusion
Media queries have been a core ingredient for responsive designs since the term responsive design was coined years ago. While they certainly aren’t going anywhere, we covered a bunch of newer CSS features and concepts that allow us to rely less often on media queries for creating responsive layouts.
We looked at flexbox and grid, clamp(), relative units, and combined them together to do all kinds of things, from changing the background of an element based on its container width, moving positions at certain screen sizes, and even mimicking as-of-yet-unreleased container queries. Exciting stuff! And all without one @media in the CSS.
The goal here is not to get rid or replace media queries but it’s more to optimize and reduce the amount of code especially that CSS has evolved a lot and now we have some powerful tool to create conditional styles. In other words, it’s awesome to see the CSS feature set grow in ways that make our lives as front-enders easier while granting us superpowers to control how our designs behave like we have never had before.