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 withprefers-contrastandprefers-color-schemeprefers-reduced-motion(2019),prefers-color-scheme(2019) andforced-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-typeno longer sizes itself from its content in that axis. If heights collapse, check you usedinline-sizerather thansize - 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-levelwas never implemented and has been removed. It does not workprefers-color-scheme,prefers-reduced-motionandforced-colorsdate from 2019–2020- Range syntax
(768px <= width < 1024px)removes the one-pixel problem scripting: enabledstops content vanishing when JavaScript fails- Component-level branching belongs to
@container, not@media prefers-reduced-motionneeds!importantand must stop transitions. Use0.01ms, not0s- Never set colours inside
forced-colors. Add outlines and text alternatives instead light-dark()requirescolor-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.