Frontend Masters Boost RSS Feed https://frontendmasters.com/blog Helping Your Journey to Senior Developer Mon, 05 Jan 2026 17:46:37 +0000 en-US hourly 1 https://wordpress.org/?v=6.9 225069128 How to @scope CSS Now That It’s Baseline https://frontendmasters.com/blog/how-to-scope-css-now-that-its-baseline/ https://frontendmasters.com/blog/how-to-scope-css-now-that-its-baseline/#comments Mon, 05 Jan 2026 17:46:37 +0000 https://frontendmasters.com/blog/?p=8146 Firefox 146 now supports @scope in CSS, joining Chrome and Safari, meaning that it’s now supported in all major web browsers, earning it the “Baseline: Newly Available” tag.

This @scope at-rule defines a new scope context in CSS. The :scope pseudo-class represents the root of said context (otherwise known as the ‘scope root’), and all this means is that we have some new and exciting ways of writing and organizing CSS, so today I’ll demonstrate the different ways of using @scope and the benefits of each one.

You can use an @scope block in any CSS, be it within a stylesheet that you <link> up or a <style> block within HTML. In fact, the latter has some interesting properties we’ll get to.

Whichever way you use CSS, the rules are, by default, globally scoped. For example, in the demo below, even though the CSS rules are nested within the <main>, they apply to the whole document:

<header></header>

<main>
  <style>
    /* No scoping applied. Global scope. */
    header, footer {
      background: rgb(from green r g b / 30%);
    }
  </style>

  <header></header>
  <section></section>
  <footer></footer>
</main>

<footer></footer>

Styles in a CSS stylesheet are also globally scoped by default.

@scope in a <style> Block

However, @scope can be used to limit the CSS to the ‘scope root’ (which in this case is <main>, because the <style> is a direct child of <main>). In addition, within the @scope at-rule, :scope selects this scope root:

<header></header>

<main>
  <style>
    @scope {
      /* Scope root */
      :scope { /* Selects the <main> */

        /* <header>/<footer> within scope root */
        header, footer {
          background: rgb(from green r g b / 30%);
        }
      }
    }
  </style>

  <header></header>
  <section></section>
  <footer></footer>
</main>

<footer></footer>

If needed, we can narrow the scope, or actually, stop the scoping at a particular second selector. In the example below I’ve added a ‘scope limit’ — specifically, I’ve defined the scope as from <main> (implicitly) to <section>. The scope is non-inclusive, so :scope > * selects the <header> and <footer> (excluding <section>, since it’s outside of the scope). main and section don’t select anything as, again, they’re out-of-scope, but we can continue to select <main> by using :scope:

<main>
  <style>
    @scope to (section) {
      /* Selects nothing */
      main, section {
        color: red;
      }

      /* However, this selects <main> */
      :scope {
        font-weight: bold;
      }

      /* Selects scoped direct children */
      :scope > * {
        background: rgb(from green r g b / 30%);
      }

      /* This also works */
      & > * {
        background: rgb(from green r g b / 30%);
      }

      /* As does this */
      > * {
        background: rgb(from green r g b / 30%);
      }
    }
  </style>

  <header></header>
  <section></section>
  <footer></footer>
</main>

When you use the to keyword it is known as donut scope. In the image below you can see why (the rings of the donuts include the scope root and scope limit, but it’s what’s in the donut hole that’s actually included in the scope):

Nothing inside that <section> could be selected at all, because the “donut” stops there. That’s the hole in the donut.

The <style> element itself can be selected by the universal selector (*), so if you were to, for example, set display to anything other than none, the CSS would hilariously output as a raw string (but still work, somehow):

To get really weird, style the <style> element like you would a <pre> tag and add the contenteditable attribute!

Notably, you don’t need to use the :scope selector or an equivalent, that’s just helpful for clarity or adding specificity to the selector if needed.

<p>
  <style>
    @scope {
      color: red;
    }
  </style>
  
  I'll be red.
</p>

<p>I'll be whatever color is inherited.</p>

The potential benefits of scoping in a <style> block are:

  • HTML and CSS are kept together
  • No external resource to download (even if you load CSS asynchronously to stop it from render-blocking, which risks Cumulative Layout Shift anyway, external resources must be downloaded in full before they can be rendered, which isn’t ideal)
  • CSS always renders with the HTML, which means no Cumulative Layout Shift, and when not deferring non-critical CSS, is the best way to prioritize resources efficiently

Keep in mind that the CSS will output more times than is necessary if you’re reusing the component, which is very anti-DRY and why you’ll want to combine this type of scoped CSS with other types of CSS where appropriate. With that in mind, let’s talk about using @scope with internal and external CSS.

@scope in a CSS file

When using @scope with a CSS file, we must specify the scope root (and optionally the end scope) within the @scope at-rule manually, like this:

@scope (main) to (section) {
  > * {
    background: rgb(from green r g b / 30%);
  }
}

This is actually true for <style> blocks as well. If you specify the scope with a selector (with or without the to selector), it will behave the same way. The distinction with a <style> block is when you don’t specify a selector, the scope becomes the parent element.

The benefit of this method is that it’s DRY, so you won’t be repeating yourself. However, there are quite a few drawbacks:

  • CSS is a render blocking resource
  • Potential Cumulative Layout Shift (if loading external CSS asynchronously)
  • HTML and CSS aren’t kept together

Other ways to use :scope

What’s interesting is that, if we use :scope outside of @scope, it selects the global scope root, which in HTML is <html>:

/* Global scope root */
html { }

/* Selects the same thing */
:root { }

/* Selects the same thing! */
:scope { }

I don’t know why we’d select :scope instead of :root or html, but it makes sense that we can do so, and explains why :scope was supported before @scope.

:scope can also be used in the querySelector(), querySelectorAll(), matches(), and closest() JavaScript DOM APIs, where :scope refers to the element on which the method is called. Take the following HTML markup, for example:

<section>
  <div>
    Child div
    <div>Grandchild div</div>
  </div>
</section>

While trying to select the direct child <div> only:

  • section.querySelectorAll("div").forEach(e => e.style.marginLeft = "3rem") undesirably but expectedly selects both <div>s
  • section.querySelectorAll("> div").forEach(e => e.style.marginLeft = "3rem") doesn’t work (even though, as demonstrated earlier, > div would work in CSS)
  • Luckily, section.querySelectorAll(":scope > div").forEach(e => e.style.marginLeft = "3rem") targets the child div only, as desired
  • section.querySelectorAll("& > div").forEach(e => e.style.marginLeft = "3rem") also works, as it would in CSS

Fun fact, we can also use & instead of :scope:

& {
  /* Instead of html, :root, or :scope */
}

@scope (main) to (section) {
  & {
    /* Instead of :scope */
  }
}

A Well-Balanced Approach to Serving and Writing Scoped CSS

I was really looking forward to @scope, and it securing full browser support in the last minute of 2025 made it my feature of the year. Regardless of what types of websites you build, you’ll find all ways of implementing @scope quite useful, although I think you’ll often use all implementations together, in harmony.

It will depend on the type of website that you’re building and how much you want to balance CSS organization with web performance.

Personally, I like splitting CSS into reusable modules, including them as internal CSS using templating logic only when needed (e.g., forms.css on /contact), and then using in-HTML scoped <style>s for one-time or once-per-page components. That way we can avoid render-blocking external CSS without causing Cumulative Layout Shift (CLS) and still have fairly organized CSS. One thing to consider though is that CSS isn’t cached with these methods, so you’ll need to determine whether they’re worth that.

If you’re building heavy front-ends, caching external CSS will be better and fewer bytes overall, but you can totally serve CSS using all methods at once (as appropriate) and use @scope with all of them.

In any case though, the ultimate benefit is, of course, that we’re able to write much simpler selectors by defining new scope roots.

All in all, the future of CSS could look like this:

/* global.css */
body {
  color: #111;
}

section {
  background: #eee;

  h2 {
    color: #000;
  }
}


/* home.css */
@scope (section.home-only) {
  :scope {
    background: #111;

    h2 {
      color: #fff;
    }
  }
}
<!DOCTYPE html>
<html>

<head>
  <!-- Site-wide styles -->
  <link rel="stylesheet" href="global.css">

  <style>
    /* Reusable BUT critical/above-the-fold, so not for global.css */
    @scope (header) {
      height: 100vh;
    }

    /* Include home.css conditionally */
    {% if template.name == "index" %}{% render "home.css" %}{% endif %}
  </style>

</head>

<body>
  <header>
    <h1>Critical/above-the-fold content</h1>
  </header>

  <main>
    <section>
      <h2>Default section (styles from external CSS)</h2>
    </section>

    <section class="home-only">
      <h2>Home-only section (styles from internal CSS)</h2>
    </section>

    <section class="home-only">
      <h2>Home-only section (styles from internal CSS)</h2>
    </section>

    <section class="home-only">
      <h2>Home-only section (styles from internal CSS)</h2>
    </section>

    <section>
      <style>
        @scope {
          :scope {
            background: #f00;

            h2 {
              color: #fff;
            }
          }
      </style>

      <h2>Unique section (styles from in-HTML CSS)</h2>
    </section>

  </main>
</body>

</html>

This is a very simple example. If we imagine that section.home-only is a much more complex selector, @scope enables us to write it once and then refer to it as :scope thereafter.

]]>
https://frontendmasters.com/blog/how-to-scope-css-now-that-its-baseline/feed/ 2 8146
How to Add and Remove Items From a Native CSS Carousel (…with CSS) https://frontendmasters.com/blog/how-to-add-and-remove-items-from-a-native-css-carousel-with-css/ https://frontendmasters.com/blog/how-to-add-and-remove-items-from-a-native-css-carousel-with-css/#respond Wed, 26 Nov 2025 21:30:50 +0000 https://frontendmasters.com/blog/?p=7830 The CSS Overflow Module Level 5 defines specs for scrolling controls that enable users to navigate overflow content without manually scrolling (like click-and-dragging the scrollbar, the trackpad, a scrollwheel, or the like). This includes scroll buttons, which enable users to scroll 85% of the scrollport, unless scroll snapping is enabled, as well as scroll markers, which enable users to skip to specific scroll targets (direct children of the scroll container).

These buttons and markers make themselves present via CSS pseudo-elements. At the time of this writing, these pseudo-elements are only supported in Chrome 142+:

  • ::scroll-marker: a generated element that links to a scroll target in a scroll container (behaves like an <a>)
  • ::scroll-button(<direction>): a generated element that scrolls 85% of the scrollport, where <direction> can be up , down, left, right, or all (behaves like a <button>)

There are many ways that we can leverage these CSS features. I’ll share some of them throughout this article, but focus on one in particular: a standard CSS carousel. We’ll use all the bells and whistles mentioned above and one extra twist, the ability to add and remove items from it.

This functionality would be ideal for showing product photos according to user-defined variables such as color or size, or showing items handpicked by users, just to give to two examples.

Ready to dig in?

Step 1: Setting up the Scroll Container

In this first step, I’m just going to walk you through the HTML, the carousel’s dimensions, and how we determine which carousel items have been added to the carousel.

The HTML

The carousel itself is an unordered list (<ul>) with list items (<li>) inside (in terms of accessibility, I think this is the best markup). Prior to that we have some checkboxes, which users can toggle to add and remove the carousel items, and for the purpose of this walkthrough I’ve pre-selected a few of them using the checked attribute. Finally, we wrap all of that, establishing our overall component. This part is important because we’ll be seeing which checkboxes within it aren’t checked, and then hiding the corresponding carousel items — also within it — based on that:

<div class="component">

  <input type="checkbox" id="i1"><label for="i1">Toggle slide 1</label>
  <input type="checkbox" id="i2"><label for="i2">Toggle slide 2</label>
  <input type="checkbox" id="i3" checked><label for="i3">Toggle slide 3</label>
  <input type="checkbox" id="i4" checked><label for="i4">Toggle slide 4</label>
  <input type="checkbox" id="i5" checked><label for="i5">Toggle slide 5</label>

  <ul class="carousel">
    <li>1</li>
    <li>2</li>
    <li>3</li>
    <li>4</li>
    <li>5</li>
  </ul>

</div>

The CSS

First we rein in the carousel’s width using max-width, then we make the height half of whatever the computed width is using aspect-ratio. Just a little responsive design.

After that, we see which checkboxes aren’t checked, and then hide the corresponding scroll targets/carousel items. For example, this is what we do for the first checkbox and carousel item:

  • .component:has(input:nth-of-type(1):not(:checked)) {}: select the component, which contains an input, the first of which isn’t checked
  • li:nth-of-type(1) {}: within that, select the first carousel item
  • display: hidden: and hide it

That covers the adding-and-removing logic. What’s even better is that those checkboxes can be used to submit data, so if you were to mark up the component as a <form>, you do form-like things with it, like serialize the data and save it.

In the next section, we declare placeholder styles for when no checkboxes are checked (.component:not(:has(input:checked))). This conditional :has() block (and certain others) isn’t required, but it’s a great way of clarifying what, when, and why, for other developers and for yourself if you come back to the code later.

If at least one checkbox is checked (.component:has(input:checked)), the carousel receives display: flex, which makes the carousel items flow horizontally, while the carousel items within receive min-width: 100%, which ensures that only one carousel item is displayed at a time.

The final block runs if multiple checkboxes are checked (.component:has(input:checked ~ input:checked), which translates to “if a checked checkbox is immediately or non-immediately followed by another checked checkbox”). This is where the code for the scroll markers, scroll buttons, and scroll behaviors will go.

.component {
  ul.carousel {
    max-width: 738px;
    aspect-ratio: 2 / 1;
  }

  /* If the first checkbox isn’t checked */
  &:has(input:nth-of-type(1):not(:checked)) {
    /* Hide the first list item */
    li:nth-of-type(1) {
      display: none;
    }
  }

  /* And so on, incrementing the nth-of-type */
  &:has(input:nth-of-type(2):not(:checked)) {
    li:nth-of-type(2) {
      display: none;
    }
  }

  /* If no checkboxes are checked */
  &:not(:has(input:checked)) {
    /* Placeholder content for the carousel */
    ul.carousel {
      background: #eee;
    }
  }

  /* If any checkbox is checked */
  &:has(input:checked) {
    ul.carousel {
      /* Implies flex-direction:row */
      display: flex;

      li {
        /* Show one list item only */
        min-width: 100%;
      }
    }
  }

  /* If multiple are checked */
  &:has(input:checked ~ input:checked) {
    /* Step 2 and step 3 code here */
  }
}

Continuing from where we left off, let’s revisit the carousel. Keep in mind that we’re working within the context of multiple checkboxes being checked (.component:has(input:checked ~ input:checked), which means that the carousel items that are visible will horizontally overflow the carousel unless we declare overflow: hidden (or overflow: scroll if you want to allow manual scrolling).

Next, by default, scroll buttons enable users to scroll 85% of the scrollport, but we’re looking for a slideshow-type behavior where one complete slide is shown at a time, so we’ll need to set up scroll snapping for the additional 15%. scroll-snap-type: x will do exactly that for the x-axis. We’ll figure out the exact alignment in a moment.

Complimenting that, scroll-behavior: smooth will ensure that users snap to the carousel items smoothly when using the scroll buttons (and scroll markers).

anchor-name: --carousel turns the carousel into an anchor, naming it --carousel. This will enable us to align the scroll buttons (and scroll markers) relative to the carousel, but again this is something that we’ll do in a moment.

The scroll-marker-group property relates to the ::scroll-marker-group pseudo-element that’s generated whenever a scroll marker is generated. It’s basically the container for the scroll markers, where the value of scroll-marker-group determines whether ::scroll-marker-group is inserted before or after the carousel’s content (similarly to ::before/::after), affecting tab order. You must set scroll-marker-group to either before or after.

Treat ::scroll-marker-group like any other container. For example, display: flex; gap: 1rem; will make the scroll markers flow horizontally with 1rem of spacing between them. After that, we combine position: fixed and position-anchor: --carousel (--carousel refers to the anchor that we named earlier) to align the container relative to the carousel, then justify-self: anchor-center to align it horizontally and bottom: calc(anchor(bottom) + 1rem) to align it 1rem from the bottom.

The scroll markers are pseudo-elements of the scroll targets (so li::scroll-marker), which makes sense, right? One marker for every scroll target. But as mentioned before, they’re inserted into ::scroll-marker-group, not the scroll targets, so where you select them isn’t where they’re inserted. After your brain has reconciled that (it took me a minute), you’ll need to set their content property. We’re using stylized markers here, so we’ve set them to an empty string (content: ""), but you can insert whatever content you want inside of them, and even number them using CSS counters.

After that you’re free to style them, and if you want to take that a step further, ::scroll-marker has three pseudo-classes:

  • :target-current: the active scroll marker
  • :target-before: all scroll markers before the active one
  • :target-after: all scroll markers after the active one

Note: the pseudo-class must be prefixed by the pseudo-element:

/* Won’t work */
:target-current {
  /* ... */
}

/* Won’t work (even though it should?) */
::scroll-marker {
  &:target-current {
    /* ... */
  }
}

/* Only this will work */
::scroll-marker:target-current {
  /* ... */
}

This is the full (step 2) CSS code:

/* Step 1 code here */

ul.carousel {
  /* Hide overflow/disable scrolling */
  overflow: hidden;

  /* Enable x-axis scroll snapping */
  scroll-snap-type: x;

  /* Enable smooth scrolling */
  scroll-behavior: smooth;

  /* Turn the carousel into an anchor */
  anchor-name: --carousel;

  /* Insert the SMG after the content */
  scroll-marker-group: after;

  /* SMG (holds the scroll markers) */
  &::scroll-marker-group {
    /* Scroll marker layout */
    display: flex;
    gap: 1rem;

    /* Anchor the SMG to the carousel */
    position: fixed;
    position-anchor: --carousel;

    /* Anchor it horizontally */
    justify-self: anchor-center;

    /* Anchor it near the bottom */
    bottom: calc(anchor(bottom) + 1rem);
  }

  li::scroll-marker {
    /* Generate empty markers */
    content: "";

    /* Style the markers */
    width: 1rem;
    aspect-ratio: 1 / 1;
  }

  /* Active marker */
  li::scroll-marker:target-current {
    background: white;
  }

  /* All markers before the active one */
  li::scroll-marker:target-before {
    background: hsl(from white h s l / 50%);
  }

  /* All markers after the active one */
  li::scroll-marker:target-after {
    background: red;
  }

  /* Step 3 code here */
}

Step 3: adding the scroll buttons

In this final step we’ll add the scroll buttons, which are pseudo-elements of the scroll container (the carousel in this case). ::scroll-button() accepts five physical values for its only parameter:

  • ::scroll-button(*)
  • ::scroll-button(left)
  • ::scroll-button(right)
  • ::scroll-button(up)
  • ::scroll-button(down)

As well as four logical values:

  • ::scroll-button(block-start)
  • ::scroll-button(block-end)
  • ::scroll-button(inline-start)
  • ::scroll-button(inline-end)

They too must have valid content properties like the scroll markers, otherwise they won’t show up. In today’s example we’re only using ::scroll-button(left) and ::scroll-button(right), inserting directional arrows into them, but only when enabled (e.g., ::scroll-button(left):enabled). When they’re :disabled (which means that it’s impossible to scroll any further in that direction), no content property is set (which, again, means that they won’t show up).

We also use anchor positioning again, to align the scroll buttons relative to the carousel. ::scroll-button(*) selects all scroll buttons, which is where most of this logic is declared, then of course ::scroll-button(left) and ::scroll-button(right) to align the individual buttons to their respective edges.

And finally, we also declare scroll-snap-align: center on the carousel items (<li>), complimenting the scroll-snap-type: x that we declared on the carousel earlier, which ensures that when users click on these scroll buttons, they don’t scroll 85% of the scrollport. Instead, they snap to the scroll target fully.

/* Step 1 code here */

ul.carousel {
  /* Step 2 code here */

  /* All scroll buttons */
  &::scroll-button(*) {
    /* Anchor them to the carousel */
    position: fixed;
    position-anchor: --carousel;

    /* Anchor them vertically */
    align-self: anchor-center;
  }

  /* Left scroll button (if enabled) */
  &::scroll-button(left):enabled {
    /* Generate the button with content */
    content: "⬅︎";

    /* Anchor it near the left */
    left: calc(anchor(left) + 1rem);
  }

  /* Right scroll button (if enabled) */
  &::scroll-button(right):enabled {
    /* Generate the button with content */
    content: "⮕";

    /* Anchor it near the right */
    right: calc(anchor(right) + 1rem);
  }

  li {
    /*
      Snap to the center of scroll targets
      instead of scrolling 85% of the scrollport
    */
    scroll-snap-align: center;
  }
}

And here’s the same thing but vertical:

Wrapping up

These features are really cool. They can be used in so many different ways, from tabs to pagination. To throw in a more a real-world example of what we explored here today, a carousel showing product photos:

Just choose the color that you want and that’s what the carousel will show!

]]>
https://frontendmasters.com/blog/how-to-add-and-remove-items-from-a-native-css-carousel-with-css/feed/ 0 7830
How much do you really know about media queries? https://frontendmasters.com/blog/learn-media-queries/ https://frontendmasters.com/blog/learn-media-queries/#comments Mon, 29 Sep 2025 15:46:45 +0000 https://frontendmasters.com/blog/?p=7267 Earlier this year, I realized that I knew very little about possibly most of the media queries.

Maybe that’s not surprising — since I never hear about them.

Beyond the classics like @media(min-width: 400px) and the user-preference media queries such as @media (prefers-reduced-motion: reduce), and maaaybe orientation, I can’t say that I was using media queries a whole lot. Especially since flexbox, grid layout, and calc() became fairly normalized, in addition to newer sizing values such as min-contentmax-contentfit-content, and more recently, stretch.

But there are so many descriptors! Most of these I’ve never used:

  • any-hover
  • any-pointer
  • aspect-ratio
  • color
  • color-gamut
  • color-index
  • display-mode
  • dynamic-range
  • environment-blending
  • forced-colors
  • grid
  • height
  • horizontal-viewport-segments
  • hover
  • inverted-colors
  • monochrome
  • nav-controls
  • orientation
  • overflow-block
  • overflow-inline
  • pointer
  • prefers-color-scheme
  • prefers-contrast
  • prefers-reduced-data
  • prefers-reduced-motion
  • prefers-reduced-transparency
  • resolution
  • scan
  • scripting
  • update
  • vertical-viewport-segments
  • video-color-gamut
  • video-dynamic-range
  • width

It’s not that I thought media queries were only for responsive design, but out of sight, out of mind, right? Nobody talks about them. Granted, some of them have few use-cases, but after being more mindful of them for a few months, I’ve come to the conclusion that many of them definitely deserve more attention.

Plus, there are more ways to write media queries now. This includes an @custom-media at-rule for saving media queries as custom properties, which is super cool.

Let’s dig in.

hover/pointer/any-hover/any-pointer

Modern devices can be used in many different ways. For example, it’s not uncommon to hook a mouse up to a tablet, which is why we shouldn’t think of tablets as touchscreen devices anymore. In fact, it’s now unwise to use media queries to query for what specific device they “are”, which is why the media types tty, tv, projection, handheld, braille, embossed, aural, and speech were deprecated (all, print, and screen are the only types now, but all of them will likely be deprecated eventually).

These days it’s more prudent to query the device’s capabilities and how the user has set it up, and that’s where the hover, pointer, any-hover, and any-pointer media query descriptors come into it. At first glance, hover and pointer sound like the same thing, and while you could use them interchangeably for a high-level use-case, using them together is what truly enables us to target capabilities:

/* Primary input mechanism is a mouse */
@media (hover: hover) and (pointer: fine) {
  ...
}

/* Primary input mechanism is a joystick */
@media (hover: hover) and (pointer: coarse) {
  ...
}

/* Primary input mechanism is touch */
/* (also targets joystick-less controllers) */
@media (hover: none) and (pointer: coarse) {
  ...
}

/* Primary input mechanism is a stylus */
@media (hover: none) and (pointer: fine) {
  ...
}

As you might’ve guessed from @media (hover: none) and (pointer: coarse) targeting joystick-less controllers (e.g., the D-pad-only controllers that come with old-ish TVs and game consoles) in addition to touchscreen devices. It’s not a fool-proof method, but it doesn’t make any overly bold assumptions about input mechanisms.

Here’s a simple use-case:

button:hover {

  /* Default hover styles */

  @media (hover: none) and (pointer: coarse) {
    /* High-visibility hover styles when obscured by fingers */
  }

}

The possibilities are endless, and you can of course query just one descriptor (e.g., @media (hover: none)), but they’re rarely useful individually. A more powerful usage is in the combinative way demonstrated above, but instead of declaring alternative hover styles for touchscreen devices, providing a more touch-friendly UI, perhaps by using the display property to show/hide different components altogether.

It’s also worth mentioning that hover and pointer query the primary input mechanism, whereas their any-hover and any-pointer counterparts query all input mechanisms. For example, the following code targets touchscreen devices with a mouse or stylus hooked up as an additional input mechanism, which might be a more accurate representation of the user’s setup:

@media (hover: none) and (pointer: coarse) and (any-pointer: fine) {
  ...
}

forced-colors

Users can choose color schemes at the OS or browser level, or create their own. As an example, Firefox allows us to change the color of text, backgrounds, and links, whereas High Contrast Mode in Windows offers more customization options and has themes to choose from. In an ideal world we wouldn’t need to do anything here, but if something turns out to be inaccessible or just looks odd, we can use the forced-colors media query descriptor to correct it:

@media (forced-colors: active) {
  /* Corrective styles, like applying borders to controls that might not otherwise have them. */
}

If something must be a certain color, simply apply forced-color-adjust: none along with the necessary property and value:

#this-must-be-blue {
  color: blue;
  forced-color-adjust: none;
}

It’s also worth noting that when forced-colors is set to active, the user’s preferred color scheme will be set to either light or dark depending on their chosen theme. You can query the assigned color scheme using @media (prefers-color-scheme: light) and @media (prefers-color-scheme: dark). In addition, prefers-contrast will be set to more, less, or custom.

You can learn more about forced-colors at MDN.

width/height

width and height? I already know about those!”

Yes, these are certainly the most commonly used media queries, but did you know that a new syntax with comparison operators was introduced about three years ago? I like the operators because they can be inclusive or exclusive of the value in question. For example, we can express “more than” using > or “more than or equal to” using >=, which enables us to drop the min-/max- prefixes (which are only inclusive) and standardize the width and height keywords across the board:

@media (width: 900px) {
  /* Styles when viewport width is 900px */
}

@media (width < 900px) {
  /* Styles when viewport width is less than 900px */
}

@media (width > 900px) {
  /* Styles when viewport width is more than 900px */
}

@media (width <= 900px) {
  /* Styles when viewport width is less than or equal to 900px */
}

@media (width >= 900px) {
  /* Styles when viewport width is more than or equal to 900px */
}

There’s also a between-this-and-that syntax, which oddly requires ‘lessy’ operators but is otherwise easy to remember:

@media (900px < width < 1200px) {
  /* Styles when viewport width is 901-1199px (exclusive) */
}

@media (900px <= width <= 1200px) {
  /* Styles when viewport width is 900-1200px (inclusive) */
}

If you’re not keen on this syntax (yeah, it’s a little weird), you can still use the old syntax. Or, even better, use a little of both:

@media (width > 900px) and (width < 1200px) {
  /* Styles when viewport width is 901-1199px (exclusive) */
}

@media (width >= 900px) and (width <= 1200px) {
  /* Styles when viewport width is 900-1200px (inclusive) */
}

It’s worth noting that logical properties like inline-size and block-size also work, but it often makes more sense in @media queries to deal with physical properties since the browser window itself doesn’t change when the flow changes.

inverted-colors

Since there are a variety of viewing modes (light, dark, high-contrast, etc.) already, having a mode that inverts colors might seem unnecessary, but it’s useful when light or dark mode isn’t available (because inverting the colors somewhat toggles the mode) and when the user has a Color Vision Deficiency (because inverting the colors creates new colors). While the former issue can be fixed by respecting light-dark mode preferences and implementing light-dark mode toggling, the latter issue requires color inversion.

We can query this mode using the inverted-colors media query descriptor, and what we want to do with it is revert shadows and non-UI media back to ‘normal,’ otherwise the non-UI media will look extremely odd and the shadows will appear as highlights. To invert a shadow color (or any color), convert it to HSL format using the relative color syntax, and add 180 to the Hue (h):

For media, filter: invert(1) will do the trick:

.box-shadow {

  /* When not inverted, normal box shadow */
  @media (inverted-colors: none) {
    box-shadow: 0 0 8px limegreen;
  }

  /* When inverted, add 180 to Hue/h to revert */
  @media (inverted-colors: inverted) {
    box-shadow: 0 0 8px hsl(from limegreen calc(h + 180) s l);
  }

}

/* Same thing for text shadows */
.text-shadow {
  @media (inverted-colors: none) {
    text-shadow: 0 0 8px limegreen;
  }
  @media (inverted-colors: inverted) {
    text-shadow: 0 0 8px hsl(from limegreen calc(h + 180) s l);
  }
}

/* Same thing for drop shadows */
.drop-shadow {
  @media (inverted-colors: none) {
    filter: drop-shadow(0 0 8px limegreen);
  }
  @media (inverted-colors: inverted) {
    filter: drop-shadow(0 0 8px hsl(from limegreen calc(h + 180) s l));
  }
}

.non-ui:is(img, svg, video, ...) {

  box-shadow: 0 0 8px black;

  @media (inverted-colors: inverted) {
    /* Reverts the media and shadow at the same time */
    filter: invert(1);
  }

}

You can see it in action here (make sure to toggle inverted colors to see the UI change but images/shadows stay the same):

While forced colors are better as long as users can choose themes, some operating systems don’t support it (macOS doesn’t).

As of September 2025, only Safari supports the inverted-colors media query descriptor prefers-reduced-motion.

orientation

An orientation query is a rather simple media query descriptor that resolves to portrait if the viewport is larger vertically or landscape if it’s larger horizontally.

The thing with orientation is that users hold their handheld device differently based on it, which could mean having to code a slightly different layout for each orientation. Even if you’re lucky enough to avoid that issue, you’re still likely to run into issues with aspect ratios or relative units (e.g., viewport or percentage units) where something looks proportionate in one orientation but very exaggerated or de-exaggerated in the other.

Targeting a specific orientation is easy enough:

@media (orientation: portrait) {
  ...
}

@media (orientation: landscape) {
  ...
}

As for optimizing layouts for certain orientations on touchscreen devices, you’ll want to combine the orientation descriptor with the ‘touchscreen descriptors’ mentioned earlier:

@media (orientation: landscape) and (hover: none) and (pointer: coarse) {
  /* Landscape touchscreen styles */
}

Generally speaking, due to the diversity of devices and media queries available today, avoid using orientation to detect the device type. For example, vertical monitors do exist (you’ll see them at airports, for instance), so don’t assume that portrait means handheld.

overflow-inline/overflow-block

The overflow-inline and overflow-block media query descriptors with the scroll value match vendors that support overflow content on their respective axes. We’re basically talking about web browsers here, as well as windows that display HTML content and have a scrolling mechanisms.

/* Vendor supports inline-axis scrolling */
@media (overflow-inline: scroll) {
  ...
}

/* Vendor supports block-axis scrolling */
@media (overflow-block: scroll) {
  ...
}

The overflow-inline and overflow-block media query descriptors with the none value match vendors that don’t support overflow content on their respective axes. To be clear, this doesn’t mean that the document has overflow: hidden declared, it means that scrolling isn’t supported at all (e.g., a HTML document that’s rendered as an OS-level widget or on a digital billboard).

/* Vendor doesn’t support inline-axis scrolling */
@media (overflow-inline: none) {
  ...
}

/* Vendor doesn’t support block-axis scrolling */
@media (overflow-block: none) {
  ...
}

A more common use-case exists for overflow-block, which accepts another value: paged. Naturally, paged refers to printed documents and HTML-based ebooks such as EPUBs, with printed documents obviously being the most common of the two.

/* Vendor supports block-axis scrolling, but is paged media */
@media (overflow-block: paged) {
  ...
}

The spec alludes to all media types being deprecated eventually, so this is somewhat designed to replaced the print media type.

The prefers- family

The prefers- family of media query descriptors should be used to cater to users with preferences and comply with the WCAG (Web Content Accessibility Guidelines).

prefers-color-scheme

/* User prefers light mode */
@media (prefers-color-scheme: light) {
  ...
}

/* User prefers dark mode */
@media (prefers-color-scheme: dark) {
  ...
}

I like to query this using JavaScript, check a ‘dark mode’ checkbox accordingly, and then use CSS’s light-dark() function to style according to the inferred (or user-specified) mode:

/* If dark mode is inferred, check dark mode box */
if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
  document.querySelector("#dark-mode").checked = true;
}
/* But users can change at will */
<input type="checkbox" id="dark-mode">
/* If dark mode isn’t checked */
:root:has(#dark-mode:indeterminate) {
  color-scheme: light;
}

/* If dark mode is checked */
:root:has(#dark-mode:checked) {
  color-scheme: dark;
}

body {
  /* Then set values conditionally */
  background: light-dark(white, black);
}

prefers-contrast

There are four possible values for prefers-contrast:

/* User has no contrast preference */
@media (prefers-contrast: no-preference) {
  ...
}

/* User prefers more contrast */
@media (prefers-contrast: more) {
  ...
}

/* User prefers less contrast */
@media (prefers-contrast: less) {
  ...
}

/* User has forced a color theme */
@media (prefers-contrast: custom) {
  ...
}

You’re unlikely to use no-preference or more because the WCAG has defined a minimum level of color contrast that websites must adhere to. In addition, prefers-contrast: custom means that the user has specified a color theme preference via forced colors mode that’s neither a low or high contrast theme. It’s a matter of semantics that again has no practical use, so simply use low for the minority of users that prefer low contrast due to migraines, Dyslexia, and so on. This doesn’t violate WCAG as long as there’s a mechanism for switching low-contrast mode off.

prefers-reduced-data

prefers-reduced-data: reduce targets users that prefer to consume less data when browsing while connected to their cellular network, whereas prefers-reduced-data: no-preference naturally targets those who have no preference. Personally, I’d love to see more websites reduce their size for those with slower connections and/or smaller data allowances, perhaps by swapping heavy decorative background images for something else:

div {
  @media (prefers-reduced-data: no-preference) {
    /* Heavy decorative image */
    background-image: url(<url>);
  }

  @media (prefers-reduced-data: reduce) {
    /* Literally anything else */
    background: <value>;
  }
}

Or loading fonts conditionally:

@media (prefers-reduced-data: no-preference) {
  @font-face {
    font-family: lexend;
    src: <value>;
  }
}

body {
  /* If user prefers reduced data, fallback font sans-serif will be used */
  font-family: lexend, sans-serif;
}

As of October 2025, no web browser supports this.

prefers-reduced-motion*

The key to prefers-reduced-motion, which accepts the same values as prefers-reduced-data (no-preference and reduce), is simply to reduce (or remove, in extreme cases) transitions and animations for those with vestibular disorders, which interestingly affect 35% of adults by aged 40:

button {
  @media (prefers-reduced-motion: no-preference) {
    /* Continuously pulsate */
    animation: pulse 1s infinite;
  }

  @media (prefers-reduced-motion: reduce) {
    /* Pulsate once and not so intensely */
    animation: scaleUpDown 1s;
  }
}

prefers-reduced-transparency

Same here — your options are no-preference and reduce.

I’d normally consider this one to be an edge case, but with Apple’s new Liquid Glass aesthetic being available to everybody now, I expect it to surge in popularity. If you must implement it, at least reduce transparency for those that don’t like it or find it to be inaccessible:

div {
  @media (prefers-reduced-transparency: no-preference) {
    background: hsl(0 0 0 / 60%);
  }

  @media (prefers-reduced-transparency: reduce) {
    background: hsl(0 0 0 / 80%);
  }
}

As of September 2025, only Chrome and Edge support this.

resolution

resolution, min-resolution, and max-resolution accept values of the <resolution> data type (dpi, dpcm, dppx/x). A fantastic use-case for this is to serve higher-resolution images to higher-resolution devices, as you would when using the srcset and sizes HTML attributes. And remember, we can use the new range syntax here, so resolution > 1x instead of min-resolution: 1.01x:

div {
  background-image: url("/image-1x.jpg");

  @media (resolution > 1x) {
    background-image: url("/image-2x.jpg");
  }

  @media (resolution > 2x) {
    background-image: url("/image-3x.jpg");
  }

  @media (resolution > 3x) {
    background-image: url("/image-4x.jpg");
  }
}

Nesting media queries

CSS nesting has had full browser support since August 2023, enabling us to nest CSS rules inside other CSS rules, and that includes at-rules such as media queries. If you’ve been using a CSS preprocessor such as LESS, Scss, or Sass for this, well…you don’t need to anymore. Instead, to use an earlier example, you can nest media queries like this:

.box-shadow {
  @media (inverted-colors: none) {
    box-shadow: 0 0 8px limegreen;
  }
  @media (inverted-colors: inverted) {
    box-shadow: 0 0 8px hsl(from limegreen calc(h + 180) s l);
  }
}

This enables you to write media queries more contextually.

All in all

If you were anxious about exploring the now-many different types of media queries and their syntaxes, I’d say that’s fair. However, while catering to today’s diversity of devices and user preferences is without a doubt getting more complex, these media queries are actually pretty easy to use once you know how.

Besides, WCAG 3.0, which could set the standard for the design of even more inclusive patterns, will be here eventually. This’d mean having the legal requirement of catering for more user preferences regardless of how edge case-y they are, and as we’ve seen in recent years, new and amended accessibility laws (and similar laws such as GDPR) tend to cause a last-minute frenzy as the deadline for compliance draws closer. My advice? Get ahead of the curve and start putting those media queries to use!

]]>
https://frontendmasters.com/blog/learn-media-queries/feed/ 6 7267