Browse by section

Web Design 日本語

CSS @media: The Features That Actually Landed

What genuinely changed about @media in recent years is range syntax with comparison operators (Baseline March 2023) and scripting (December 2023). Alongside them, container queries (@container, Baseline February 2023) fill in what @media fundamentally cannot do.

This article was published in 2024 and revised in September 2026. The light-level media feature described at the time as “newly introduced” was never implemented in any browser engine and has been removed from the specification. That has been corrected and replaced with features that work.

  • @media (light-level: dim) does nothing. It appeared briefly in a Media Queries Level 5 draft, was never implemented, and was dropped as redundant with prefers-contrast and prefers-color-scheme
  • prefers-reduced-motion (2019), prefers-color-scheme (2019) and forced-colors (2020) are not 2024 additions

Sponsored

When each feature became available

Feature Available since Status
prefers-color-scheme 2019 Established
prefers-reduced-motion 2019 Established
forced-colors 2020 Established
Container queries @container Baseline February 2023 New
Range syntax (400px <= width) Baseline March 2023 New
scripting December 2023 New
prefers-reduced-transparency Chrome 118+ (Level 5 draft) Experimental
light-level Never implemented; removed Unusable

Range syntax: no more min-width and max-width

Write ranges with comparison operators. Safari 16.4 completed support in March 2023.

/* before */
@media (min-width: 768px) { ... }
@media (max-width: 767.98px) { ... }
@media (min-width: 768px) and (max-width: 1023.98px) { ... }

/* range syntax */
@media (width >= 768px) { ... }
@media (width < 768px) { ... }
@media (768px <= width < 1024px) { ... }

The main win is that the one-pixel problem disappears. max-width is inclusive, so min-width: 768px and max-width: 768px both apply at exactly 768px. That is why people wrote 767.98px. With range syntax, width < 768px states the boundary unambiguously.

/* both apply at exactly 768px */
@media (max-width: 768px) { .box { color: red; } }
@media (min-width: 768px) { .box { color: blue; } }

/* no overlap */
@media (width < 768px)  { .box { color: red; } }
@media (width >= 768px) { .box { color: blue; } }

It works with height and resolution too.

@media (height < 600px) { ... }
@media (resolution >= 2x) { ... }

Sponsored

scripting: branch on whether JavaScript runs

This is the CSS equivalent of <noscript>, for environments where JavaScript is disabled or failed to load.

/* JavaScript is available */
@media (scripting: enabled) {
  .fade-in { opacity: 0; transition: opacity .4s; }
  .fade-in.is-visible { opacity: 1; }
}

/* no JavaScript: content is visible from the start */
@media (scripting: none) {
  .js-only-button { display: none; }
  .no-js-message { display: block; }
}

This prevents a real bug. Scroll animations that start at opacity: 0 leave the page unreadable when JavaScript fails. Confining that initial state to scripting: enabled removes the failure mode entirely.

Value Meaning
enabled Scripting is available
none Scripting is unavailable
initial-only Only during load (rarely implemented)

The related implementation is in scroll animations with the Intersection Observer API.

Container queries: branch on the parent, not the viewport

@media has one fundamental limit: it can only see the viewport. Put the same card component in a wide main area and a narrow sidebar, and viewport width cannot tell them apart.

/* declare a container on the parent */
.card-container {
  container-type: inline-size;
  container-name: card;
}

/* lay out horizontally when the parent is 400px or wider */
@container card (width >= 400px) {
  .card {
    display: grid;
    grid-template-columns: 120px 1fr;
    gap: 16px;
  }
}

The same CSS now adapts to where the component is placed—stacked in a sidebar, side by side in the main column.

Two caveats:

  • An element with container-type no longer sizes itself from its content in that axis. If heights collapse, check you used inline-size rather than size
  • An element cannot query itself. Declare the container on the parent and style the children

Container units are available too.

@container card (width >= 400px) {
  .card__title {
    font-size: 5cqi; /* 5% of the container's inline size */
  }
}
@media @container
Based on Viewport Parent element
Best for Page-level layout Reusable components
User preferences Available Not available

Page skeleton with @media, components with @container.

Sponsored

prefers-reduced-motion: writing it correctly

Available since 2019, but the usual snippet does not actually work.

/* often ineffective */
@media (prefers-reduced-motion: reduce) {
  * { animation-duration: 0s; }
}
  • It loses on specificity to any more specific rule written later
  • It does not stop transitions—only animations

This version is reliable:

@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
}

0.01ms rather than 0s is deliberate. At exactly zero, animationend and transitionend may never fire, breaking any code that removes an element after its animation completes. A near-zero duration looks instant while keeping the events.

The JavaScript side is covered in the Web Animations API animate() method.

forced-colors: do not specify colours

This one is widely misunderstood. It detects an OS-level forced colour mode, such as Windows High Contrast.

What you must not do is set colours inside it.

/* overrides the palette the user chose */
@media (forced-colors: active) {
  body {
    background-color: black;
    color: yellow;
  }
}

In forced colours mode the user has selected a palette that works for them. Specifying black and yellow overrides that choice—and it will not work as intended anyway, since most author colours are replaced in this mode.

The correct use is to restore information that is lost when colours are stripped.

@media (forced-colors: active) {
  /* a button identified only by background colour needs an outline */
  .button--primary {
    border: 2px solid ButtonText;
  }

  /* icons drawn as background images disappear; add text */
  .icon-search::after {
    content: "Search";
  }

  /* use system colour keywords */
  .card {
    border: 1px solid CanvasText;
    background: Canvas;
    color: CanvasText;
  }
}

Available keywords include Canvas, CanvasText, LinkText, ButtonFace, ButtonText and Highlight. Specify roles, not specific colours.

forced-color-adjust: none preserves colours for things like logos, but use it sparingly—only where the colour genuinely carries meaning.

prefers-color-scheme and light-dark()

Dark mode still starts with prefers-color-scheme, but you can now express both values per colour with light-dark().

:root {
  /* required: declare that both schemes are supported */
  color-scheme: light dark;

  --bg:   light-dark(#ffffff, #121212);
  --text: light-dark(#333333, #f2f2f2);
  --link: light-dark(#0b5cff, #8ab4ff);
}

body {
  background: var(--bg);
  color: var(--text);
}

The color-scheme: light dark; declaration is mandatory. Without it, light-dark() always returns the light value. It also makes form controls and scrollbars follow the OS theme.

For a manual theme toggle, write both the media query and the attribute selector.

:root { --bg: #ffffff; --text: #333333; }

@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) { --bg: #121212; --text: #f2f2f2; }
}

:root[data-theme="dark"] { --bg: #121212; --text: #f2f2f2; }

For designing the palette itself, oklch() helps—see the CSS color() function and oklch().

Other media features worth knowing

Feature Use
(hover: hover) Apply hover effects only where hovering exists
(pointer: coarse) Finger input—enlarge tap targets
(prefers-contrast: more) The user wants higher contrast
(prefers-reduced-transparency: reduce) Reduce translucency (Chrome 118+)
(update: slow) E-paper and similar—stop animating
(display-mode: standalone) Installed as a PWA

(hover: hover) is especially practical: hover styles written without it stick after a tap on touch devices.

@media (hover: hover) and (pointer: fine) {
  .card:hover {
    transform: translateY(-4px);
    box-shadow: 0 8px 24px rgb(0 0 0 / .12);
  }
}

Summary

  • light-level was never implemented and has been removed. It does not work
  • prefers-color-scheme, prefers-reduced-motion and forced-colors date from 2019–2020
  • Range syntax (768px <= width < 1024px) removes the one-pixel problem
  • scripting: enabled stops content vanishing when JavaScript fails
  • Component-level branching belongs to @container, not @media
  • prefers-reduced-motion needs !important and must stop transitions. Use 0.01ms, not 0s
  • Never set colours inside forced-colors. Add outlines and text alternatives instead
  • light-dark() requires color-scheme: light dark;

Media query articles go stale quietly. Confirm that a browser actually implements a feature—on Can I use or MDN—before writing code against it.