Media queries and breakpoints
A media query is a condition. If it is true, the CSS inside applies. That is all — and the interesting part is how many conditions there are beyond screen width, because several of them solve problems people solve badly with JavaScript.
The syntax
@media (min-width: 40rem) {
.cards { grid-template-columns: repeat(2, 1fr); }
}
The parentheses are required. @media min-width: 40rem is invalid and, as always in
CSS, fails silently — the whole block is discarded with no error.
Combining conditions
@media (min-width: 40rem) and (max-width: 60rem) { } /* both */
@media (min-width: 40rem), (orientation: landscape) { } /* either — comma is OR */
@media not all and (min-width: 40rem) { } /* negation, awkward */
@media screen and (min-width: 40rem) { } /* media type + condition */
The comma is OR, which reads wrong to most people the first time. And not applies
to the whole query, not to one condition, which is why it needs the all and why
it is rarely worth using.
Range syntax, which is better
@media (width >= 40rem) { }
@media (40rem <= width <= 60rem) { }
@media (width < 40rem) { }
Supported in all current browsers and much easier to read than
(min-width: 40rem) and (max-width: 60rem). It also avoids the off-by-one problem:
max-width: 600px and min-width: 600px both match at exactly 600px, so a
declaration can apply in both. With (width < 600px) and (width >= 600px) there is
no overlap at all.
That overlap is a real, occasional bug — a border that appears twice, a padding that comes from the wrong block — and it is invisible until you test at exactly the breakpoint.
Media types
@media screen { }
@media print { }
@media all { } /* default — omit it */
Only screen, print and all remain. handheld, tv and projection were
removed because nothing implemented them honestly.
@media print is worth ten minutes on any site with content somebody might print —
a price list, a recipe, an invoice:
@media print {
nav, footer, .no-print { display: none; }
a[href^="http"]::after { content: " (" attr(href) ")"; }
body { font-size: 12pt; color: #000; }
}
That second rule prints the URL after each link, since a printed link is otherwise
useless. pt is the correct unit here — one of the few places it is.
The queries that are not about width
This is the part worth knowing, because each replaces something people do badly.
prefers-reduced-motion
@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;
}
}
A visitor who has switched on "reduce motion" in their OS is often somebody for whom
movement causes nausea or triggers a vestibular disorder. This is not a preference
about taste. The block above is the standard safety net and belongs in every
stylesheet. !important is justified here — it is one of the genuine exceptions.
prefers-color-scheme
@media (prefers-color-scheme: dark) { }
Covered properly in the dark-mode lesson later in this module.
hover and pointer
@media (hover: hover) and (pointer: fine) {
.card:hover { box-shadow: 0 8px 24px rgb(0 0 0 / 0.12); }
}
@media (pointer: coarse) {
.button { min-height: 44px; } /* a finger needs a bigger target */
}
This is the correct answer to "hover does not work on touch". Rather than guessing from screen width — a touchscreen laptop is wide, a phone with a mouse exists — ask the browser what input the device actually has.
pointer: coarse means a finger. pointer: fine means a mouse or stylus.
hover: hover means the primary input can hover. Wrapping hover effects in
(hover: hover) stops the sticky-hover bug on touch, where a tapped element keeps
its hover style until you tap elsewhere.
min-height: 44px for touch targets is the widely used figure; WCAG 2.2 asks for at
least 24×24 CSS pixels as a minimum, and 44 is the comfortable target.
orientation and aspect-ratio
@media (orientation: landscape) { }
@media (min-aspect-ratio: 16/9) { }
orientation: landscape is true whenever width exceeds height — including a
desktop monitor, which is the mistake people make with it. It is rarely what you
want; a width query usually is.
The rest, briefly
@media (prefers-contrast: more) { }
@media (forced-colors: active) { } /* Windows high contrast mode */
@media (scripting: none) { } /* JavaScript disabled */
@media (min-resolution: 2dppx) { } /* high-density screens */
@media (display-mode: standalone) { } /* installed as an app */
forced-colors: active matters because Windows high-contrast mode overrides your
colours entirely — and, as the backgrounds lesson noted, box-shadow becomes
invisible there while outline does not.
Where to put your queries
Two approaches, and one is clearly better for maintenance.
All queries at the bottom of the file, grouped by breakpoint. Tidy-looking, and it means the rules for one component are in two distant places. Changing a card means editing four locations.
Queries next to the rule they modify:
.cards {
display: grid;
grid-template-columns: 1fr;
gap: 1rem;
}
@media (width >= 40rem) {
.cards { grid-template-columns: repeat(2, 1fr); gap: 1.5rem; }
}
@media (width >= 60rem) {
.cards { grid-template-columns: repeat(3, 1fr); gap: 2rem; }
}
/* next component */
Everything about .cards in one place. The file has more @media blocks, which
costs nothing — they compress away and the parse cost is negligible. Do this.
Custom properties and media queries
A neat trick: change a variable in the query rather than every rule that uses it.
:root {
--gap: 1rem;
--pad: 1rem;
--cols: 1;
}
@media (width >= 40rem) { :root { --gap: 1.5rem; --pad: 2rem; --cols: 2; } }
@media (width >= 60rem) { :root { --gap: 2rem; --pad: 3rem; --cols: 3; } }
.cards {
display: grid;
grid-template-columns: repeat(var(--cols), 1fr);
gap: var(--gap);
padding: var(--pad);
}
Two queries for the whole page instead of one per component. The trade-off is that the numbers are now away from the component that uses them, so this suits global rhythm — spacing scale, column count, container width — rather than one component's quirk.
Note what you cannot do: a custom property may not hold a media query condition.
@media (width >= var(--bp)) does not work, because custom properties are resolved
too late. Breakpoint values have to be literal.
What media queries cannot do
The important limitation, and the reason the next-but-one lesson exists.
A media query only knows about the viewport. It cannot know how much space this component has. So a card in a 300px sidebar and the same card in a 900px main area get identical styling, because the window is the same width for both.
For years the workaround was a modifier class — .card--narrow — applied by hand or
by JavaScript, which meant a component's styling depended on knowing where it had
been placed. Container queries fix this properly, and they are two lessons away.
Check your work
What the parentheses are. Required — without them the block is silently discarded.
What a comma means. OR, not AND.
Why range syntax is better. Readable, and no overlap at the exact breakpoint —
max-width: 600px and min-width: 600px both match at 600.
Which media types survive. screen, print, all.
Why prefers-reduced-motion is not a taste preference. Movement can cause nausea
or trigger a vestibular disorder — and !important is justified in that block.
The correct test for hover. (hover: hover) and (pointer: fine), not screen
width.
What pointer: coarse means. A finger. Give it a bigger target.
Why orientation: landscape is a trap. It is true on every desktop monitor.
Why forced-colors matters. Windows high-contrast overrides your colours, and
box-shadow disappears there.
Where to put queries. Next to the rule they modify.
What a custom property cannot do. Hold a breakpoint value for @media.
What a media query fundamentally cannot know. How much space the component has.
Practice
- Write a media query with no parentheses and confirm nothing happens and nothing errors.
- Write
(min-width: 40rem), (orientation: landscape)and work out when it applies. - Set
max-width: 600pxandmin-width: 600pxrules that conflict, then resize to exactly 600px. Fix it with range syntax. - Write a
@media printblock, then use print preview on a page of yours. - Add the
prefers-reduced-motionsafety net, then switch on "reduce motion" in your OS settings and confirm your animations stop. - Wrap a hover effect in
(hover: hover)and test on a real phone. Then remove the wrapper and tap the element — note the sticky hover. - Give buttons
min-height: 44pxunder(pointer: coarse)and measure them on a phone. - Use
orientation: landscapeon a desktop monitor and observe that it matches. - Move all your media queries next to their components. Note how much easier the next change is.
- Convert a three-breakpoint card layout to the custom-property approach.
- Try
@media (width >= var(--bp))and confirm it does not work. - Put the same card component in a 300px sidebar and a 900px main area. Try to style them differently with a media query alone.
Official documentation
- MDN — Using media queries — Every feature and operator, including the range syntax.
- MDN — @media — The reference list of media features, which is longer than this lesson's.
- MDN — prefers-reduced-motion — Including how the OS setting maps to the query on each platform.
- W3C — Understanding WCAG 2.5.8 Target Size — The actual minimum touch-target requirement, rather than the figure everybody repeats.
Next: images, which are almost always the biggest thing on the page.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship