Unleashing the Power of CSS: Advanced Techniques for Responsive User Interfaces

Copyright © 2023 SitePoint Pty. Ltd.

Ebook ISBN: 978-1-925836-56-1

Author: Stephanie Eckles

Series Editor: Oliver Lindberg

Technical Editor: Michelle Barker

Product Manager: Simon Mackie

English Editor: Ralph Mason

Cover Designer: Mark O'Neill

Notice of Rights

All rights reserved. No part of this book may be reproduced, stored in a retrieval system or transmitted in any form or by any means, without the prior written permission of the publisher, except in the case of brief quotations embodied in critical articles or reviews.

Notice of Liability

The author and publisher have made every effort to ensure the accuracy of the information herein. However, the information contained in this book is sold without warranty, either express or implied. Neither the authors and SitePoint Pty. Ltd., nor its dealers or distributors will be held liable for any damages to be caused either directly or indirectly by the instructions contained in this book, or by the software or hardware products described herein.

Trademark Notice

Rather than indicating every occurrence of a trademarked name as such, this book uses the names only in an editorial fashion and to the benefit of the trademark owner with no intention of infringement of the trademark.

SitePoint logo
Published by SitePoint Pty. Ltd.

10-12 Gwynne St, Cremorne, VIC, 3121
Australia
Web: www.sitepoint.com
Email: books@sitepoint.com

About SitePoint

SitePoint specializes in publishing fun, practical, and easy-to-understand content for web professionals. Visit http://www.sitepoint.com/ to access our blogs, books, newsletters, articles, and community forums. You’ll find a stack of information on JavaScript, PHP, design, and more.

About the Author

Stephanie Eckles is the author of in-depth tutorials on ModernCSS.dev, and the creator of StyleStage.dev, SmolCSS.dev, and 11ty.Rocks. Steph has well over a decade of webdev experience that she enjoys sharing as an author, egghead and workshop instructor, podcast host, Twitch streamer, and conference speaker. She's an advocate for accessibility, scalable CSS, and the Jamstack. Offline, she's mom to two girls and a cowboy corgi and enjoys baking.

Preface

Who Should Read This Book?

This book is for anyone who wants to better understand the new and improved features have landed in CSS over the past few years. It's assumed you have some prior experience with CSS.

Conventions Used

Code Samples

Code in this book is displayed using a fixed-width font, like so:

<h1>A Perfect Summer's Day</h1>
<p>It was a lovely day for a walk in the park.
The birds were singing and the kids were all back at school.</p>

You’ll notice that we’ve used certain layout styles throughout this book to signify different types of information. Look out for the following items.

Tips, Notes, and Warnings

Hey, You!

Tips provide helpful little pointers.

Ahem, Excuse Me ...

Notes are useful asides that are related—but not critical—to the topic at hand. Think of them as extra tidbits of information.

Make Sure You Always ...

... pay attention to these important points.

Watch Out!

Warnings highlight any gotchas that are likely to trip you up along the way.

Supplementary Materials

https://www.sitepoint.com/community/ are SitePoint’s forums, for help on any tricky problems.

books@sitepoint.com is our email address, should you need to contact us to report a problem, or for any other reason.

Chapter 1: What You’ve Missed in CSS Land

Many new and improved features have landed in CSS over the past few years, and a cross-browser effort to improve compatibility has made the language more stable than ever! Let’s review these enhancements to layout, responsive design, element styling, properties, and selectors, and also peek at upcoming features.

The coordinated implementation of new features across browsers these days means we can start to use these features pretty much as soon as they appear, which is great for keeping our stylesheets as simple as possible. A few, single-line properties can now replace multi-line, hacky solutions. In some cases, newly available features may even mean we can remove JavaScript workarounds we once needed to get around old limitations!

The CSS Working Group (CSSWG)—the body responsible for updates to the language—has been taking feedback from developers and drawing inspiration from community solutions. The improvements described below provide an exciting glimpse into what’s now possible with CSS. There’s never been a better time to get (back) into learning CSS!

New and Enhanced Properties

In this first section, we’ll review some exciting new properties that have landed in CSS in recent times.

Custom Properties

With the end-of-life timeline started for Internet Explorer 11, it’s high time to use custom properties! Also referred to as “CSS variables”, custom properties allow us to define values that are reusable across our stylesheets. Custom properties can be used as an entire value for a property or as partial values, like the h in an hsl() definition. We can also modify custom properties in JavaScript. Check out my overview of how custom properties can be used.

Since custom properties aren’t static like Sass or other preprocessor variables, they open up a lot of opportunities for more flexible, dynamic styles. Custom properties offer far more than just being “CSS variables”, as Lea Verou explains in her incredible presentation at CSS Day 2022.

aspect-ratio

A new property that eliminates “the padding hack” is aspect-ratio. This does what its name suggests, allowing us to define an aspect ratio for sizing an element. The hack I referred to was commonly used to maintain a 16:9 ratio for video embeds. Now that ratio can be achieved through this one property and the declaration aspect-ratio: 16/9. It’s also a quick way to achieve a perfect square with aspect-ratio: 1.

Here’s a CodePen demo of how to use aspect-ratio in conjunction with the older property object-fit to keep a consistent avatar size regardless of the original image ratio and without distorting the image.

Individual Transform Properties

Also newly implemented across browsers are individual transform properties. Previously, if we wanted to redefine the rotate() value within the transform property, we would have to duplicate the definition (or leverage custom properties). As of Chromium 104, we can now use the individual properties of translate, rotate, and scale.

Logical Properties

If we’re managing content for an international audience, we can consider using logical properties. Available for most CSS 2.1 properties, logical variants consider the writing mode and flow of text. For standard English text, we would trade “left/right” for “inline”, and “top/bottom” for “block”:

.element {
  margin-block: 2rem;
}

As in the example above, logical properties also offer shorthands for setting two sides at once, where margin-block for horizontal writing modes is equivalent to setting margin-top and margin-bottom.

The inset Shorthand

Another useful logical shorthand is inset, which sets top, right, bottom and left at once.

New and Extended Selectors

Three of the most influential changes to CSS of late have been the :is, :where, and :has pseudo-class selectors. Here’s a summary of them:

:is() accepts a selector list and equals the specificity of the highest selector. For example, :is(#id, a, .class) will have the specificity of an id.

:where() accepts a selector list and always has zero-specificity, making it a popular choice for defining baseline styles in resets.

:has() is the long-awaited “parent selector”, which allows checking a parent for a particular child and then styling either the parent or extending into a compound selector to style the children. (There’s a whole tutorial on :has() in this series.)

Forgiving Selectors

Uniquely, both :is() and :where() are also forgiving selectors. This means that, if an invalid selector or a prefixed selector that doesn’t apply is in the list, the rule will continue to work for the valid selectors. This is contrary to standard compound rule definitions like -webkit-property, property, where browsers that don’t understand -webkit-property will throw out the whole rule.

This CodePen demo harnesses :where(), :is(), and :has() to create an author bio component that changes grid display properties depending on whether or not an avatar is available.

Partial Browser Support

Note that :has() only has partial browser support at the time of writing, so the demo above will currently work in Safari 15.4+ and Chrome/Edge 105+, as well as Firefox 103 with the layout.css.has-selector.enabled flag enabled.

Enhanced :not()

The :not() selector has recently been enhanced to accept a selector list, making :not(nav a, footer a) valid. However, unlike :is() and :where(), the update hasn’t made :not() forgiving of invalid selectors due to backward compatibility support.

Focus Selectors

The next two new pseudo-classes both affect focus behavior. The :focus-within selector can be used to style a parent when a child is in focus—such as a container around a form field. For element focus styles, we can now use :focus-visible, which has recently replaced :focus as the cross-browser default for styling elements that receive focus.

The :focus-visible pseudo-class makes defined styles visible based on heuristics. Practically speaking, this involves setting focus styles when elements gain focus via keyboard tabbing but not via mouse clicks. For more on this, check out my method of standardizing focus styles using custom properties and focus-visible, which also covers the handy companion property outline-offset.

::marker

Last but not least, the ::marker pseudo-element allows us to directly select and style list item bullets and numbers on <ul> and <ol> elements, as well as the “caret” for the <summary> element. This means we can use ::marker to change just a list’s bullet color! There’s a reduced set of allowed properties for styling with ::marker, and it may not be the best approach depending on the complexity of a design. For more details on this, and for more ways to apply custom list styling, check out my “Totally Custom List Styles” article, as well as this additional information about ::marker.

Improvements for Element Styling

In this section, we’ll review some useful new properties for styling elements.

accent-color

One of the most common things frameworks and design systems seek to change is native form field styles. Prior to the arrival of the accent-color property, even changing a form element’s color was impossible. With accent-color, we can now influence the checked appearance of radio buttons and checkboxes, and the filled-in state for range inputs and the progress element.

color-scheme

If we’re looking to adapt our interface to a user’s light or dark mode preference using a custom toggle and/or the prefers-color-scheme query, we should also add the color-scheme property. This provides an opt-in to adapting browser UI elements such as scrollbars, form controls, and CSS system colors. Whereas accent-color lets us pick custom colors for a few elements, color-scheme requests that the browser adapt even more—such as asking text inputs and textareas to display as light or dark themed.

It’s recommended that this be applied to the :root element and that the values be listed in order of a site’s defaults. In other words, if we default to light but support dark, list light dark. If we default to dark but support light, list dark light. If we only support light or dark, just list that single value:

:root {
  color-scheme: light dark;
}

Defining color-scheme on Individual Elements

We can define color-scheme on individual elements as well, which is useful for selecting how to adapt a UI for a wider range of custom themes. For more on this, dive into how color-scheme works and learn about the related <meta> element.

Forced-color Modes

To round out the topic of color, there’s one more preference query and property pair to discuss. Some users on Windows require a “high contrast” theme, in which the OS forces a reduced palette to take the place of our defined colors. The palette fills in values for system colors, which replace things like background, text, button, and link colors, while styles like box-shadows are removed.

If we have critical styles that use color—such as product color swatches—we may need to use the forced-colors query alongside the force-color-adjust property. Given the following pairing, our original colors for the .swatch would be retained:

@media (forced-colors: active) {
  .swatch {
    forced-color-adjust: none;
  }
}

Don’t Force It

Forced colors should be used sparingly and only when the user experience is negatively impacted by swapping for the high contrast theme colors. If you’re unfamiliar with high contrast themes, learn about styling with forced colors.

Text Decoration Improvements

On the text decoration front, we now have the text-underline-offset property available, which allows us to adjust the position of the defined text-decoration from its original position. The text-decoration-thickness companion property allows us to control the stroke thickness of the text-decoration. Combined, these properties eliminate the hack of using a border or even a pseudo-element in order to style a link underline.

Color Fonts

Since 2018, the COLR font-format has had cross-browser support, affecting fonts that include a color palette. Recently, two related CSS properties were released that allow us to define some features for color fonts: font-palette and @font-palette-values. The first, font-palette, allows us to select which font-defined palette to use, and @font-palette-values allows us to define a custom palette. Learn more about color fonts and the CSS features in this article by Ollie Williams.

New and Updated Properties for Layout and Scroll Behavior

In this section, we’ll review properties that enhance the options for controlling layouts and how users can scroll a page.

gap

The gap property is now supported for both Grid and Flexbox. The gap shorthand property allows setting a “gap” of space between flex and grid children. A gap is only ever applied between children, not around them, which makes it different from applying margin. While gap has been available for Grid since its inception, support for Flexbox gap was lagging behind until it landed in Safari in April 2021, resulting in cross-browser compatibility.

Scroll Control Properties

Several scroll-related features have become stable, with one of them being scroll-snap. With scroll-snap, we can control orientation and “snap points” within a scrolling area. Example use cases include a native swiping experience through an image gallery, or creating a CSS-powered slide deck.

Not the Be-all

Though we can now create a range of scrolling effects with CSS, using CSS alone isn’t always the best option. JavaScript may still be required for fully accessible advanced scrolling interactions.

To use scroll snapping, we must create a wrapper that contains overflow and sets scroll-snap-type, which defines the scrolling behavior. Then, optionally, set scroll-snap-* properties on the children to define alignment and whether or not scrolling should stop on each child. An example, along with some important cautions for using scroll-snap, can be found in my scroll snap demo on SmolCSS.

Related to the scroll snap feature are the properties for scroll-margin and scroll-padding. These properties are unique, because they’re essentially tacked on to the related element only in the context of scroll position, while not affecting the layout position of the element.

My favorite use case is leveraging scroll-margin-top to provide space above an element that’s the target of an anchor link (such as https://example.com/#my-target-element). For example, the following code makes a nice addition to our reset styles and results in 2rem of space between the top of the viewport and the top of the element that’s being navigated to:

:target {
  scroll-margin-top: 2rem;
}

A property for controlling scroll behavior that immediately allowed me to remove some JavaScript from certain projects was scroll-behavior, which offers the value smooth. This results in smooth scrolling—such as when visiting in-page anchor links. The scroll-behavior property gained cross-browser support as of Safari 15.4, released March 2022.

Note that scroll-behavior: smooth can cause issues for folks with motion sensitivities. To enable it in a more accessible way, wrap it with the preference query prefers-reduced-motion: no-preference:

@media (prefers-reduced-motion: no-preference) {
  html {
    scroll-behavior: smooth;
  }
}

Yet another newly cross-browser scroll-related addition (as of Safari 16) is overscroll-behavior, which defines the behavior when the scroll position reaches the boundary of a contained scroll area. A practical and common scenario where this is useful is documentation sites that have a sidebar for navigation. Without the use of overscroll-behavior when users are scrolling within the navigation and reach the end of the list, the rest of the content on the page will begin to scroll. Now we can set overscroll-behavior: contain to prevent the remaining page area from scrolling.

Here’s a CodePen demo of this newly supported feature.

Dynamic Viewport Units

New viewport units have been released to address a problem that can cause scroll-related frustration. This update was prompted by the 100vh issue in Safari iOS, which ignores its dynamic UI elements and makes the value taller than the visible viewport. Now we can opt in to the preferred measurement by using the large, small, and dynamic viewport units. Bramus has an excellent overview of them, but in summary:

“Large” (lvh/lvw) assumes any dynamic UI element is retracted (hidden), resulting in more viewport space.

“Small” (svh/svw) assumes any dynamic UI element is expanded (visible), resulting in less viewport space.

“Dynamic” (dvh/dvw) recalculates the value as a dynamic UI element retracts and expands.

Modern Responsive Design Features

The CSS math functions min(), max() and clamp() became stable in April 2020. These functions accept multiple values, which makes them excellent companions for dynamically changing numeric values wherever numbers are accepted. This includes dimension properties as well as gradients, background-size, box-shadow, font-size, and more!

Here’s what they do:

min() asks the browser to use the smaller computed size.

max() asks the browser to use the larger computed size.

clamp() accepts three values, in order—a minimum, an ideal, and a maximum size—where the ideal value should include a dynamic measure such as a viewport unit or a percentage.

How do these relate to responsive design? We can use them to request that the browser move smoothly between acceptable values as the context changes, instead of creating multiple media queries to handle adjustments.

Here are three quick use cases:

.container {
  /* Grow no larger than 90vw */
  /* When 100% - 2rem computes to less than 90vw,
     use that calculation, resulting in a 1rem "gutter"
     on smaller viewports */
  width: min(90vw, 100%—2rem);
}

.container + .container {
  /* On taller viewports, expand up to 8vh,
     and on shorter viewports don't shrink less
     than 4rem */
  margin-top: max(8vh, 4rem);
}

.element {
  /* Allows padding up to 3rem for larger viewports,
     and reduced padding down to 1rem for smaller viewports,
     with the "ideal" being 5%, which is relative to the
     element's computed width */
  padding: clamp(1rem, 5%, 3rem);
}

Container Queries

One of the most exciting developments in all of CSS—and one which greatly affects what we can do with responsive design—is container queries. At the time of writing (April 2023), they’re available in Chrome/Edge 105+, Safari 16+, and Firefox 110+. A container query polyfill enables the essential features for Firefox and a significant number of older browsers.

So, what are container queries? Like media queries, they take the form of an at-rule. But they differ from media queries in that, instead of orchestrating changes at the viewport level, container queries allow any component or element to respond to a defined container’s width. (Currently, height is still being considered.) By using a container query, we’re able to change styles for a container’s children.

The following CodePen demo—from my in-depth primer on container queries—alters the text and colors as the containers transition through a range of sizes. (Widen and narrow the viewport to see these changes take place.)

The example above is considered a size-related container query. Also in the works for the container query specification are style-related queries. These will allow us to query style features of the query container—and most excitingly, this includes custom property values. Chrome 111 shipped with support for custom property values for style queries, which we can test in spec author Miriam Suzanne’s style query CodePen demo. (We cover container queries in more depth in the Practical Uses of Container Queries tutorial in this series.)

Container Units

Container units were also released alongside the @container feature. While there are several of these units available, cqi will probably be one of the most frequently used, as 1cqi corresponds to 1% of an element’s inline dimension (“width” for horizontal writing modes). This makes it an attractive upgrade for fluid typography techniques that previously used clamp() with viewport units—which becomes insufficient for varying element widths. Now we can have context-independent fluid typography using a definition like font-size: clamp(1rem, 1rem + 4cqi, 1.75rem). The container query polyfill (referenced in the “Container Queries” section above) also covers support for these units.

Catering for Text Zoom

When using fluid typography techniques, make sure that text can be zoomed to 200% of its original on-screen size to meet WCAG Success Criterion 1.4.4—Resize Text.

Media Query Range Syntax

There’s been an update to range syntax that’s now enabled for both container queries and media queries from Chrome/Edge 104, Firefox 63, and Safari 16.4. Older versions are also supported via the container query polyfill.

Range syntax means we can use mathematical comparison operators within queries, as shown in the following code:

/* Legacy syntax */
@media (min-width: 320px) and (max-width: 768px) {
}

/* New range syntax */
@media (320px <= width <= 768px) {
}

A Paradigm-shift for CSS Organization

CSS Cascade Layers provides a new at-rule (@layer) that assists authors in managing the cascade. @layer is a powerful tool for controlling specificity, which it does by orchestrating the order of rule sets. In an impressively coordinated cross-browser rollout, CSS Cascade Layers was made available from Chromium 99, Safari 15.4, and Firefox 97.

What does @layer do? It allows us, as authors, to be explicit about controlling two key elements of the “C” in CSS—the cascade. With @layer, the power is in our hands to pre-emptively mitigate conflicts between specificity and order of appearance—the last two priorities the browser considers when applying an element’s style.

A large codebase, or any project with multiple authors, can easily fall back on !important when specificity issues arise. With @layer, we can define groups of rule sets with a pre-determined order to reduce the likelihood of conflicts.

Consider this small example below, in which we create an ordering of layers, and then assign rules to those layers. Although the @baseline layer receives a p rule first, it still wins over the @reset layer, because the initial order of layers defines the order used. Additionally, specificity matters between layers, but a high-specificity rule in an earlier layer can be beaten out by a lower-specificity rule in a later layer.

Given the following layer order, <p class="red"> will render gray text:

@layer reset, baseline;

@layer baseline {
  p {
    color: gray;
  }
}

@layer reset {
  p.red {
    color: red;
  }
}

There are lots of deeper concepts to understand to be successful with layers. For more details, and for tips on how to incorporate them with Sass or third-party styles, check out my getting started guide for cascade layers.

Using New CSS Features

While most of the items we’ve reviewed have great evergreen browser support, we should do our own testing with devices and browsers that match our audience. Often, we can use these new additions as a progressive enhancement—which involves providing more stable fallbacks when features aren’t supported.

Sometimes, we can define a legacy property that accomplishes a close but less-perfect solution prior to the new property. The browser will use the latest defined property that it understands, so if it understands the modern property it will use it. For example, to begin using logical properties, we can do something like this:

.element {
  margin-left: 1rem;
  margin-inline-start: 1rem; /* Browsers that understand `margin-inline-start` will use this rule */
}

@supports

The @supports rule also enables feature detection within CSS. Here’s an example of using it for an aspect-ratio fallback:

.aspect-ratio-item {
  height: max(25vh, 15rem);
}

@supports (aspect-ratio: 1) {
  .aspect-ratio-item {
    aspect-ratio: 4/3;
    height: auto;
  }
}

We can also check for lack of support with @supports not (property: value), or for selector support with @supports selector(:selector(a)).

Supporting @supports

Support for @supports and @supports selector() varies, so use these options with caution! For the most robust solution, we should compare relative levels of support for the features we’re trying to use with support for @supports itself.

Further information about @supports features, along with more details about expected behavior, are available on the MDN @supports page.

Build Tools for Modern CSS Support

A popular build tool is PostCSS. It’s used with various plugins—particularly PostCSS Preset Env, which helps to polyfill various CSS features until they’re stable. This includes nesting, media query range syntax, and cascade layers.

Depending on what features we need supported, it may be worth looking into Lightning CSS, a PostCSS alternative that provides some polyfills and also the bonus built-in benefits of minification and autoprefixing.

Looking Towards the (Near) Future

CSS Color Module Level 5 includes a lot of color features, including the following, which have presently no or very minimal, partial support:

new color spaces

color-mix()

relative color syntax

Of these, I’m most excited about relative color syntax, which is a way to create a color from another one. For example, rgb(from red r g b / 50%) creates a red with 50% alpha transparency. This feature will also allow changing between color spaces, or adjusting parts of color spaces like the l in lch().

The Grid spec is still evolving, and an addition with partial-and-growing support is the long-awaited subgrid feature. At the time of writing, subgrid has support in Safari 16+ and Firefox 71+, with Chromium work in development.

Subgrid will allow nested grid containers to use a parent grid container’s rows and/or columns and match the size of the parent’s tracks. This can enable a site-wide grid that’s set once and that all page containers can use. Or more granularly, it can be used to align content within a grid of cards even with variable character lengths for titles and such. Rachel Andrew demonstrates the features of subgrid in this Smashing Meets talk.

These three specs don’t currently have any stability but are showing positive development:

@scope. This will allow us to apply styles within a scoped region without affecting children beyond that region, as explained by spec author Miriam Suzanne.

@scroll-timeline. This will allow animations that are triggered by scroll position, where scrolling advances or rewinds the animation timeline. Watch Bramus Van Damme demonstrate scroll-linked animations in his talk from CSS Café.

Nesting. This feature will allow native rule nesting—similar to what’s allowed in preprocessors like Sass—as explained in the editor’s draft.

Keeping Up with New CSS Features

To keep up with these and other changes to CSS, bookmark the Interop dashboard and have a look at the CSS Working Group’s GitHub projects and issues. If you’re a VS Code user, the webhint plugin also highlights CSS features when their support is less than what’s indicated in your package browserslist.

Chapter 2: Using the CSS :has() Selector for Scaling Reusable Components

Heralded as “the parent selector”, the :has() pseudo-class has far greater range than just styling an element’s ancestor. With its availability in Safari 15.4+ and Chromium 105+, and behind a flag in Firefox, now is a great time to become familiar with :has() and its use cases.

As a pseudo-class, the basic functionality of :has() is to style the element it’s attached to—otherwise known as the “target” element. This is similar to other pseudo-classes like :hover or :active, where a:hover is intended to style the <a> element in an active state.

However, :has() is also similar to :is(), :where(), and :not(), in that it accepts a a list of relative selectors within its parentheses. This allows :has() to create complex criteria to test against, making it a very powerful selector.

In this guide, we’ll learn how to create a range of selectors using :has()—from very basic ones to ones that enable us to build advanced components.

An Introduction to the :has() Selector

To get a feel for how :has() works, let’s look at an example of how to apply it. In the following selector, we’re testing if an <article> element has an <img> element as a child:

article:has(img) {}

A possible result of this selector is shown in the image below. Three article elements are shown, two containing images and both having a palegreen background and different padding from the one without an image.

The selector above will apply as long as an <img> element exists anywhere with the <article> element—whether as a direct child or as a descendant of other nested elements.

If we want to make sure the rule applies only if the <img> is a direct (un-nested) child of the <article> element, we can also include the child combinator:

article:has(> img) {}

The result of this change is shown in the image below. The same three cards are shown, but this time only the one where the image is a direct child of the <article> has the palegreen background and padding.

In both selectors, the styles we define are applied to the target element, which is the <article>. This is why folks often call :has() the “parent” selector: if certain elements exist in a certain way, their “parent” receives the assigned styles.

The Specificity of :has()

The :has() pseudo-class itself doesn’t add any specificity weight to the selector. Like :is() and :not(), the specificity of :has() is equal to the highest specificity selector in the selector list. For example, :has(#id, p, .class) will have the specificity afforded to an id. For a refresher on specificity, review the section on specificity in CSS Master, 3rd Edition.

We can also select a target element if it’s followed by a specific sibling element using the adjacent sibling combinator (+). In the following example, we’re selecting an <h1> element only if it’s directly followed by an <h2>:

h1:has(+ h2) {}

In the image below, two <article> elements are shown. In the first one, because the <h1> is followed by an <h2>, the <h1> has a palegreen background applied to it.

Using the general sibling combinator (~), we can check if a specific element is a sibling anywhere following the target. Here, we’re checking if there’s a <p> element somewhere as a sibling of the <ul>:

ul:has(~ p) {}

The image below shows two <article> elements, each containing an unordered list. The second article’s list is followed by a paragraph, so it has a palegreen background applied.

The selectors we’ve used so far have styled the target element attached to :has(), such as the <ul> in ul:has(~ p). Just as with regular selectors, our :has() selectors can be extended to be far more complex, such as setting styling conditions for elements not directly attached to the :has() selector.

In the following selector, the styles apply to any <p> elements that are siblings of an <h2> that itself has an <h3> as an adjacent sibling:

h2:has(+ h3) ~ p

In the image below, two <article> elements are shown. In the second, the paragraphs are styled with a palegreen background and an increased left margin, because the paragraphs are siblings of an <h2> followed by an <h3>.

Know Your Selectors!

We’ll be more successful using :has() if we have a good understanding of the available CSS selectors. MDN offers a concise overview of selectors, and I’ve written a two-part series on selectors with additional practical examples.

Remember, :has() can accept a list of selectors, which we can think of as OR conditions. Let’s select a paragraph if it includes <a> _or_ <strong> _or_ <em>:

p:has(a, strong, em) {}

In the image below, there are two paragraphs. Because the second paragraph contains a <strong> element, it has a palegreen background.

We can also chain :has() selectors to create AND conditions. In the following compound selector, we’re testing both that an <img> is the first child of the <article>, and that the <article> contains an <h1> followed by an <h2>:

article:has(> img:first-child):has(h1 + h2) {}

The image below shows three <article> elements. The second article has a palegreen background (along with other styling) because it contains both an image as a first child and an <h1> followed by an <h2>.

You can review all of these basic selector examples in this CodePen demo.

Limitations of the :has() Selector

To better understand what :has() can do, let’s quickly review the most impactful things it can’t do.

:has() Isn’t a Forgiving Selector

Unlike the other relational pseudo-class selectors :is() and :where(), selectors within :has() must all be considered valid by the browser for the rule to be applied. This is because :has() is not a “forgiving selector”. This means that, given :has(:invalid-selector, img, p), the whole rule will be thrown out and not applied—even if there is an <img> or a <p> present.

:has() Can’t Be Nested within Another :has()

Unfortunately, a selector like :has(p:has(a)) is invalid, because :has() isn’t allowed to be nested within another :has().

The good news is that, because of how the relational selector list works, we can write :has(p a) and achieve the same result—that is, of testing whether a paragraph containing an <a> element exists.

:has() Can’t Detect the Presence of Pseudo-elements

While :has() can be used to detect pseudo-classes like :checked, it can’t check for pseudo-elements due to performance implications. The spec notes that, since pseudo-elements only exist conditionally and must be attached to their ancestors, querying for pseudo-elements with :has() introduces cycles, given how the browser evaluates CSS rules.

Examples of pseudo-elements include:

::before

::after

::marker

::selection

Using :has() for Proactive Component Styling

Now that we understand the essential mechanics of using :has(), we’re going to use it to style some practical components. With :has(), we can provide proactive stying instead of relying on the application of classes.

Can We Use?

At the time of writing, the demos that follow will only work in Safari 15.4+ or Chrome/Edge 105+, or with the layout.css.has-selector.enabled flag enabled in Firefox 103+. Check Can I use… for the latest support information.

Button Layouts Using :has() and :not()

The combination of :has() with :not() is a powerful pairing of selectors.

Consider variants of a button component, such as text-only, text plus an icon, and icon-only. Depending on the presence of an SVG icon, and whether or not the button text is visible, the base <button> element should receive slightly different properties.

For reference, here’s the HTML, with the full SVG string omitted:

<button type="button">Text Button</button>
<button type="button">Text + Icon Button <svg /></button>
<button type="button"><svg /> Icon + Text button</button>
<button type="button">
  <svg /><span class="inclusively-hidden">Icon only button</span>
</button>

In the past, we might have managed the difference by adding separate classes of .btn for base styles, and .icon-btn for the icon-only button.

With :has(), we can use simplified, semantic markup and modify the base styles by testing for the presence of certain children. In order to achieve our full range of styles, we’ll define each of the following four selectors:

/* Has any svg icon */
button:has(svg) {}

/* Doesn't have an svg icon */
button:not(:has(svg)) {}

/* Assume icon-only button due to hidden label */
button:has(.inclusively-hidden) {}

/* Assume not an icon-only button due to visible text */
button:not(:has(.inclusively-hidden)) {}

By combining :has() with :not(), we can test both positive and negative matches, resulting in more well-rounded selectors that cover all permutations of a component.

This CodePen demo shows examples of these options for styling a button component with :has().

Nesting :not() and :has()

There’s an important distinction between nesting :not() in :has() (:has(:not())) and nesting :has() in :not() (:not(:has())), as Manuel Matuzović explains. It’s well worth reading.

Linking Sibling Styles with State Selection

Combining :has() with the selection of element styles can lend to some fun effects, previously only possible by intercepting actions with JavaScript.

Consider a standard navigation list:

<nav>
  <ul>
    <li><a href="">Lorem</a></li>
    <li><a href="">Minus</a></li>
    <li><a href="">Eos</a></li>
    <li><a href="">Minus</a></li>
    <li><a href="">Eligendi</a></li>
  </ul>
</nav>

Our goal is to provide a simple effect: lower the opacity of links that aren’t currently in focus or being hovered over, as pictured below.

Here’s some CSS:

nav:has(:hover, :focus) a:not(:hover, :focus) {
  opacity: 0.45;
}

The logic in the code above is that, when a <nav> link is being hovered over or is in focus, apply the opacity styles to the links that aren’t in focus or being hovered over.

Here’s a CodePen demo of this hover effect.

Preventing Page Scroll for an Open Dialog

We haven’t yet discussed attribute selectors. Let’s set styles around a <dialog> element based on whether or not it’s visible, by using the [open] attribute selector.

Coupled with :has(), we can make interesting use of this selector: we’ll prevent background page scroll when the dialog is open. The entire rule is as follows:

:root:has(dialog[open]) {
  overflow: hidden;
}

This CodePen demo provides a live example of the code above.

Ready for a Dialog?

Have you heard of the <dialog> element? Evergreen browser support was achieved for this element in 2022, so it’s a good idea to learn what it’s for and how to use it.

Alternate Grid Layouts for a Hero Component

Hero is a term borrowed from newsprint to describe the header section that introduces a page template. Using :has(), we can create a flexible component dependent only on the presence or lack of an <img> element.

The two header variations we’ll explore have the following HTML structure. The possibility of an image is anticipated in the HTML comment, where it would be the first child of the <header> element:

<header>
  <!-- possible image -->
  <div class="content">
    <h1>Welcome to our site</h1>
    <p>Lorem ipsum dolor sit amet consectetur, adipisicing elit.</p>
    <ul class="cta-row">
      <li><a href="">Contact us</a></li>
      <li><a href="">Buy now</a></li>
    </ul>
  </div>
</header>

When no image is present, we’ll want to achieve centered content with slightly larger text, as pictured below.

When there’s an image, we’ll use Grid styling to make it visually appear as a background image and layer the content over it. A bit more Grid manipulation will provide an offset appearance on larger viewports and set minimum and maximum widths for the content area.

The first lesson of this demo is not to use :has() when a regular complex selector will do. In this case, we don’t need to use :has() to check for the presence of an img:first-child before we create the rule for the image. The natural behavior of CSS will resolve that for us: if it doesn’t find this element, there’s nothing to style anyway. So, we can set up header > img:first-child, which will only be used when it finds a valid match.

Similarly, we can use complex selectors for the following designations as well:

/* Applies when the header doesn't start with an image */
header > .content:first-child {}
header > .content:first-child .cta-row {}

/* Applies when the header starts with an image */
header > img:first-child + .content {}

This leaves us only needing one :has() rule, since we want to style the parent <header> only when there’s an img:first-child:

/* Creates new grid layout when there's an image */
header:has(> img:first-child) {}

As a bonus, we can use use :has() to count link or button elements and change the color of either the “only child” _or_ the second item if there are exactly two items. The assumption is that our guidelines specify that we either include a single primary call to action, or, given a secondary and primary action, that we list the primary action last. Consequently, more than three items would result in none receiving the “primary” styling:

.cta-row:has(> :only-child, > :nth-child(2)) :last-child a {}

This CodePen demo provides a live example of the code above.

Modifying a Grid Layout

For another example of modifying a parent’s grid layout based on the presence of certain children, refer to the “New and Extended Selectors” section of the What You’ve Missed in CSS Land tutorial in this series. It reviews lots of new features that have landed in CSS in recent times.

Change Grid Layout Using Quantity Queries

The trick of using :nth-child in the hero example above is what has come to be called a “quantity query”. First developed by Heydon Pickering way before :has() existed, quantity queries originally leveraged :nth-last-child as a bit of a hack to count and style precise numbers of elements.

Using :has(), we can style based on quantities of child elements in a few different ways:

ul:has(> :nth-child(2)) selects the ul if it has two or more children

ul:has(> :nth-child(2):last-child) selects the ul if it has exactly two children

For a few more examples, check out this article from Bramus Van Damme on styling a parent based on its number of children.

With quantity queries in mind, let’s consider an image grid. Based on the number of items and whether there’s an odd or even number of them, we’ll update:

the image aspect ratios

the number of columns and rows spanned by list items

the number of columns in the grid

The image below shows how our grid layout adapts to differing numbers of images.

The HTML for our grid is as simple as can be:

<ul class="image-grid">
  <li><img src="image.jpg" /></li>
  <!-- more list items containing images -->
</ul>

As for our .image-grid class, the important definition is for grid-template-columns, where we’ve defined an equal-sized column grid that accepts a custom property to change the number of grid columns. The default value is set at 2:

.image-grid {
  display: grid;
  grid-template-columns: repeat(var(--image-grid-cols, 2), 1fr);
}

To ensure the images are equally sized, we give them an aspect-ratio. To seamlessly fill the grid tracks, we use width and height of 100% as well as object-fit: cover, which leads to the lossless scaling-up of the images to fill the cell:

.image-grid img {
  aspect-ratio: 3/2;
  object-fit: cover;
  width: 100%;
  height: 100%;
}

As things currently stand, the first consequence of our layout occurs when we have an odd number of items, because one grid cell will be left empty.

We’ll address that with two :has() rules to affect the li:first-child and have it span all grid columns, and also reduce the aspect ratio to control the amount of vertical space it uses.

Our quantity query checks that an “odd” list item is the :last-child of our grid, meaning that, in total, we have an odd number of items. Then we expand the selectors to target the first list item and also its image:

.image-grid:has(li:nth-child(odd):last-child) li:first-child {
  grid-column: 1/-1;
}

.image-grid:has(li:nth-child(odd):last-child) li:first-child img {
  aspect-ratio: 3/1;
}

Being Explicit with :has()

If we didn’t include the li qualifier, the selector would always match, because the single <img> within the list items would make the :has() check valid. It’s best to be explicit about our tests when using :has()!

Overriding the Cascade with :where()

If we build up a selector with multiple pseudo-class clauses and combinators, we’ll also be increasing the specificity. If we’re having trouble overriding rules later in the cascade, we can wrap all or parts of the selector with :where(), which lowers specificity to zero.

Next, we’d like to further modify the grid when there are greater numbers of items present. To start, if there are at least five items, we’ll change the number of columns to 3. This leaves us with two empty cells for exactly five items, so in that case we’ll adjust the first item to span one grid column and two grid rows:

/* Applies to grids with >= 5 items */
.image-grid:has(li:nth-child(5)) {
  --image-grid-cols: 3;
}

/* Applies to grids of exactly 5 items */
.image-grid:has(li:nth-child(5):last-child) li:first-child {
  grid-column: span 1;
  grid-row: span 2;
}

To round out our image grid styles, we’ll accommodate one more arrangement we expect to have—which is exactly six items. This time, we’ll update the first item to span two grid columns and two grid rows:

.image-grid:has(li:nth-child(6):last-child) li:first-child {
  grid-column: span 2;
  grid-row: span 2;
}

Safely Using :has() Today

As in the image grid example, :has() can be a progressive enhancement—meaning that, if the unique arrangement styles don’t apply due to lack of browser support for :has(), the grid will still be functional, just less refined.

Additionally, remember that rules including :has() as part of a selector list may be thrown out entirely by a non-supporting browser. To avoid that but still include the :has() selector, wrap it within :is() or :where(), which are forgiving of selector lists and allow selectors otherwise seen as invalid by the browser:

.card h3,
:is(article h2:has(+ .byline) {
  /* ...styles */
}

If our plan for using :has() requires full support to work, we might try using the &#64;supports feature. This will query the browser to find out if :has() is supported. If it is, the rules will apply. This gives us the opportunity to more clearly separate our fallback styles from our preferred styles that use :has():

/* Fallback styles when :has() isn't supported */

&#64;supports selector(:has(+ *)) {
  /* Styles when :has() is supported */
}

Dealing with Partial Browser Support

Because Firefox has been rolling out support for :has() in parts, Bramus Van Damme cautions that, when we’re using the @supports method, we should include a relative selector to check if full support is available for all types of :has() selectors.

The Future of :has()

While we’ve seen that :has() is very useful in and of itself, it’s also an excellent sidekick to other modern features.

For example, any time a count of something could affect a layout—for example, given a wider or narrower parent—there will be a use case for :has() combined with container size queries!

When container style queries are also available, use of :has() will help with toggling custom properties. Those might be based on count or the presence of children—such as a logo in a navigation block. We’ll then be able to use those custom property values with the style query.

A bit further into the future will be CSS scope, at which time :has() will allow scoping to be even more precise.

Given that :has() is a brand-new property, its “best practices” are yet to be established, and there are endless opportunities to explore. Grab a browser that supports it and start experimenting!

Additional Resources for Learning :has()

At the time of writing, there’s only been partial browser support for :has() for less than a year, so we can anticipate even more resources becoming available soon! For now, the following resources provide further excellent examples and explanations:

“Using :has() as a CSS Parent Selector and much more”, by Jen Simmons

“:has(): the family selector”, by Jhey Tompkins

“CSS :has()”, by Estelle Weyl

“Detect “islands of elements” with the same class, thanks to CSS :has()”, by Bramus Van Damme

“How the CSS :is, :where and :has Pseudo-class Selectors Work”, by Craig Buckler

Chapter 3: Practical Uses of Container Queries

Container queries enable the styling of elements based on available space. They allow us to build resilient components that are adaptable across infinite, unknown layout arrangements. This is in contrast to viewport media queries, which require style changes to be orchestrated at the page level. Let’s build up our knowledge of how container queries work by examining practical use cases and creating size-agnostic components.

We’ll learn about making cards fully responsive to any layout, how to create truly fluid typography, and how to use Grid with container queries to adapt form and navigation arrangements. We’ll also consider accessibility within the solutions, and see how container queries can actually help improve accessibility.

Container Queries vs Viewport Media Queries

We’re most likely familiar with responsive design and layouts that respond to the viewport, like the one pictured below.

In viewport responsive design, it’s popular to couple a layout grid to breakpoints. Those breakpoints usually relate to simplified device sizes, as pictured above, such as mobile, tablet and desktop.

Importantly, these breakpoints don’t necessarily consider individual elements and components on the screen, focusing more on how components flow into the predefined grid. Sometimes larger components like navigation will morph separately from the grid, but typically they’ll use the global breakpoints.

Let’s contrast viewport responsive design with container responsive design.

Pictured below are variations of a card component. These three variations are rendered using container queries, which are completely independent of the viewport. The card styles are adjusted based on available space.

Don’t worry if the difference between viewport responsive design and container responsive design isn’t quite clear yet. We’ll learn by completing practical examples of using container queries.

Supporting Older Browsers

Container queries are supported in all evergreen browsers as of the release of Firefox 110. To extend support for older browsers, a polyfill is available, which we’ll review at the end of this tutorial.

First, let’s learn the syntax for creating container queries.

Defining Container Queries

The first step is to designate that an element is a container by using the container-type property. The most fundamental and currently best-supported value is inline-size, which in a horizontal writing mode equates to the element’s width. So this definition means that we intend to support a query based on the .container element’s inline size:

.container {
  container-type: inline-size;
}

Adding a container-type to an element officially designates it as a container.

Next, we’ll create the actual container query using the container at-rule, which accepts a parameter that will look familiar if we’ve ever assigned media queries.

The following @container rule says that, when an <h2> is within a container that’s 40ch wide or greater, its color should be blue:

@container (min-width: 40ch) {
  h2 {
    color: blue;
  }
}

A Container Can’t Style Itself

The rules we place within a container query won’t affect the styling of the container itself, but only its children. This means we can’t style a container from within its own query. But we can style a container with a container query if that container has an ancestor that’s also defined as a container.

To accommodate more than horizontal writing modes, we can update our query to use the logical syntax of inline-size, rather than base the query strictly on the “width” of a container:

@container (inline-size > 40ch) {
  h2 {
    color: blue;
  }
}

There are more options that just inline-size, including block-size and aspect-ratio. To learn more about the available size container queries and how to use them, check out the official spec. And for more on logical syntax, see the “Logical Properties” section of the What You’ve Missed in CSS Land tutorial in this series.

Upgrading a Card Component

If we wanted to build a card component without container queries, we could create variations by way of modifier classes. And for variations of the card size, those modifiers could be tied to breakpoints. That means that, if the card had the modifier, it would be allowed to change when the viewport width fell within that breakpoint.

The following image shows three card size variations and their respective modifier classes, where the top .card would be considered the “default”.

Here’s a CodePen demo of the viewport-responsive cards pictured above. (Resize the viewport to see the various styles kick in.)

Let’s now switch our perspective and think how we would handle these card variations using container queries.

We’ll still make the top card the default, which really means it will apply at the narrowest widths. This will also be the fallback version where container queries aren’t supported—an important scenario to consider until container queries have reached support maturity.

We’ll set layout for the middle-sized card (with the horizontal orientation) to activate when the container has a width of 350px or greater.

Finally, we’ll set the card layout to use its image as the background when the container has a width of 600px or greater.

This creates a card element that’s adaptable based on the size of the card containers. Play around with the this CodePen demo to see this in action. (Note the “Resize me!” handle in the bottom right corner.)

Applying Container Query Styles to Flexbox and Grid Items

The previous demonstration placed cards within a Flexbox-based layout. This is a good time to address an important point that also applies to elements contained in a Grid layout.

Remember that a container query allows for the styling of a container’s children, but not of the container itself, but we really want to be able to change the layout properties of our card itself as the available space changes. If we try to define div.card as a container, we won’t be able to change its direct properties like display.

In order for the styles of our card to respond to its width—whether it’s a flex or grid item—we’ll need to nest it inside a direct ancestor that’s set as a container. For the card layout demo, that means we’ve nested each card as a <div> inside a list item:

<ul class="card-list">
  <li>
    <div class="card"></div>
  </li>
</ul>

Then, the list item—which is also a flex item—is defined as the container. This enables the styles of the card itself to respond to changes in its width due to the flex behavior:

.card-list li {
  container-type: inline-size;
}

If we defined the ul.card-list as a container, the card-related container queries would be responding to the change in the entire list width.

Container Queries and Fluid Typography

Let’s stay with our card example a little longer. We can push it further by introducing container-based “fluid typography”.

Fluid typography involves use of the clamp() function, which allow us to define three values in order to create a range of accepted sizes. The middle, “ideal” value needs to be dynamic in order to cause a change between the low and high ends of the range.

Previously, we may have tried the technique of including the vw (viewport width) unit, such as:

font-size: clamp(1rem, 5vw, 2.5rem);

In contrast, the rule applied below affects the base card font size by defining a dynamic value using the cqi unit, meaning “container query inline”:

.card {
  font-size:
    clamp(
      0.9rem,
      4cqi,
      1.25rem
    );
}

In this video clip, watch the headlines and body text adjust in size proportionately to the width of individual cards.

This CodePen demo shows font resizing with clamp() in action. (Once again, make use of the “Resize me!” handle to adapt the layout without needing to resize the browser or CodePen window.)

Rem for Accessibility

It’s important to include a rem value for the minimum and maximum clamp values so that the computed font-size appropriately responds to user zoom, which is a critical accessibility consideration. Always test implementations to ensure that a user can increase the displayed type to 200% of its original size.

Shipping Resilient Components

It might seem quite bold to say, but container queries allow us to apply the “build once, deploy everywhere” methodology. As a design systems engineer, it really appeals to me to be able to ship design system components with built-in layout and style variants.

To demonstrate this idea, the image below shows a subscription form. Container queries have been applied, and the component is shown in a full-width area within the narrower space of the sidebar and at the medium width within the content area.

Grid is an excellent companion for composing these variants. Using grid template areas, we’re able to rearrange the form sections without the need for extra markup. In this subscription form component, I’ve identified three grid areas: legend, field, and submit. The overlays added in this video show how the layout changes across the variants.

In the code for the subscription form, the <form> element has a class subscription-form and is set as the container. A nested <div> with a class of form-content is what our container queries will then modify:

.subscription-form {
  container-type: inline-size;
}

At the narrowest default width, the form is a simple grid stack with a gap applied:

.form__content {
  display: grid;
  gap: 1rem;
}

The first container query for the mid-sized layout is doing the heavy lifting of creating the grid template and assigning all of the elements to the grid areas:

@container (min-width: 375px) {
  .form__content {
    grid-template-areas:
            "legend field"
            "legend submit";
    align-items: center;
    column-gap: 2rem;
  }

  legend {
    grid-area: legend;
  }

  .form-group {
    grid-area: field;
  }

  button {
    grid-area: submit;
  }
}

The second, final container query then only has to adjust the grid-template-areas definition to horizontally line up the areas. Only one additional tweak is needed, which is to realign the Subscribe button to the end position, which visually aligns it to the email input:

@container (min-width: 700px) {
  .form__content {
    grid-template-areas:
            "legend field submit";
  }

  button {
    align-self: end;
  }
}

This video clip shows the subscription form layout adjusting as the width is reduced.

This CodePen demo provides a live example of this form form layout (with “Resize me!” handles).

Coordinating the Visibility of Component Parts

Let’s move beyond rearranging layouts and consider using container queries to coordinate the visibility of component parts to create variations.

The image below shows a pagination component with previous and next page links and a pagination list. The <nav> element that contains this pagination will be our container, and we’ll be modifying the visibility of the child elements as it reduces in width, creating the variations shown below.

As with the queries we’ve created so far, we’ll create our narrowest version as the default, which displays a label of the pagination status instead of individual links. Our first container query will be responsible for making the text for previous and next labels visible. In the second query, we’ll swap the simple label for the list of page links.

In our CSS, the default state removes the pagination list from display, but inclusively hides the Previous and Next labels so that they remain accessible for identifying what appears as icon buttons at this stage:

.pagination-container {
  container-type: inline-size;
}

.pagination {
  display: none;
}

/* Inclusively hidden */
.pagination-nav__label {
  height: 1px;
  overflow: hidden;
  position: absolute;
}

Then the first container query simply brings those labels into view again:

@container (min-width: 300px) {
  .pagination-nav__label {
    height: auto;
    overflow: unset;
    position: unset;
  }
}

Finally, the second and last container query sets the display property of the pagination list to grid and hides away its simple label:

@container (min-width: 500px) {
  .pagination {
    display: grid;
  }

  .pagination-label {
    display: none;
  }
}

In this video clip, the pagination shrinks through each stage of container queries and the coordinated visibility results in a flexible, extensible pagination component.

Here’s a CodePen demo of our navigation element.

Using Named Containers for Targeted Queries

We can extend the idea of coordinating visibility by using multiple named containers for maximum flexibility.

A product site navigation is an excellent component for demonstrating this concept. We’ll be defining two containers and using the second available container property of container-name to be more explicit about attaching query styles.

The outer elements will be a container named “navigation”. And the page navigation list area will be a container named “menu”.

This will enable us to switch the navigation list to a button that will trigger a drop-down on a reduced width, based on the menu container. And on our narrowest width, we’ll reduce the company brand to just the logomark.

In our code, we assign both the container-type and the container-name, where the container-name is an unquoted string:

.navigation {
  container-type: inline-size;
  container-name: navigation;
}

.navigation__menu {
  container-type: inline-size;
  container-name: menu;
}

Now, to enable the menu container to have an independent variable width, it’s been placed within a grid column with a width of 1fr. This means it will take up all available space remaining in the <nav> after other menu item dimensions are accounted for. The result is that it will be able to adjust separately from the outer navigation container:

.navigation {
  display: grid;
  grid-template-columns: auto 1fr auto;
}

Not shown in this snippet is the hiding of the brand name, but we use the same technique as for the pagination example (inclusively hiding it so it remains an accessible label).

We’ll assign two container queries, where each is scoped to a named container.

First, we create a query against the navigation container to make the brand name visible when there’s enough space:

@container navigation (min-width: 400px) {
  .navigation__brand span {
    height: auto;
    overflow: unset;
    position: unset;
  }
}

Second, we define the menu container query, which hides the button responsible for the drop-down and displays the navigation list instead:

@container menu (min-width: 450px) {
  .navigation__menu button {
    display: none;
  }

  .navigation__menu ul {
    display: flex;
    align-items: center;
    gap: 0.5rem;
  }
}

This video clip showcases each phase of the navigation: the widest variation with all parts visible; the reduction of the menu area and the appearance of the drop-down button; and the removal of the brand name. As opposed to viewport media queries, this method means that the navigation will automatically be flexible across these states in response to available horizontal space.

These navigation components, powered by container queries, will be resilient if a longer brand name is in place, or if more actions besides Login and Sign Up are added. And the adaptations will also assist in addressing issues that can arise due to translations, which may alter word length, although further adjustment may be necessary.

This CodePen demo shows our navigation component in action.

Shorthand Syntax

We can use the container shorthand property to define both the container name and the type. The name needs to be listed first, followed by a forward slash (/) that separates it from the container type:

.navigation {
  container: navigation / inline-size;
}

Improving Accessibility with Container Queries

Surprisingly, container queries can help improve accessibility. They enable independent components to better adapt to reflow, which is WCAG success criterion 1.4.10.

Reflow is the term for supporting desktop zoom up to 400%: large areas of content should reflow into a single column and critical functionality shouldn’t be lost.

Reflow Accessibility Test

There’s a handy rule for checking reflow accessibility. If a screen has a resolution of 1,280 pixels in width, at 400% zoom, the viewport content width should be equal to a width of 320 CSS pixels.

Now, there’s no zoom-related media query, but because a zoom level of 400% computes to a reduced pixel count around 320px (see the tip above), both media and container queries that affect that pixel zone will have an impact.

This video shows how our navigation component adapts across zoom levels up to 400%. As we can see, the container queries allow the component to gracefully adjust and meet the resizing text criterion.

In contrast to this, viewport media queries often run into trouble. Because media queries are orchestrated at a page level, issues that occur outside of breakpoints aren’t always addressed. This can cause overflow and overlap issues, which result in failing this accessibility criterion.

Container Style Queries

So far, we’ve been talking about container queries based on size. Another part of the spec that’s beginning to roll out to browsers is the ability to query based on style features of a container. This means creating a query based on the computed value of either a custom property or other CSS property. The ability to query for values based on custom properties is available in Chromium 111+:

@container style(--theme: dark) {}

@container style(background-color: blue) {}

Extra style

When defining a style query, we need to add the extra style designation alongside the definition.

The custom property syntax enables a unique way to define rule sets by toggling groups of styles using our own naming conventions, as shown for style(--theme: dark).

And yes—we can combine style and size container queries!

Hop over to Una’s blog to learn more about the possibilities of style queries.

Support for Container Queries

I hope you’re hyped about container queries. The good news is we can start using them today! As noted earlier, size container query support is already available for evergreen browsers.

Google Chrome labs developed a container queries polyfill that covers the essential uses and extends support as far back as these browsers:

Firefox 69+

Chrome 79+

Edge 79+

Safari 13.4+

Note that the polyfill doesn’t support style queries.

The Future of Container Queries

The age of container queries has arrived! Although we’ve reviewed a variety of use cases in this tutorial, there are many unexplored ways to use them.

Not only is experimentation useful for personal learning, but we can still influence the specification and help report issues. Review feedback instructions in the “Status of this document” section.

I expect the style query feature will lead to extremely creative and useful solutions in CSS. I also anticipate we’ll see very clever uses for container relative units. As our collective understanding of container queries evolves, they’ll make their way into current and future frameworks, libraries, and design systems.

Container query possibilities really have no boundaries, as demonstrated in this CodePen demo by Ashraff Hathibelagal, which animates a container’s width in order to trigger other animations.

Additionally, a related feature that’s been released alongside container queries is media query range syntax. This enables the use of math operators within both standard viewport media queries and container queries. Range syntax is also required when using logical syntax for container queries (inline-size and block-size):

/* Legacy syntax */
@media (min-width: 320px) and (max-width: 768px)

/* New range syntax */
@media (320px <= width <= 768px)

/* Use in container queries */
@container (inline-size >= 320px)

The future certainly looks bright for container queries!

Additional Resources

MDN documentation for container queries

my own deep-dive primer on container queries

Ahmad Shadeed’s practical applications of container queries

spec author Miriam Suzanne’s CodePen collection of examples

Chapter 4: Responsive CSS Layout without Media Queries

Broadly speaking, the classic methodology of “responsive design” is focused around specific device breakpoints. That is, layouts are designed to adjust principally to fit nicely into the viewports of mobile, tablet and desktop devices.

A Responsive History

Technically, responsive design has been possible since the dawn of the Web, because, by default, browsers allow HTML to reflow as the viewport changes size. However, early web design tended to focus on fixed-width layouts designed for a small range of screen sizes, which became increasingly problematic as more screen sizes were developed, including those of mobile and tablet devices.

As the possibilities for CSS developed, the responsive design movement arose as an antidote to inflexible layouts, allowing layouts to better adapt to the wide range of devices they might appear on.

We don’t have to look further than our household or workplace to realize that there are far more than three convenient size ranges where our website content might be displayed. Some examples include smart devices like watches, refrigerators, or TVs. And then there are dual-screen tablets and phones. With modern CSS, our toolbox for handling the flexible, unknown canvas of the Web is filled with new and updated responsive tools.

While the dawn of responsive design was focused on media queries, we now have other ways to create intrinsically responsive layouts. That means we can base a layout around the content displayed, rather than attempt to orchestrate what changes should occur based on viewport dimensions.

In fact, the viewport is often an incorrect or poor measure by which to estimate how to adjust a layout. The viewport dimensions inform us of total visible screen size, but page layout is only one consideration; we also need to ship resilient, flexible components. Looking to the viewport for resizing components can require layers of adjustment that become fragile when we expand how and where components are used. Viewport-based media queries can also cause frustration and challenges with arranging and resizing variable content within layouts and components.

Let’s learn about the tools now available for addressing these concerns and building scalable layouts. We’ll explore responsive spacing, sizing, layout grids, and components. We’ll better understand how to use dynamic units, math functions, Flexbox and Grid to write future-proof, intrinsically responsive styles that are independent of viewport media queries.

Responsive Spacing

Spacing between and around elements can quite literally make or break a layout. Over the years, popular frameworks have provided quite strict spacing scales based on pixels and bound to viewport media queries.

The following code is an example of spacing that’s bound to pixels and the viewport:

.pt-1 { padding-top: 8px; }
.pt-2 { padding-top: 16px; }
.pt-3 { padding-top: 24px; }

/* "Medium (md)" breakpoint */
@media (min-width: 768px) {
  .pt-md-1 { padding-top: 8px; }
  .pt-md-2 { padding-top: 16px; }
  .pt-md-3 { padding-top: 24px; }
}

/* "Large (lg)" breakpoint */
@media (min-width: 1024px) {
  .pt-lg-1 { padding-top: 8px; }
  .pt-lg-2 { padding-top: 16px; }
  .pt-lg-3 { padding-top: 24px; }
}

This example application of those classes assumes we want to increase space for larger viewports, which then requires multiple classes to change the padding when there’s more or less viewport space:

<div class="pt-1 pt-md-2 pt-lg-3">

While this convention is still a valid method, we can do better by leveraging more dynamic units that are also more localized to content sizes.

Properties Used for Spacing

There are three properties used for applying spacing around and between elements:

padding: spacing for an individual element’s box

margin: spacing between elements

gap: spacing between elements specifically in a Grid or Flexbox context

Padding and clamp()

Let’s start with padding and a content-relative unit that has existed in CSS for a very long time: percents. When we set a percentage value for padding, it’s based on the element’s width. We can use that to our advantage to set one rule for padding that automatically is larger when the element is wider, and smaller when it’s narrower.

In some cases, we may want to use it as a single value:

.element {
  padding: 5%;
}

An issue with this technique is that, at extreme sizes, the padding can become too large or too small. We can stop that by adding some guardrails with the CSS math function clamp(). This function accepts three values: a minimum, ideal, and maximum. When assigning the ideal value, we use a dynamic unit—such as a percentage—so that the browser can adjust the computed padding value as the element resizes. The padding will never be less than the minimum, or exceed the maximum:

.element {
  padding: clamp(1rem, 5%, 3rem);
}

I like using this technique for components such as cards where I anticipate them being displayed at a variety of widths within a flexible grid. That way, the cards have a longer line-length at smaller sizes when the padding reduces, and more breathing room around the content at larger sizes. Since the padding values aren’t bound to viewport media queries, the padding also feels contextually appropriate to the various card sizes. This is great. Imagine, for example, that we’re placing a card in a narrow sidebar for desktop devices: if the card’s padding is associated with a breakpoint, the padding may look too large in that narrow space.

The image below shows examples of padding around content that’s responsive to the space occupied by various cards.

This CodePen demo shows a live example of clamp() governing the amount of content padding.

A downside of this method arises if we’re mixing multiple page layout grids and we want to ensure text is aligned visually. If that’s the priority, it may be better to use an alternative to the clamp()-based technique, demonstrated by Utopia, a tool for creating fluid, responsive designs. It uses viewport units so that the spacing resizes consistently across elements and isn’t tied to the element’s size. Check out the Utopia spacing calculator for how to achieve viewport-relative spacing that can be applied to any property in a way that still removes reliance on media queries.

Margin and min()

A best practice I like to stick to more often than not with margin is to apply it only in one direction. Typically, I set up my rules to only apply to margin-top or the logical property equivalent margin-block-start. The reason for this practice of a one-way margin is to avoid extra margin below elements—which can have the side effect of an unwanted increase in space. Extra bottom margin may also appear as visual space at the end of a contained section of page content with a background, or it may push down the bottom of a content component like a card.

For the margin-block-start value, I like to provide the browser a couple of options by way of the min() math function. This function takes two or more values, and the browser will apply the one with the smallest computed value.

Given the following rule, the browser will use 4rem as long as it computes to smaller than 8vh. This means that, on a device with a short viewport, the browser may flip to using the computed value of 8vh once it becomes smaller than 4rem. The listed order doesn’t matter, as the browser will re-evaluate upon any resize event:

.element {
  margin-block-start: min(4rem, 8vh);
}

This approach is also useful for dealing with high desktop zoom levels—an accessibility consideration for users with low vision. At a high zoom level, 8vh will shorten the scroll length fairly significantly between elements so that more can fit in the viewport.

Here’s a quick set of utility classes for setting up various spacing sizes. We’re using the term “block” due to the logical property direction, and “flow” is borrowed from Andy Bell’s generic rule for vertical layout spacing:

:root {
  --block-flow-sm: min(2rem, 4vh);
  --block-flow-md: min(4rem, 8vh);
  --block-flow-lg: min(8rem, 16vh);
}

.block-flow > * + * {
  margin-block-start: var(--block-flow, var(--block-flow-md) );
}

.block-flow-sm {
  --block-flow: var(--block-flow-sm);
}

.block-flow-lg {
  --block-flow: var(--block-flow-lg);
}

The rules are set up to leverage custom properties for even more flexibility. The base rule defaults to the “medium” size (--block-flow-md) but also allows an override by setting --block-flow. The override is set by the other two utilities. If we wanted to change the spacing for an individual element, we could use an inline style, or we could create additional utility rules.

This CodePen demo provides a live example of creating responsive margin using min() and vh.

Gap and Layout Spacing

When using Flexbox or Grid layout, we have access to the gap property. Although it exists between elements, it differs from margin in a few key ways:

Its default value is 0, with no browser defaults to override.

It can be set for the “row” or “column” axis independently.

It’s only applied internally to a flex or grid layout, not outside a flex or grid container.

It’s only reserved when there are multiple elements, and only in the axis needed.

It can’t be overridden for a particular item, being uniform between items on a given axis.

Gap Shorthand

The gap shorthand accepts either a single value—which sets both row and column space—or two values, where the first is for rows and the second for columns.

These differences from margin allow us to offload some spacing decisions to the browser. We have to manage which direction margin applies, and possibly use media queries to adjust it, such as when there’s a change in layout orientation. With gap, we only need to provide one or two values (two if we want row gap and column gap to be different) and the browser will only include that space in the layout when necessary.

For components that closely set text content as flex or grid items—such as navigation menus—we may want to use more static values, or low-range clamp() scales such as used by Utopia.

However, when defining larger page layouts with either Flexbox or Grid, we may consider setting gap with a method that’s dynamic in relation to the viewport.

Let’s revisit clamp(), but this time using a responsive value of vmax, which is the “viewport maximum” unit. If the viewport width is greater than the height, then 1vmax will equal 1% of the width. If the viewport height is greater than the width, then 1vmax will equal 1% of the height.

Now we’ll set up similar utility classes as we did for padding, except that we’ll define these specifically as “layout” gap values to indicate they should be used for structural components like layout grids.

As a reminder, the use of clamp() allows us to provide boundaries to prevent space becoming either too small or too large. When using vmax, we’re basically asking the browser to create more space in relation to the more roomy axis, which enables adapting to different orientations of devices as well:

:root {
  --layout-gap-sm: clamp(1rem,    3vmax, 1.5rem);
  --layout-gap-md: clamp(1.5rem,  6vmax,   3rem);
  --layout-gap-lg: clamp(1.75rem, 8vmax, 4.5rem);
}

.layout-gap {
  gap: var(--layout-gap, var(--layout-gap-md));
}

.layout-gap-sm {
  --layout-gap: var(--layout-gap-sm);
}

.layout-gap-lg {
  --layout-gap: var(--layout-gap-lg);
}

Here’s a CodePen demo that demonstrates how to create a responsive gap with clamp() and vmax.

Responsive Sizing

One way to size elements without media queries is to use intrinsic sizing. Using intrinsic methods means that the element’s content influences the size, as opposed to inheriting “extrinsic” sizing instructions from set styles. An image may intrinsically be 300px wide, but often in CSS we’ll explicitly size it, such as by setting width: 100px.

There are three keywords available in CSS for intrinsic sizing, which can be used in Flexbox, Grid, and for other properties that accept a “length” value.

The behavior of the keywords below, when applied to the width or inline-size properties, is as follows:

min-content: length of longest word, with soft wrapping applied

max-content: length of all content

fit-content: length of max-content unless that size exceeds available space, after which wrapping is allowed

This CodePen demo shows live examples of using these intrinsic sizing keywords.

Of these values, fit-content is the one I use most often. It can be used in scenarios where we may have previously used display: inline-block if our intent was to shrink a container to the dimensions of the content. Swapping to width: fit-content allows us to save the display property for other purposes, if needed.

This CodePen demo shows a live example of using fit-content to set an element’s width.

Using Math Functions for Contextual Sizing

Sometimes, when developing flexible components, we need to resize elements contextually. Instead of media-query reliant pixel sizing scales, we can use CSS math functions to offer multiple options with mixed units.

Earlier, we looked at min() as an example of defining a responsive margin. Additionally, it can be used for dimension properties, and it’s also handy for sizing grid tracks that contain media elements like images, avatars, or icons.

Let’s use it to size the avatar column in this author bio component.

Using min() with three values of mixed units, we can set up the avatar column to handle wide and narrow containers, as well as high zoom levels:

.bio {
  display: grid;
  grid-template-columns: min(64px, 20%, 15vw) 1fr;
}

Here’s a breakdown of the values and when they’re likely to apply:

64px: wide containers

20%: narrow containers

15vw: high zoom levels and other very narrow viewports

This CodePen demo gives a live example of using min() to size grid tracks.

This method does require a little practice and testing. I recommend using the “Computed” panel in the browser’s developer tools (Chromium is shown in the screenshot below) and selecting the element we expect to be resizing. Then check that the selected values are having an effect, and adjust as needed. I also visit the “Styles” panel and use arrow keys to adjust the values within min() up or down until I feel they’re providing the desired sizing behavior.

Responsive Layout Grids

The foundation for many sites continues to be a layout grid, whether that’s composed of Grid or Flexbox. Both of these tools provide ways to create fluidly responsive layout grids without media queries.

First up is perhaps my favorite of all the solutions, because of its versatility and ease of use. Using Grid, we can create a responsive set of columns that create themselves as needed. We’ll provide a single constraint—a minimum width that columns can be—which does double-duty as a sort of “breakpoint” before column items break onto new rows.

This video demonstrates the behavior we’re after.

Here’s all it takes to accomplish this responsive grid layout, where our minimum column size is set to 30ch via a helper custom property. This rule directs the browser to create as many columns as will fit that are at least 30ch wide:

.grid {
  --min: 30ch;

  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, var(--min)), 1fr));
}

Since 1fr is the “max” value of minmax(), the columns are also allowed to stretch to fill any leftover space equitably within a row. So, if the available space is 80ch and there are two grid children, they’ll each take up 40ch. If there are three children, the third will be on a second row, since 80 doesn’t divide equally into the minimum size allowed of 30.

This CodePen demo provides a live example of a responsive Grid layout.

We can accomplish a similar experience with Flexbox. The difference between the Flexbox and Grid solution is that grid items that flow to a new row can’t expand across multiple grid columns. With Flexbox, we can direct the flex items to grow to fill all remaining extra space, preventing an “orphan” that appears with the Grid solution.

In this code, as in the Grid code, the browser will create as many columns as will fit the inline space with at least the --min size of 30ch. If we have three items and the third needs to move to a new row, it will take up the remaining space due to the flex shorthand, which importantly sets flex-grow to 1. It therefore has a similar behavior to 1fr in most cases:

.flexbox-grid {
  --min: 30ch;

  display: flex;
  flex-wrap: wrap;
}

.flexbox-grid > * {
  flex: 1 1 var(--min);
}

The image below shows the final, odd-numbered list item spanning two columns, thanks to the flex-grow property.

Gap Affects Columns

In both the Grid and Flexbox solutions, if we add a gap, that space will be subtracted from the calculation of how many columns may be created before new rows are added.

Keen readers may have noticed another key difference between these solutions: when using Grid, the parent defines the child behavior. For Flexbox, we set the child layout behavior on the children. The flex shorthand sets, in order, flex-grow, flex-shrink, and flex-basis.

As an experiment, we can change the flex-grow value to 0 and see how the items will only expand up to the flex-basis value. (Experiment with the CodePen demo below.) It’s important to keep flex-shrink to 1, so that, eventually—when the available inline space is narrower than the flex-basis—the items are still allowed to “shrink”, as this helps to prevent overflow.

This CodePen demo shows our Flexbox layout in action.

The flex-basis property can be further adjusted for this solution to assign unique “breakpoints” for different items. Since we’re setting that value via the --min custom property, and Flexbox children control their own size, we can adjust it with an inline style:

<li style="--min: 40ch">...</li>

The other list children in this example will still flow around it and use the 30ch from the base rule, but the wider column effectively changes the behavior.

Here’s a CodePen demo of this code in action.

Here are two other Flexbox techniques that use flex-grow and flex-basis in interesting ways:

Heydon Pickering’s Flexbox Holy Albatross, which breaks down from columns into a single row based on the parent container’s total width.

Heydon Pickering’s and Andy Bell’s sidebar layout, which shows how to force varying Flexbox-based breakpoints for better control of when items wrap.

Reducing Risk of Layout Overflow

Flexbox excels as a layout tool in reducing the risk of overflow. Unlike grid items, flex items of independently variable widths can wrap onto new rows as necessary. The ability for content to re-arrange itself upon collision is a critical tool for building responsively without media queries.

While the Grid layout we explored did technically wrap, there’s no explicit wrapping property for Grid. We achieved “wrapping” thanks to the use of auto-fit (auto-fill would also be an option). With more defined grid tracks, such as grid-template-columns: 100px 300ch 1fr, we need to resort to media or container queries to change the grid layout. Otherwise, we may experience content overflow once the container or viewport is too narrow for the defined columns.

In Flexbox, we can easily enable wrapping behavior with flex-wrap: wrap.

Opting for Wrap

The default Flexbox wrapping behavior is flex-wrap: none, so we do need to explicitly opt in when our layout would benefit from wrapping.

The images below show some basic product cards, where we want the headline and price to align horizontally when there’s room. Initially, they’re using display: flex, but with the default value set, which doesn’t cause wrapping. The narrow card headline is quite squeezed and is at risk of overflow, depending on the content and container.

If we apply flex-wrap: wrap, a different effect happens: the price is immediately wrapped, even if there may be reasonable space to accommodate it.

This is happening because the product title is an <h3>, which is a block element. In this case, we need to apply two extra properties to the <h3>, which is the element whose space allotment we want to control.

First, we’ll use flex-basis: 15ch to assign it an “element breakpoint”, just as we used for the Flexbox grid. That will be the point at which we ensure wrapping occurs. This point will be hit when the available space between the card’s padding and the price is reduced to less than 15ch. We’ll also apply flex-grow: 1 so that it expands beyond that size when there’s more horizontal space available:

.card__header h3 {
  flex-basis: 15ch;
  flex-grow: 1;
}

With that rule added, the first card is narrow enough to have hit the “breakpoint”, so the price has wrapped. The second card is wide enough that the headline has been able to grow, as well as have natural content wrapping, but still leave space for the price.

In this CodePen demo, we can try removing or changing the flex-basis or flex-grow values to see them alter the headline’s behavior. Also be sure to try resizing the demo window to see the responsive wrapping adjust as the card widths change.

Responsive Containers

While the Flexbox and Grid solutions we just covered are, in fact, responsive to a container, as of February 2023 and the release of Firefox 110, true container queries became available in evergreen browsers.

The Practical Uses of Container Queries tutorial in this series covers multiple features and use cases for container queries. The key feature of container queries is how they allow us to orchestrate changes based on container size instead of viewport size.

In the past, we often used media queries to try to control the micro things that happen within components within a layout. That was always precarious, especially as adjustments were needed when a component changed size due to a new location in the layout or placement within a different page.

Container queries flip this around: we can consider variations of a component across multiple space possibilities, so that the component is more resilient to being placed wherever required.

For example, here’s the subscription form from our Practical Uses of Container Queries tutorial. Notice how it adapts to different locations within the layout—in an article element, in the sidebar, and when allowed to grow to the full width at the top of the layout.

Container queries also have a lightweight polyfill available, so it’s definitely time to start using them to our advantage when designing responsive, future-proof components. Container queries are covered in depth in the Practical Uses of Container Queries tutorial in this series, which includes creating fluid typography and multi-container navigation, and leveraging Grid to adapt container children.

Review of Responsive Techniques

In this tutorial, we’ve covered a lot of tools that can help us improve our responsive layouts and components. The methods we’ve explored all come down to hinting to the browser about expected behavior, while giving our layouts and components the best chance of adapting appropriately to unknown environments.

First, we looked into upgrading spacing in our layouts and components. Techniques included setting padding by using clamp() with percentages, margin with min() and vh, and pairing gap for page layout with vmax and clamp().

Then we learned about the intrinsic sizing keywords of min-content, max-content, and fit-content. Additionally, we used min() with three values of mixed units to provide parameters for sizing a grid column and handle narrow and wide containers as well as zoomed-in viewports.

After that, we learned how to create a fundamental yet very powerful Grid rule for grid layouts. Following that were the examples of using Flexbox to do a similar grid layout, but with a few key Flexbox-only feature differences.

Finally, we gave a nod to the recently released container queries, with a recommendation to check out or review our tutorial on container queries in the light of what we’ve learned here. We’re very much now on the journey to creating future-forward components and layouts.

Additional Resources

When approaching a new layout, we should consider how it will adapt to different viewports, zoom levels, and container sizes. One of the solutions we’ve covered, or one from the following resources, will likely help to make our layout next-level responsive.

To learn more about the topics in this tutorial, especially intrinsic design, check out my conference talk from beyond tellerrand on Scaling CSS Layout Beyond Pixels.

For a deeper dive into the flex and grid solutions, see my article explaining them as container query solutions (that don’t require “real” container queries!).

Many modern responsive techniques are available in the book Every Layout (free preview) by Heydon Pickering and Andy Bell.

Ahmad Shadeed has created an incredible article called “The Guide To Responsive Design In 2023 and Beyond”, which I highly recommend.

Una Kravets has written “The new responsive: Web design in a component-driven world”, explaining the various contexts that need to be considered.

Get inspired by Michelle Barker’s Creative CSS Layout talk, which has a focus on responsive and intrinsic layout features.

Andy Bell has released a sort of manifesto for building websites by being “the browser’s mentor, not its micromanager”, with a proposed set of tools.