Responsive Layouts, Fewer Media Queries

Avatar of Temani Afif
Temani Afif on (Updated on )

UGURUS offers elite coaching and mentorship for agency owners looking to grow. Start with the free Agency Accelerator today.

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.

Using flex and flex-wrap

Live Demo

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).

What we have so far:

  • ✔️ Only two lines of code
  • ❌ Consistent element widths in the footer
  • ❌ Control the number of items per row
  • ❌ Control when the items wrap

Using auto-fit and minmax

Live Demo

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% - 20px)/3).

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 - 1) * gap)/N). 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:

clamp(100%/(N + 1) + 0.1%, 400px, 100%)

…to:

clamp(100%/(N + 1) + 0.1%, (400px - 100vw)*1000, 100%)

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.
Live Demo

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:

clamp(100%/(N + 1) + 0.1%, (400px - 100vw)*1000, 100%/(M + 1) + 0.1%)

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).

Live Demo

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.

Our formula becomes:

clamp(clamp(100%/(N + 1) + 0.1%, (W1 - 100vw)*1000,100%/(M + 1) + 0.1%), (W2 - 100vw)*1000, 100%)

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. (😱)

clamp(
  clamp(
    clamp(100%/(var(--n) + 1) + 0.1%, (var(--w1) - 100vw)*1000,
          100%/(var(--m) + 1) + 0.1%),(var(--w2) - 100vw)*1000,
          100%/(var(--p) + 1) + 0.1%),(var(--w3) - 100vw)*1000,
          100%), 1fr))
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 asked if 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:

div {
  background:
   linear-gradient(green 0 0) 0 / max(0px,100px - 100%) 1px,
   red;
}
  • 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.

div {
  background:
   linear-gradient(purple 0 0) 0 /max(0px,100px - 100%) 1px,
   linear-gradient(blue   0 0) 0 /max(0px,300px - 100%) 1px,
   linear-gradient(green  0 0) 0 /max(0px,500px - 100%) 1px,
   red;
}

Toggling an element’s visibility

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:

div {
  max-width: clamp(0px, (100vw - 500px) * 1000, 100%);
  max-height: clamp(0px, (100vw - 500px) * 1000, 1000px);
  overflow: hidden;
}

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.

div {
  --c:(100vw - 400px); /* we define our condition */
  top: clamp(10%, var(--c) * -1000, 50%);
  left: clamp(0px, var(--c) * 1000, 40%);
}
Live Demo

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.