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-width describe 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.
  • orientation is defined purely as height >= width for the viewport. It is a proxy for width that stops being one on tablets and pivoted desktop displays.
  • aspect-ratio (as max-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:

Where Width Bands and Orientation Disagree A scatter plot with viewport width on the horizontal axis and viewport height on the vertical axis. Dashed vertical lines mark the 640 and 1024 pixel breakpoints. A diagonal line marks the one-to-one aspect ratio, above which a viewport is portrait. Six real device viewports are plotted, showing that a rotated phone and a narrow desktop window fall on opposite sides of the diagonal from where their width band would suggest. height (px) below 640 640 – 1023 1024 and up 1400 700 0 0 640 1024 1600 viewport width (px) iPhone 15 upright — 393 × 852 same phone rotated — 852 × 393 iPad Air upright — 820 × 1180 iPad Air rotated — 1180 × 820 pivoted desktop — 680 × 1300 laptop maximised — 1512 × 850 aspect-ratio 1/1 — portrait above the line, landscape below The rotated phone sits in the 640–1023 band but is emphatically landscape; the pivoted desktop is portrait at 680 px wide. Any rule written in one feature alone mis-serves one of those two points. Combining features excludes both.

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:

Declared Ranges Versus Effective Ranges Four horizontal bars stacked above a viewport width axis. Each bar shows the range a source's media query declares. Below them, a second set of bars shows the effective range each source actually owns once earlier sources in document order have claimed their overlap. The portrait source's declared range extends to 639 pixels but its effective range is unchanged because nothing earlier overlaps it. Declared by the media attribute 1. min-width 1024 2. min-width 640 3. portrait compound 4. no media attribute Effective after first-match-wins 1. hero-lg 2. hero-md 3. hero-portrait 4. hero-sm 0 640 1024 1600 Rows 3 and 4 share a range; row 3 wins only when its aspect clause also passes, so row 4 catches rotated phones.

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:

What Happens on Rotation A horizontal timeline running from zero to about four hundred milliseconds. Events are marked in order: orientation change fires, media conditions are re-evaluated, the update-the-image-data algorithm selects a new source, a network request is issued for the new crop, the response arrives, the image is decoded and painted. A shaded band spanning the network request marks the window in which a mismatched CSS aspect-ratio would produce a layout shift. layout shift risk window — box already resized, bytes not yet here 0 ms rotate media re-eval ~8 ms new source picked ~12 ms request issued ~18 ms download portrait crop — 31 KB over 4G bytes complete ~290 ms decode new crop painted ~340 ms The old crop stays painted for the whole window; nothing shifts as long as the reserved box does not change under it. A CSS aspect-ratio that flips at ~8 ms while the file lands at ~340 ms is a 330 ms shift you will see in field CLS.

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.