Viewport and Responsive Behaviour
Date: 2026-08-17
A CSS pixel is not a device pixel and hasn’t been for fifteen years. The viewport meta tag is what tells a mobile browser to stop pretending it’s a desktop, and the device pixel ratio is what decides how many physical pixels an image needs — get either wrong and the page is unreadable or three times heavier than it should be.
The viewport is the rectangle the browser lays the page out in, measured in CSS pixels; device pixel ratio (DPR) is how many physical screen pixels make up one CSS pixel.
The two viewports
Mobile browsers maintain a fiction, and the meta tag is how you decline it.
WITHOUT the viewport meta tag
layout viewport = ~980 CSS px ← the browser pretends it's a desktop
visual viewport = ~390 CSS px ← what the user can actually see
→ the page renders at desktop width, then zooms out to fit
→ text is unreadable, media queries never match
WITH <meta name="viewport" content="width=device-width, initial-scale=1">
layout viewport = 390 CSS px ← matches the device
visual viewport = 390 CSS px
→ media queries work, text is legible
<meta name="viewport" content="width=device-width, initial-scale=1">That exact line, and nothing more. Two additions to avoid:
<!-- ✗ both prevent pinch-zoom. an accessibility failure, and a
WCAG conformance failure for anyone who needs to magnify -->
<meta name="viewport" content="width=device-width, maximum-scale=1">
<meta name="viewport" content="width=device-width, user-scalable=no">Preventing zoom is usually added to stop iOS zooming when a form field is focused. The actual fix for that is a font size of 16px or larger on inputs — iOS zooms only below that threshold — which solves it without removing a control people depend on — WCAG, Accessible Forms.
Device pixel ratio
DPR = physical pixels / CSS pixels
DPR 1 1 CSS px = 1 device px older desktop monitors
DPR 2 1 CSS px = 4 device px most modern phones, retina laptops
DPR 3 1 CSS px = 9 device px high-end phones
a 400 × 300 CSS px image slot on a DPR 2 screen
→ needs an 800 × 600 source to look sharp
→ that's 4× the pixels, not 2×
The quadratic relationship is the thing to hold. Going from DPR 1 to DPR 3 is nine times the pixel data, which is why serving one large image to everyone is so expensive on the devices least able to afford it.
The fix is srcset with density or width descriptors, letting the browser choose:
<img src="/product-400.jpg"
srcset="/product-400.jpg 400w,
/product-800.jpg 800w,
/product-1200.jpg 1200w"
sizes="(max-width: 600px) 100vw, 400px"
width="400" height="300" alt="…">sizes describes the slot, not the image. It tells the browser how wide the element will be at each breakpoint, so it can multiply by DPR and pick a source — before CSS has loaded, which is why it can’t work this out for itself — Image Optimisation, Modern Image Formats.
The viewport units, and the mobile problem
vw / vh 1% of the LAYOUT viewport
vh is the trap: on mobile it's the viewport WITHOUT
the address bar, so a 100vh element is taller than
the visible area until the bar hides
svh small viewport height — address bar VISIBLE
lvh large viewport height — address bar HIDDEN
dvh dynamic — changes as the bar shows and hides
← use for full-height layouts. note it triggers
layout on scroll, so don't animate against it
height: 100vh on a mobile hero is the classic bug — content sits below the fold because the browser reserved space for a bar that’s currently drawn over it. 100svh is usually what was meant.
Breakpoints and the better alternatives
Breakpoints respond to the viewport, which is the wrong question for a component that could appear in a sidebar or a full-width row.
MEDIA QUERY CONTAINER QUERY
@media (min-width: 768px) @container (min-width: 400px)
responds to the viewport responds to the component's own
container
a card in a narrow sidebar the card knows how much room IT has
gets the "wide" layout → correct in every context
because the window is wide
Container queries remove most breakpoint logic in a component-based system, and they’re the right default for anything reusable. Viewport media queries remain correct for page-level layout — how many columns the whole page has.
Intrinsic sizing removes more of it again. min(), max(), clamp(), grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)) and flex-wrap all adapt continuously without a single breakpoint — which is why the modern advice is breakpoints as a last resort rather than as the structure — Responsive Design.
Practical points
- Test at 320px wide. It’s the narrowest viewport in common use and where horizontal overflow appears
- Test at 400% zoom, which WCAG requires to reflow without horizontal scrolling — and which behaves like a very narrow viewport, so fixing one usually fixes the other
overflow-x: hiddenon the body is a plaster. It hides the symptom; something is still wider than the viewport, and on some devices it breaks scroll behaviour. Find the offending element- Set
widthandheighton every image so space is reserved before the file arrives — Cumulative Layout Shift - Respect safe areas on notched devices with
env(safe-area-inset-*), or fixed bottom bars sit under the home indicator - The visual viewport moves independently when the on-screen keyboard opens.
visualViewportis the API for anything that must track it
Where it interacts
- Image Optimisation — DPR is the multiplier that makes correct sizing worth so much
- Cumulative Layout Shift — most shift is space that wasn’t reserved because dimensions weren’t declared
- Mobile Interaction Patterns — the interaction side of the same constraint
- Responsive Design — the design approach this note supplies the mechanics for