Container Queries, Responsive Grids, and Fluid Typography

Introduction

This is part guide and part experiment. We'll explore how container queries, the CSS clamp function, and CSS grid, can be combined to create a range of fluid text styles, which are enhanced with a container query.

Example

Lorem grid gap metus, grid container queries row gap

Container query dolor sit, responsive design amet, consectetur grid template adipiscing elit. Integer non grid area vitae dolor grid-column-gap.

Lorem grid gap metus, grid container queries row gap

Container query dolor sit, responsive design amet, consectetur grid template adipiscing elit. Integer non grid area vitae dolor grid-column-gap.

Lorem grid gap metus, grid container queries row gap

Container query dolor sit, responsive design amet, consectetur grid template adipiscing elit. Integer non grid area vitae dolor grid-column-gap.

Lorem grid gap metus, grid container queries row gap

Container query dolor sit, responsive design amet, consectetur grid template adipiscing elit. Integer non grid area vitae dolor grid-column-gap.

Lorem grid gap metus, grid container queries row gap

Container query dolor sit, responsive design amet, consectetur grid template adipiscing elit. Integer non grid area vitae dolor grid-column-gap.

Text boxes with example text, aligned on a CSS grid. Each spans different column widths to demonstrate how a single container query can be used to create a range of text styles.

It's a familiar sight, text in boxes, spanning columns. What's different about this example is that I haven't used media queries to change font sizes, and the same code is used for each individual box of text, regardless of how wide it is, spooky.

The typical approach

Typically, you'd use a combination of media queries and fluid typography to achieve what's happening in the example, e.g.

:root {
  --font-size-xs: 0.75rem; /* 12px */
  --font-size-sm: 0.875rem; /* 14px */
  --font-size-base: 1rem; /* 16px */
  --font-size-lg: 2rem; /* 32px */
  --font-size-xl: 3rem; /* 48px */
  --font-size-xxl: 4rem; /* 64px */
}

body {
  font-size: clamp(var(--font-size-base), 1vw, var(--font-size-lg));
}

.box {
  font-size: var(--font-size-xs);

  @media (min-width: 768px) {
    font-size: var(--font-size-base);
  }

  @media (min-width: 1024px) {
    font-size: var(--font-size-lg);
  }
}

.h1 {
  font-size: var(--font-size-lg);
  line-height: 1.125;

  @media (min-width: 768px) {
    font-size: var(--font-size-xl);
  }

  @media (min-width: 1024px) {
    font-size: var(--font-size-xxl);
  }
}

.h2 {
  font-size: var(--font-size-sm);
  line-height: 1.125;

  @media (min-width: 768px) {
    font-size: var(--font-size-lg);
  }

  @media (min-width: 1024px) {
    font-size: var(--font-size-xl);
  }
}

The example doesn't give any control over what happens with the boxes that don't span the full 12 columns, so you'd also use modifier classes specifically for those cases, e.g.

.box-one-quarter {
  font-size: var(--font-size-xs);
}

.box-one-half {
  @media (min-width: 768px) {
    font-size: var(--font-size-base);
  }
}

.box-three-quarters {
  @media (min-width: 768px) {
    font-size: var(--font-size-base);
  }
  @media (min-width: 1024px) {
    font-size: var(--font-size-lg);
  }
}

Or this kind of utility-based approach:

.box-three-quarters-base {
  @media (min-width: 768px) {
    font-size: var(--font-size-base);
  }
}

.box-three-quarters-lg {
  @media (min-width: 1024px) {
    font-size: var(--font-size-lg);
  }
}

I like how the code can be defined in blocks, but it becomes repetitive, that's due to the use of media queries.

What compounds things is that there's a similar approach with spacing units and every other part of the system, so what you're seeing here is the tip of the iceberg.

Syncing all elements of a design system with every media query is a drain on time.

Text may appear too small on some screens and too large on others.

Let's try another approach.

Using container queries

Define some static font sizes as custom properties (I always use rem units for accessibility), so we can re-use them, I usually add them after any CSS resets.

These values usually come from a design system or a figma file, or you can make up your own.

:root {
  --font-size-xs: 0.75rem; /* 12px */
  --font-size-sm: 0.875rem; /* 14px */
  --font-size-base: 1rem; /* 16px */
  --font-size-lg: 2rem; /* 32px */
  --font-size-xl: 3rem; /* 48px */
  --font-size-xxl: 4rem; /* 64px */
}

Create a class, I called it .content for obvious reasons, then select the elements you want to apply the responsive font-sizes to.

.content {
  /* Make .content a container */
  container-type: inline-size;

  :is(p, ul, ol) {
    /* Select p, ul, ol and apply styles */
    @container (width >= 0) {
      line-height: 1.5;
      font-size: clamp(var(—-font-size-base), 5cqw, var(—-font-size-lg));
    }
  }
}
  • I added container-type: inline-size; to the parent element.
  • I then selected elements I want to apply styles to, they have to be children of the container, inb this case it's p, ul, ol.
  • Finally, I used a @container query to use the cqw unit, unless you're within a container this has no effect, it let's you get the size of the container and size the text dynamically based on the width of it.

I found using unitless values, e.g. line-height: 1.5, the most sensible way to handle line-height with fluid & responsive typography. Unitless values are multipliers, if you have font-size: 1rem and apply line-height: 1.5 the computed value of line-height will be 24px.

The way I do this is not an exact science (no, I'm not using a graph), and usually "by-eye", so the mid-range values I've used are based on what feels right.

Once you implement that code, to see it take effect, you'll need to change the widths of the containers.

Add containers to a grid

Drop the containers onto a CSS grid, you don't need anything fancy, this kind of thing will do the trick:

.grid-demo {
  display: grid;
  grid-column-gap: 1rem;
  grid-template-columns: repeat(6, 1fr);
  grid-row-gap: 1.5rem;

  @media screen and (min-width: 510px) {
    grid-template-columns: repeat(12, 1fr);
  }
}

Then, write CSS that places the containers within the columns you want them to appear in. For my example, I created several utility classes.

.one,
.two,
.three,
.four {
  grid-column: 1 / -1;
}

.two {
  @media screen and (min-width: 360px) {
    grid-column: span 3;
  }
  @media screen and (min-width: 510px) {
    grid-column: 1 / span 9;
  }
}

.three {
  @media screen and (min-width: 360px) {
    grid-column: span 3;
  }
  @media screen and (min-width: 510px) {
    grid-column: 1 / span 6;
  }
}

.four {
  @media screen and (min-width: 360px) {
    grid-column: span 3;
  }
}

You might be thinking, hang-on we're using media queries again; either way, we still need a way to lay out the containers on a grid, use whatever method works best.

Final touches

For a bit of variety with the headings, we can create utility classes. We don't need to touch the paragraphs again as the initial .box class has container queries that handle that.

.h1 {
  @container (min-width: 0) {
    font-size: clamp(var(--font-size-lg), 10cqw, var(--font-size-xxl));
    line-height: 1.125;
  }
}

.h2 {
  @container (min-width: 0) {
    font-size: clamp(var(--font-size-sm), 8cqw, var(--font-size-xl));
    line-height: 1.125;
  }
}

To apply fluid spacing between the paragraphs and headings, we can tap into the cqw unit again, and use clamp() to apply spacing proportionate to the size of the text in each container.

.content p:not(:last-child) {
  margin: clamp(1rem, 10cqw, 1.5rem) 0 clamp(1.5rem, 10cqw, 3rem) 0;
}

.content * + p {
  margin-top: clamp(1rem, 5cqw, 1.5rem);
}

The min and max values could also come from a series of custom properties from a design system.

Conclusion

I'm loosely adhering to a design system, this is intentional, I don't think we should get too hung up on absolute perfection with intermediary font-sizes using clamp(), but we definitely don't want any irregularities, just use the static values and go with what feels right in-between.

We've looked at how a typical setup for fluid and responsive typography would typically work, and then seen an alternative, which I prefer, you might too (if you do, I'd love to hear from you).

Always use rem units to avoid user zoom accessibility issues, zoom in to test your solution using the browser to make sure.

If you have any suggestions as to how the examples can be made clearer, easier to understand, or how I can add any missing use-cases you feel would help others, I'd love nothing more than to hear from you! Reach out to me on Twitter, or shoot me an email.

Written by Morgan Feeney

I’ve been designing and developing for almost 2 decades.
Read about me, and my work.

For juicy tips and exclusive content, subscribe to my FREE newsletter.