Picture Element Media Queries for Portrait Crops
A portrait crop is the one art-direction case where the obvious media query is almost always the wrong one. (max-width: 639px) sounds like “phone”, but it stops matching the moment the user rotates that phone into landscape — and a 852 × 393 viewport is exactly where a tall 3:4 crop looks worst. (orientation: portrait) sounds like “tall”, but it also matches a 1400 px-wide desktop browser on a pivoted monitor, which will happily download your 480 px mobile crop and stretch it across a third of a metre of screen. This page works through which media feature actually encodes the condition your portrait crop depends on, how to order overlapping <source> entries so the browser resolves them the way you meant, and how to verify the result at every boundary. It sits under art direction with the HTML picture element, which covers the surrounding markup, crop generation and CLS mechanics that this page assumes are already in place.
Prerequisite checklist
Step 1 — Decide what the crop actually depends on
Three media features can plausibly gate a portrait crop, and they carve the space of possible viewports differently:
min-width/max-widthdescribe how much horizontal room the layout has. Use them when the portrait crop exists because the image column is narrow — a single-column mobile stack, a sidebar, a card grid at one item per row.orientationis defined purely asheight >= widthfor the viewport. It is a proxy for width that stops being one on tablets and pivoted desktop displays.aspect-ratio(asmax-aspect-ratio/min-aspect-ratio) states the shape of the viewport directly, and is the only one of the three that lets you say “the viewport is at least half again as tall as it is wide” — the condition under which a 3:4 crop genuinely beats a 3:2 one.
Plotting real devices on a width × height plane shows why the three features disagree and where they disagree hardest:
The practical answer for a hero image is a compound condition: the portrait crop should win only where the viewport is both narrow and taller than it is wide. Written as one media query that is (max-width: 639px) and (max-aspect-ratio: 1/1), it excludes the rotated phone (fails the aspect test) and the pivoted desktop (fails the width test) simultaneously. Everything else in the element can then use plain width bands, because they no longer have to carry the orientation semantics on their own.
Tradeoff: compound conditions are cheap to evaluate but they multiply the number of viewport states you must test. Adding one and clause turns a three-way boundary check into a six-way one. Keep the compound condition on exactly one <source> — the portrait one — and leave the rest of the ladder as simple width bands.
Step 2 — Order the sources so overlaps truncate correctly
The selection algorithm has one rule that governs everything else: the browser walks <source> children in document order and commits to the first whose media matches and whose type is supported. It never backtracks, never scores candidates, and never prefers a “more specific” query the way CSS cascade specificity would. Two sources whose media conditions overlap are therefore resolved purely by which one you wrote first.
That makes an art-direction source list an ordered list of half-open intervals, not a set of mutually exclusive rules. You can write bands that overlap freely as long as the earlier one is the one you want to win in the overlap:
Row four is the safety net that makes the whole ladder total. Because the portrait source carries an aspect clause it can fail even inside its width band, and if nothing followed it a rotated phone would fall all the way through to the terminal <img>. A trailing source with no media attribute at all matches unconditionally and absorbs every state the earlier rows rejected. Write it last, always, and give it the landscape small crop rather than the portrait one.
The complete solution
<!--
Art-directed hero with a portrait crop gated on a compound condition.
Source order is strictly widest-first; the browser commits to the first match.
-->
<picture>
<!--
media="(min-width: 1024px)"
Desktop and large tablet landscape. No aspect clause is needed: a
viewport wider than 1024 px is never tall enough for the portrait crop
to be the better composition, even on a pivoted 1440 x 2560 monitor.
width/height
Declared on the <source>, not only on the <img>. Chrome, Firefox and
Safari 16+ use the per-source pair to compute the reserved box for the
candidate they actually select, so the box changes with the crop.
-->
<source media="(min-width: 1024px)"
srcset="/img/hero-lg.jpg"
width="1600" height="900">
<!--
media="(min-width: 640px)"
Tablets upright, rotated phones, and any desktop window the user has
narrowed. Overlaps the source above; the earlier row already claimed
everything at 1024 px and beyond, so this row's effective range is
640-1023 px. Writing it as a closed band (min-width AND max-width)
would be equivalent but adds a second boundary to test.
-->
<source media="(min-width: 640px)"
srcset="/img/hero-md.jpg"
width="1024" height="576">
<!--
media="(max-width: 639px) and (max-aspect-ratio: 1/1)"
THE portrait rule. Both clauses are load-bearing:
max-width 639px excludes the pivoted desktop monitor, which is
portrait-shaped but has 680+ px of layout room.
max-aspect-ratio 1/1 excludes the rotated phone at 852 x 393, which
is inside the width band on some devices but is
the worst possible viewport for a 3:4 crop.
max-aspect-ratio compares width/height, so "at most 1/1" means the
viewport is square or taller. Use 4/5 instead of 1/1 if you only want
the crop on genuinely tall viewports and are happy to fall through on
near-square ones.
-->
<source media="(max-width: 639px) and (max-aspect-ratio: 1/1)"
srcset="/img/hero-portrait.jpg"
width="480" height="640">
<!--
No media attribute at all: an unconditional match that absorbs every
state the rows above rejected. In practice that is the rotated phone
and any sub-640px viewport wider than it is tall. Without this row those
viewports fall through to the <img> src, silently bypassing art direction.
-->
<source srcset="/img/hero-sm.jpg"
width="640" height="360">
<!--
Terminal <img>: the element that actually paints, whichever source won.
src only reached by engines that ignore <source> entirely
width/height the default reserved box before selection resolves
loading eager, because this is the LCP candidate
decoding async, so decode happens after layout commit
Give the fallback the landscape crop: it is the safer degradation for an
unknown viewport, and it matches the aspect-ratio declared in CSS below.
-->
<img src="/img/hero-sm.jpg"
alt="Harbour cranes silhouetted against a low winter sun"
width="640" height="360"
loading="eager" decoding="async">
</picture>
<style>
/*
The reserved box must track the selected crop or the swap produces a shift.
Each rule mirrors exactly one <source> above, using the identical media
condition text — copy-paste it rather than paraphrasing, because a
one-pixel divergence between the HTML and CSS boundaries creates a viewport
band where the box and the file disagree.
*/
picture img { display: block; width: 100%; height: auto; aspect-ratio: 640 / 360; }
@media (max-width: 639px) and (max-aspect-ratio: 1/1) {
picture img { aspect-ratio: 480 / 640; }
}
@media (min-width: 1024px) {
picture img { aspect-ratio: 1600 / 900; }
}
</style>
Verification steps
1. Read currentSrc at both sides of every boundary
img.currentSrc is the resolved URL the browser actually committed to — the only trustworthy readout, because the DOM still contains every <source> regardless of which one won. Boundary conditions are where portrait rules break, so probe one pixel either side of each edge rather than at comfortable round numbers:
// Playwright — assert the selected crop at each boundary and in both orientations.
// npm i -D @playwright/test
const { test, expect } = require('@playwright/test');
const CASES = [
// [ width, height, expected file, why this case exists ]
[ 1024, 768, 'hero-lg.jpg', 'first pixel of the desktop band' ],
[ 1023, 768, 'hero-md.jpg', 'last pixel of the mid band' ],
[ 640, 1136, 'hero-md.jpg', 'tall but 640 wide — mid band, not portrait' ],
[ 639, 1136, 'hero-portrait.jpg', 'first pixel where the portrait rule may fire' ],
[ 393, 852, 'hero-portrait.jpg', 'iPhone 15 upright' ],
[ 639, 393, 'hero-sm.jpg', 'narrow AND landscape — the aspect clause must reject' ],
];
for (const [width, height, expected, why] of CASES) {
test(`${width}x${height}: ${why}`, async ({ page }) => {
// Set the viewport BEFORE navigation. Resizing after load exercises the
// re-selection path instead of the initial-selection path, and the two
// can differ when a service worker or the HTTP cache is involved.
await page.setViewportSize({ width, height });
await page.goto('http://localhost:8080/hero-demo/');
const src = await page.locator('picture img').evaluate(el => el.currentSrc);
expect(src).toContain(expected);
});
}
2. Confirm the CSS and HTML boundaries agree
A mismatch between the media text in <source media> and the media text in the @media block is the single most common cause of a portrait-crop layout shift, and it is trivially detectable at runtime:
// Paste into the DevTools console. Every source's media condition must be
// matchable by matchMedia and must have a CSS twin that agrees at this width.
[...document.querySelectorAll('picture source')].forEach((s, i) => {
const q = s.media || '(all)';
console.log(
i, q.padEnd(48),
'matches:', s.media ? matchMedia(s.media).matches : true,
'| srcset:', s.srcset
);
});
// Then compare against the box the browser reserved:
const img = document.querySelector('picture img');
console.log('reserved box ratio:', getComputedStyle(img).aspectRatio,
'| file ratio:', img.naturalWidth + '/' + img.naturalHeight);
// The two ratios must be equal. If they are not, the CSS breakpoint and the
// source breakpoint disagree at the current viewport width.
3. Record a rotation and check the Layout Shifts track
Rotating a device re-enters the source selection algorithm, so a new crop is fetched mid-session. Open DevTools → Performance, enable Screenshots, start recording, toggle the device-emulation rotate button, and stop. In the Layout Shifts track you should see nothing. If you see a shift, the reserved box changed before the new image arrived, which means the CSS aspect-ratio switched on the media query while the file was still in flight.
The sequence of events during that swap is worth internalising, because the safe window is narrower than it looks:
Common mistakes
1. Using orientation as a shorthand for “phone”
(orientation: portrait) matches any viewport where height is at least width, including a maximised browser on a pivoted 1440 × 2560 display and a Surface in portrait mode at 1368 px wide. Both then download a 480 px-wide crop and upscale it by 3×.
<!-- WRONG: matches a 1440 x 2560 pivoted desktop monitor. -->
<source media="(orientation: portrait)" srcset="/img/hero-portrait.jpg">
<!-- CORRECT: portrait shape AND a narrow layout column. -->
<source media="(max-width: 639px) and (max-aspect-ratio: 1/1)"
srcset="/img/hero-portrait.jpg">
2. Ordering narrow bands before wide ones
Because the first match wins, a max-width source written above a min-width source is unreachable in exactly the range you cared about — or, worse, claims everything.
<!-- WRONG: the unconditional source is first, so it matches at every width
and the two rows below it are dead markup. -->
<source srcset="/img/hero-sm.jpg">
<source media="(min-width: 1024px)" srcset="/img/hero-lg.jpg">
<source media="(min-width: 640px)" srcset="/img/hero-md.jpg">
<!-- CORRECT: widest first, unconditional last. -->
<source media="(min-width: 1024px)" srcset="/img/hero-lg.jpg">
<source media="(min-width: 640px)" srcset="/img/hero-md.jpg">
<source srcset="/img/hero-sm.jpg">
3. Leaving a one-pixel gap between adjacent bands
Viewport width is not always an integer. Browser zoom, text-size-adjust, and fractional device pixel ratios all produce widths like 639.5 px, which satisfies neither (max-width: 639px) nor (min-width: 640px).
<!-- WRONG: 639.5px matches neither rule and falls through unexpectedly. -->
<source media="(min-width: 640px)" srcset="/img/hero-md.jpg">
<source media="(max-width: 639px)" srcset="/img/hero-portrait.jpg">
<!-- CORRECT: rely on ordering, not on hand-computed exclusivity. The second
row needs no lower bound at all because the first row already owns
everything at 640px and above, fractional widths included. -->
<source media="(min-width: 640px)" srcset="/img/hero-md.jpg">
<source media="(max-aspect-ratio: 1/1)" srcset="/img/hero-portrait.jpg">
4. Declaring sizes on an art-directed source
sizes describes how wide the image will render so the browser can pick a width descriptor. On a source whose srcset has a single URL and no w descriptor, it does nothing; combined with descriptors it introduces a second selection mechanism that fights the media attribute. Density switching belongs to srcset and sizes for responsive layouts; art direction should stay on media alone unless you deliberately want both, in which case follow the arithmetic in how to calculate optimal sizes attribute values.
5. Gating on the viewport when the layout is component-driven
If the image lives in a card that is 300 px wide inside a 1400 px page, no viewport media query can describe its real constraint — the portrait crop is needed because the container is narrow, and the viewport says nothing about that. <picture> has no container-relative media feature. Either drive the crop from a server-rendered class that knows the layout slot, or move the sizing decision into CSS container queries for dynamic media sizing and use <picture> only for format negotiation, as covered in combining art direction with AVIF fallbacks.
Warning: a portrait crop that fires on rotation costs a full extra download every time the user turns the phone. If your analytics show heavy rotation on the page, prefer a crop that is acceptable in both orientations over a pair that is perfect in each — the bytes cost more than the composition gains.
Related
- Art Direction with the HTML Picture Element — the parent guide covering crop generation, source ordering and CLS mechanics end to end
- Combining Art Direction with AVIF Fallbacks — what happens to this source ladder once each crop also needs an AVIF and a WebP variant
- Mastering srcset and sizes for Responsive Layouts — the resolution-switching mechanism that
mediadeliberately does not duplicate - CSS Container Queries for Dynamic Media Sizing — the right tool when the crop depends on a component’s width rather than the viewport’s