Responsive images
Send each device the image it needs with srcset, sizes and picture. A NASA photo made in six widths, loaded on a phone, a tablet, a laptop and a desktop in Chrome, Firefox and WebKit: one big file, srcset with and without sizes, a 2x cap, a picture with a square crop, density descriptors, and sizes=auto on a lazy image.
- 01Work out what a screen needsMultiply the width the page shows an image by the screen's pixel ratio, and find the file the browser will pick.
- 02Write srcset and sizesList widths with w descriptors and a sizes that copies your layout, or use x descriptors for an image that never changes size.
- 03Use picture, a cap and sizes=autoShow a different crop on narrow screens, give 3x screens a 2x file, and let lazy images measure their own slot.
One photo, four screens
Ravi is building a new page. At the top is a photo of two ospreys on their nest. A big photo like this, at the top of a page, is called the hero. Asha will open the page on her phone, on the bus. Ravi will check it on his desktop, in a browser window nearly five times as wide as hers.
Same page, same photo. Which of them needs the bigger file?
You might guess Ravi. His screen is so much bigger. But with the markup this lesson builds, Asha's phone downloaded a file 1200 pixels wide, and Ravi's desktop one 800 pixels wide. Two things explain it: how wide the page shows the photo, and how many dots each screen packs into that width.
Here are the two screens, drawn to one scale:
The photo is bigger on Ravi's screen. But Asha's screen packs 9 dots into the space where his has 1, so her phone needs more pixels across the photo. The next two parts explain the numbers under each screen.
In Image formats, Ravi chose the kind of file. This lesson chooses its size. Images and media, in the system design course, plans a whole site's images. Optimizing images, in the Next.js course, shows a framework doing this work for you.
How wide the page shows the photo
A web page is laid out in CSS pixels. That's the unit you write in CSS, as in width: 600px. Asha's phone gives the page a window 390 CSS pixels wide. Ravi's desktop gives it 1920.
The page doesn't stretch the photo across Ravi's whole window. On wide screens, its layout puts the photo in a column 600 px wide. On narrow screens, the photo fills the window, minus 16 px of padding on each side. So the photo is 358 px wide on Asha's phone, and 600 px wide on Ravi's desktop.
How many dots the screen packs in
A screen is a grid of tiny dots of light, called device pixels. Ravi's desktop draws each CSS pixel with one device pixel. Asha's phone is much sharper: it fits three across each CSS pixel, and three down. That number, device pixels across one CSS pixel, is the screen's device pixel ratio, or pixel ratio for short. Asha's phone is 3x, and Ravi's desktop 1x. Under the two screens in the picture above, the squares are one CSS pixel each, magnified.
MDN says the ratio tells the browser "how many of the screen's actual pixels should be used to draw a single CSS pixel". JavaScript can read it as window.devicePixelRatio.
You cross-stitch the same small bird onto two cushions, one stitch in each square of the weave. The second cushion's cloth is woven three times as fine. For the bird to fill the same space on it, you need a pattern with three times as many stitches across. Use the coarse pattern on the fine cloth, and each stitch has to cover three squares. The bird looks blocky.
- In real life: The space the bird fills on the cushion
- In the browser: The width the page shows the photo, in CSS pixels
- In real life: How finely the cloth is woven
- In the browser: The pixel ratio: device pixels across each CSS pixel
- In real life: Stitches across the pattern
- In the browser: Pixels across the image file
- In real life: A coarse pattern stretched over fine cloth
- In the browser: A small file on a 3x screen, which looks blurry
So a screen needs the photo's shown width times its pixel ratio, in pixels across. Here it is for both screens:
// The osprey photo on two screens. Widths are in CSS pixels.const phone = { shownWidth: 358, pixelRatio: 3 };const desktop = { shownWidth: 600, pixelRatio: 1 }; console.log(`Asha's phone needs ${phone.shownWidth * phone.pixelRatio} pixels across.`);console.log(`Ravi's desktop needs ${desktop.shownWidth * desktop.pixelRatio} pixels across.`);- Line 1 is a comment. The widths in this example are CSS pixels.
- Line 2 describes Asha's phone: it shows the photo 358 CSS pixels wide, at pixel ratio 3.
- Line 3 describes Ravi's desktop: 600 CSS pixels wide, at pixel ratio 1.
- Lines 5 and 6 multiply the two. They print
Asha's phone needs 1074 pixels across.andRavi's desktop needs 600 pixels across.
There's the answer. Asha's window is about a fifth as wide as Ravi's, but her phone needs almost twice as many pixels across the photo.
Why it matters: send too few pixels, and the browser has to stretch the file. MDN warns that a raster image, a fixed grid of pixels like a JPEG, "starts to look grainy when displayed larger than its original size". Send too many, and Asha waits for pixels her screen can't show. No single file is right for every screen.
How the course tested it
To see what browsers really do, the course built Ravi's page and loaded it many ways:
- The photo is NASA's. NASA's guidelines say its images "generally are not subject to copyright in the United States". The course saved it as JPEG at six widths: 400, 800, 1200, 1600, 2000 and 2400 pixels.
- The page has the hero, a small round avatar under it, and a second copy of the photo far down, in a 300 px sidebar.
- Chrome, Firefox and WebKit, the engine inside Safari, opened it on a phone, a tablet, a laptop and a desktop. Each noted which file every image came from.
- The hero was marked up five ways. The sections below build them one at a time.
- Chrome also loaded each way on the phone 5 times, over a link as slow as a slow 4G network: a 150 ms round trip and 1.6 Mbps down. It noted the LCP, or Largest Contentful Paint: the moment the largest thing on the screen, here the hero, was painted.
Here is the whole test at once. Pick a device and a markup. Start with Asha's phone, then try Ravi's desktop.
- The hero's filephoto-1200.jpg
- Its size67.2 kB
- Shown at358 CSS px wide
- Device pixels per CSS pixel3.4
- The screen's pixel ratio3
- The avatar's fileavatar-3x.jpg, 8.6 kB
- LCP on the slow link, in Chrome824 ms
- Chrome, Firefox and WebKit agreeyes
sizes tells the browser the photo is 600 px wide in windows of 1000 px or more. In smaller windows, it fills the window. The page itself shows the photo as wide as the window, minus 16 px of padding on each side. In windows of 1000 px or more, it shows it 600 px wide.
A screen needs the photo's shown width times its pixel ratio, in pixels across. Offer the browser the photo in several sizes, tell it how wide the photo will be shown, and it picks the smallest file that's big enough. The list of sizes is srcset. The shown width is sizes. For a different crop on narrow screens, you wrap them in <picture>.
srcset with width and density descriptors
Ravi's first version of the page is the simplest. It has one file, big enough for the biggest screen:
<img class="hero" src="/photo-2400.jpg" alt="Two ospreys on a nest" width="2400" height="1600">- Line 1 starts the
<img>. Its class,hero, is how the page's CSS finds it. - Line 2 names the file in
src: the 2400 px photo. Thenaltdescribes the photo for people who can't see it. Last,widthandheightgive the photo's shape, so the browser can keep space for it before the file arrives.
Every device downloaded that same file: 207.4 kB. Asha's phone needed 1,074 pixels across and got 2,400, more than twice as many. Ravi's desktop needed 600 and got four times as many.
Picture a shop that sells its shirt in one size only: the biggest. Everyone can get it on, but most people swim in it, and they all pay for the extra cloth. A better shop stocks the same shirt in six sizes, each with its size on the label. Each customer takes the smallest one that fits.
- In real life: One giant shirt for everyone
- In HTML: One 2400 px file for every device
- In real life: The same shirt in six sizes
- In HTML: Six files of the photo, listed in
srcset - In real life: The size on each label
- In HTML: A width descriptor, like
1200w - In real life: Taking the smallest one that fits
- In HTML: The browser picking the smallest file that's big enough
The rail of sizes is the srcset attribute. Here is the hero with it:
<img class="hero" srcset="/photo-400.jpg 400w, /photo-800.jpg 800w, /photo-1200.jpg 1200w, /photo-1600.jpg 1600w, /photo-2000.jpg 2000w, /photo-2400.jpg 2400w" src="/photo-2400.jpg" alt="Two ospreys on a nest" width="2400" height="1600">- Line 2 is
srcset: a list of files, separated by commas. After each file comes its width descriptor, the file's width in pixels followed byw. So/photo-1200.jpg 1200wsays "this file is 1200 pixels wide". MDN: it "must match the intrinsic width of the referenced image". - Line 3 keeps
src, for browsers that don't knowsrcset. Browsers that do know it skipsrchere. The HTML standard: "the src attribute is ignored when the srcset attribute uses w descriptors."
How the browser picks a file
The browser now has six files. To pick one, it first needs the slot: how wide the image will be shown, in CSS pixels. MDN uses that word for the space an image fills. With only srcset, the browser assumes the image fills the whole window: 390 on Asha's phone.
Then it divides each file's width by the slot. The answer is how many device pixels that file would put across each CSS pixel. The HTML standard calls it the file's pixel density, and picks with one rule. In it, the candidates are the files, sorted from the lowest density to the highest. devicePixelRatio is the screen's pixel ratio:
"Let selectedSource be the first image source in candidates whose associated pixel density is greater than or equal to devicePixelRatio, if there is one; otherwise the last image source in candidates."
In plain words: take the first file with at least as many device pixels per CSS pixel as the screen has. If no file has enough, take the biggest. Here is that rule, run for Asha's phone:
// The six files, and Asha's phone: a window 390 CSS pixels wide, at pixel ratio 3.const fileWidths = [400, 800, 1200, 1600, 2000, 2400];const slotWidth = 390;const pixelRatio = 3; const densities = fileWidths.map((width) => width / slotWidth);console.log(`Densities in a ${slotWidth} px slot: ${densities.map((density) => density.toFixed(2)).join(", ")}`); const first = densities.findIndex((density) => density >= pixelRatio);console.log(`The first one of at least ${pixelRatio} is the ${fileWidths[first]} px file.`);- Lines 2 to 4 are the six widths, the slot (Asha's whole window, 390 CSS pixels) and her pixel ratio, 3.
- Line 6 divides each width by the slot. Line 7 prints
Densities in a 390 px slot: 1.03, 2.05, 3.08, 4.10, 5.13, 6.15. - Line 9 finds the first density of at least 3. Line 10 prints
The first one of at least 3 is the 1200 px file.
That's the file all three engines picked for Asha's phone. There's a shorter way to say the same rule: multiply the slot by the pixel ratio, and take the smallest file at least that wide. For Asha, 390 × 3 = 1,170, so the 1200 file. Here is what each device took:
// The hero's file on each device, with srcset and no sizes. Chrome, Firefox and WebKit picked the same.const picks = { "phone, 390 wide at 3x": 1200, "tablet, 820 wide at 2x": 2000, "laptop, 1440 wide at 2x": 2400, "desktop, 1920 wide at 1x": 2000,}; for (const [device, width] of Object.entries(picks)) console.log(`${device}: photo-${width}.jpg`);- Lines 3 to 6 are recorded: the file each device's hero came from.
- Line 9 prints one line per device. The first is
phone, 390 wide at 3x: photo-1200.jpg. The last isdesktop, 1920 wide at 1x: photo-2000.jpg.
Each one follows the rule. The tablet's window is 820 wide at 2x: 820 × 2 = 1,640, so it took the 2000 file. Ravi's desktop: 1920 × 1 = 1,920, so 2000 again. The laptop asked for 1440 × 2 = 2,880. No file is that wide, so it took the last one, photo-2400.jpg.
Something is off, though. The laptop and the desktop show the photo only 600 px wide, yet they took files for their whole window. The next section fixes that. First, one more kind of descriptor.
Density descriptors, for an image that never changes size
The avatar under the hero is a small round photo of one osprey's head. It's 96 CSS pixels wide on every device. Its slot never changes, so only the pixel ratio matters. For an image like this, srcset can name screens instead of widths:
<img class="avatar" srcset="/avatar-1x.jpg 1x, /avatar-2x.jpg 2x, /avatar-3x.jpg 3x" src="/avatar-1x.jpg" alt="An osprey" width="96" height="96">- Line 2 lists three files. After each comes a density descriptor: a number followed by
x.2xmeans "this file is for a screen with pixel ratio 2". - Line 3 sets
widthandheightto 96. The page's CSS shows the avatar 96 px wide, too.
There's no sizes. The files already say which screen each is for, so the browser has nothing to divide. The 2x file is 192 pixels wide, two for each of the 96 CSS pixels, and the 3x file is 288. Here is what each device took:
// The 96 px avatar: the file each device took (all three engines agreed), and each file's bytes.const picked = { phone: "3x", tablet: "2x", laptop: "2x", desktop: "1x" };const bytes = { "1x": 1867, "2x": 4869, "3x": 8598 }; for (const [device, file] of Object.entries(picked)) console.log(`${device}: avatar-${file}.jpg, ${(bytes[file] / 1000).toFixed(1)} kB`);- Line 2 is recorded: the file each device took. All three engines agreed.
- Line 3 is each file's size in bytes.
- Line 5 prints
phone: avatar-3x.jpg, 8.6 kB,tablet: avatar-2x.jpg, 4.9 kB,laptop: avatar-2x.jpg, 4.9 kBanddesktop: avatar-1x.jpg, 1.9 kB.
Each device took the file for its own pixel ratio. So which kind should you use? The HTML standard says the x descriptor "is not appropriate when the rendered size of the image depends on the viewport width". The viewport is the window. So:
- Use
xfor an image that's the same size on every screen, like an avatar or a logo. - Use
wfor an image whose width follows the window, like the hero.
Why it matters: each screen got all the sharpness it can show, and paid for no more.
Writing a correct sizes attribute
Why did the laptop take a 2400 px file for a photo shown 600 px wide? Because of when the browser chooses. It picks the file as soon as it reads the <img> tag, before it has the page's CSS. You met this in Load the LCP resource early: the preload scanner spots an <img> in the HTML and starts fetching it at once.
So the browser can't know the layout yet. web.dev: "viewport size is the only layout information the browser has when the image request is made." Without help, it plays safe and assumes the image is as wide as the window. MDN: "If the sizes attribute is not provided, it has a default value of 100vw (the viewport width)." A vw is one hundredth of the window's width, so 100vw is the whole window.
You phone the shirt shop and ask them to put a shirt aside for you. The assistant can't see you, so they ask your size. Say "medium", and a medium is waiting at the till. Say nothing, and they play safe: they put aside a shirt for someone as broad as the doorway.
- In real life: The assistant, who can't see you yet
- In HTML: The browser, choosing before it has the CSS
- In real life: Telling them your size
- In HTML: The
sizesattribute - In real life: Saying nothing
- In HTML: No
sizes: the browser assumes100vw - In real life: A shirt for someone as broad as the doorway
- In HTML: A file for an image as wide as the window
sizes is how the page says its size. web.dev calls it shorthand for "here is the size of the rendered image in the layout." Here is the hero with it, on line 3:
<img class="hero" srcset="/photo-400.jpg 400w, /photo-800.jpg 800w, /photo-1200.jpg 1200w, /photo-1600.jpg 1600w, /photo-2000.jpg 2000w, /photo-2400.jpg 2400w" sizes="(min-width: 1000px) 600px, 100vw" src="/photo-2400.jpg" alt="Two ospreys on a nest" width="2400" height="1600">- Line 3 is
sizes. It says: in a window at least 1000 px wide, the photo is shown 600 px wide. In any other window, it fills the window. - Lines 2 and 4 are the same as before.
How to read a sizes list
A sizes value is a list, separated by commas. Each item except the last has two parts:
- a media condition: a test about the window, in brackets.
(min-width: 1000px)means "the window is at least 1000 px wide"; - a width: how wide the image is shown when that test passes.
The last item, 100vw, has no test. It's the width to use when no test passes. The browser reads the list from the left and stops at the first test that passes. MDN: "The browser ignores everything after the first matching condition, so be careful how you order the media conditions." Here is that reading, for the four windows:
// The hero's sizes, "(min-width: 1000px) 600px, 100vw", read for four window widths.const slotWidth = (windowWidth) => (windowWidth >= 1000 ? 600 : windowWidth); for (const windowWidth of [390, 820, 1440, 1920]) { console.log(`A window ${windowWidth} px wide: the slot is ${slotWidth(windowWidth)} px.`);}- Line 2 is the hero's
sizes, quoted on line 1, written as a function. In a window at least 1000 px wide, the slot is 600. Otherwise, it's the window's own width. - Lines 4 to 6 run it for the four windows. The first line printed is
A window 390 px wide: the slot is 390 px.The last isA window 1920 px wide: the slot is 600 px.
Where does 600 come from? From the page's CSS. sizes repeats, in the HTML, what the CSS will do once it arrives:
.hero, picture img { display: block; width: 100%; height: auto; }
@media (min-width: 1000px) { .hero, picture { width: 600px; } }- Line 1 makes the hero as wide as the page's content: the window, minus the page's 16 px of padding on each side.
- Line 2 makes it 600 px wide in windows at least 1000 px wide.
Then the browser multiplies the slot by the pixel ratio, as before. The laptop now asks for 600 × 2 = 1,200 pixels and takes photo-1200.jpg. Ravi's desktop asks for 600 × 1 = 600 and takes photo-800.jpg. Here is what that saved:
// The hero on the two big screens, where the layout shows it 600 px wide: its bytes without sizes, and with.const bytes = { laptop: { noSizes: 207388, sizes: 67243 }, desktop: { noSizes: 154427, sizes: 34637 },}; for (const [device, { noSizes, sizes }] of Object.entries(bytes)) console.log(`${device}: ${(noSizes / 1000).toFixed(1)} kB → ${(sizes / 1000).toFixed(1)} kB`);- Lines 3 and 4 are recorded: the hero's bytes on the laptop and the desktop, without
sizesand with it. - Line 7 prints
laptop: 207.4 kB → 67.2 kBanddesktop: 154.4 kB → 34.6 kB.
Ravi's desktop now downloads less than a quarter of the bytes it did. The phone and the tablet didn't change. Their windows are under 1000 px, so sizes says 100vw, which is what the browser assumed anyway. Here are all three versions of the page side by side:
Close enough is fine
sizes doesn't have to be exact. On Asha's phone, it said the photo fills the window: 390 px. The CSS really showed it 358 px wide, inside the padding. So the browser asked for 390 × 3 = 1,170 pixels instead of 1,074, and still took photo-1200.jpg. The HTML standard says these sizes "do not necessarily have to match up exactly with the actual image width as specified in the CSS."
For a closer match, a width in sizes can use calc(), like calc(100vw - 32px) for the window minus 32 px of padding.
Why it matters: a sizes that's a little too wide can only make the browser take a bigger file than it needs. One that's too narrow can make it take a file with too few pixels, and the photo looks soft. If you can't be exact, be a little too wide.
picture for formats and art direction
On Asha's phone, the wide photo shrinks to a strip 358 px wide and 239 px tall. The two birds shrink with it. A square crop around the birds, shown the full width of her screen, would show them bigger.
Showing a different crop on different screens is called art direction. MDN: "the art direction problem involves wanting to change the image displayed to suit different image display sizes." srcset can't do it. web.dev: "srcset isn't a method for swapping image sources at specific breakpoints, and it isn't meant to swap one image for another."
You have a wide photo of the whole family on a beach. For the hallway, you print it big, with the whole scene. For a small frame on your desk, you don't just shrink it, or everyone would be a speck. You crop it to a close-up of the faces, and print that.
- In real life: The whole beach scene, printed big
- In HTML: The wide photo, on big screens
- In real life: The close-up of the faces
- In HTML: The square crop of the two birds
- In real life: Choosing the print by the size of the frame
- In HTML: A
<source>chosen bymedia, a test about the window
In Image formats, Ravi used <picture> to offer newer formats. Each <source> inside it was chosen by its type. A <source> can also be chosen by media: the same kind of window test as in sizes. Here is the hero with a square crop for narrow windows:
<picture> <source media="(max-width: 699px)" srcset="/square-400.jpg 400w, /square-800.jpg 800w, /square-1200.jpg 1200w" sizes="100vw" width="1200" height="1200"> <img class="hero" srcset="/photo-400.jpg 400w, /photo-800.jpg 800w, /photo-1200.jpg 1200w, /photo-1600.jpg 1600w, /photo-2000.jpg 2000w, /photo-2400.jpg 2400w" sizes="(min-width: 1000px) 600px, 100vw" src="/photo-2400.jpg" alt="Two ospreys on a nest" width="2400" height="1600"></picture>- Lines 1 and 4 open and close the
<picture>. - Line 2 is a
<source>for windows up to 699 px wide. It offers the square crop at three widths, shown the full window wide. Itswidthandheightgive the square's shape. - Line 3 is the
<img>, with the wide photo'ssrcsetandsizesfrom before. Wider windows use it, and so do browsers that don't know<picture>.
The browser reads the sources in order and takes the first whose media test passes. The HTML standard: "If child has a media attribute, and its value does not match the environment, continue to the next child." If no source passes, it uses the <img>'s own files. Either way, the <img> is what sits on the page. A source only changes which file it shows.
Inside the chosen source, srcset and sizes work exactly as before. On Asha's phone, all three engines took square-1200.jpg: 390 × 3 = 1,170, and 1200 is the first width that's enough. The other windows are at least 700 px wide, so they took the same files as plain srcset and sizes. Here are the two crops:
Windows 700 px and wider: the photo
Windows up to 699 px: the square cropThe crop cost Asha some speed. Shown 358 by 358 instead of 358 by 239, it covers more of her screen, so it needs more pixels. It was 113.0 kB against 67.2, and the phone's LCP was 1,048 ms against 824.
Why it matters: art direction is for what people see, not for saving bytes. Use it when a shrunk photo loses its subject.
Capping device pixel ratio
Asha's phone asks for three device pixels across every CSS pixel: nine in each square, where a 2x screen asks for four. The bytes follow the pixels. photo-1200.jpg was 67.2 kB, and photo-800.jpg 34.6 kB, about half.
Can Asha see the difference between those two files on her phone? The course can measure the bytes. Whether the extra sharpness shows is something to check with your own eyes, on your own photos.
You can choose to cap the pixel ratio: give 3x screens the file a 2x screen would get. There's no attribute for that. The trick is a sizes that tells 3x screens the slot is smaller than it is. It's like telling the shop you're a size smaller than you are: the shirt is a little snug, and it costs less.
sizes="(min-resolution: 3dppx) and (max-width: 999px) 67vw, (min-width: 1000px) 600px, 100vw"- The first item has two tests joined by
and.(min-resolution: 3dppx)passes on screens with at least 3 device pixels per CSS pixel. MDN definesdppxas "the number of dots per px unit". (max-width: 999px)passes in windows narrower than 1000 px. When both pass, the slot is67vw: 67 hundredths of the window, about two thirds.- The other two items are the hero's
sizesfrom before.
// Asha's phone with the capped sizes: the slot is 67vw of a 390 px window, at pixel ratio 3.const slotWidth = 390 * 0.67;const needed = slotWidth * 3; console.log(`The slot is ${slotWidth.toFixed(1)} px, so the phone asks for ${Math.round(needed)} pixels.`);console.log(`A 2x phone of the same width would ask for ${390 * 2}.`);- Line 2 works out 67vw of Asha's 390 px window.
- Line 3 multiplies it by her pixel ratio, 3.
- Line 5 prints
The slot is 261.3 px, so the phone asks for 784 pixels. - Line 6 prints
A 2x phone of the same width would ask for 780.
Both are under 800. So Asha's phone now takes photo-800.jpg, the same file a 2x phone would. Here is what each markup did on the slow link:
// Asha's phone on the slow link, in Chrome: the hero's file, its bytes, and the median LCP of 5 loads, in ms.const phone = { "one file": ["photo-2400.jpg", 207388, 1524], "srcset and sizes": ["photo-1200.jpg", 67243, 824], "capped at 2x": ["photo-800.jpg", 34637, 652],}; for (const [markup, [file, bytes, lcp]] of Object.entries(phone)) console.log(`${markup}: ${file}, ${(bytes / 1000).toFixed(1)} kB, LCP ${lcp} ms`);- Lines 3 to 5 are recorded: the hero's file, its bytes, and the median LCP of 5 loads. The median is the middle value once the loads are sorted.
- Line 8 prints
one file: photo-2400.jpg, 207.4 kB, LCP 1524 ms,srcset and sizes: photo-1200.jpg, 67.2 kB, LCP 824 msandcapped at 2x: photo-800.jpg, 34.6 kB, LCP 652 ms.
Every step down saved time. Going from one file to srcset and sizes brought the photo from 1,524 ms to 824. The cap brought it to 652.
The cap has a price. sizes no longer tells the truth about the layout, and the browser believes it. It took the 800 px file to be 261 CSS pixels wide, because that's what sizes said. Only the page's CSS stretched it to 358. MDN says that without CSS, the browser shows an image at the width sizes gives, "regardless of the physical pixel dimensions of the downloaded file."
Why it matters: only cap an image whose width the CSS sets. And look at your photos on a 3x screen before you decide.
sizes=auto with lazy loading
sizes has one weak spot: it's a copy of the layout. When Ravi changes his CSS, he has to remember to change every sizes too. For some images, the browser can measure the slot itself.
Those images must be lazy-loaded. loading="lazy" tells the browser to wait until the image is close to the window. MDN: "The auto keyword indicates that the browser should use the expected layout width of the element to select the image to display." And: "This is only valid when combined with loading="lazy", as the page is expected to already have CSS and other layout information by the time the image loads."
Back to the shirt shop: this time you walk in. The assistant can see you, so you don't need to say your size. Here is the sidebar photo, far down Ravi's page:
<img class="side" loading="lazy" srcset="/side-400.jpg 400w, /side-800.jpg 800w, /side-1200.jpg 1200w, /side-1600.jpg 1600w, /side-2000.jpg 2000w, /side-2400.jpg 2400w" sizes="auto" src="/side-2400.jpg" alt="Two ospreys on a nest" width="2400" height="1600">- Line 2 makes it lazy.
- Line 3 offers the same six widths, under names of their own. The reason comes below.
- Line 4 is
sizes="auto": measure the slot. The page's CSS shows this photo 300 px wide on every device.
// A lazy photo far down the page, shown 300 px wide, scrolled into view: the file each device took, the same in all three engines.const sidebar = { phone: { auto: 1200, vw: 1200 }, tablet: { auto: 800, vw: 2000 }, laptop: { auto: 800, vw: 2400 }, desktop: { auto: 400, vw: 2000 },}; for (const [device, { auto, vw }] of Object.entries(sidebar)) console.log(`${device}: sizes="auto" side-${auto}.jpg, sizes="100vw" side-${vw}.jpg`);- Lines 3 to 6 are recorded: the file each device took once the photo was scrolled into view, with
autoand with100vw. - Line 9 prints one line per device. The first is
phone: sizes="auto" side-1200.jpg, sizes="100vw" side-1200.jpg. The last isdesktop: sizes="auto" side-400.jpg, sizes="100vw" side-2000.jpg.
With auto, each device took a file for a 300 px slot at its own pixel ratio. Ravi's desktop asked for 300 × 1 = 300, so 400. The laptop asked for 300 × 2 = 600, so 800. With 100vw, each took a file for its whole window, up to 2400 px on the laptop. On Asha's phone, both came out the same: 390 × 3 = 1,170 and 300 × 3 = 900 both lead to the 1200 file.
Browsers that don't know auto need a fallback. MDN: "For better backward compatibility with browsers that do not support auto, you can include fallback sizes after auto in the sizes attribute." For the sidebar, that's sizes="auto, 300px". The two crops in the picture section are built this way.
One surprise came up while recording. At first, the sidebar used the hero's file names. Chrome then showed it a bigger file it had already loaded for the hero, whatever sizes said. The standard allows that: if a sharper file's "image data is already available without a network request", then "the user agent may set selectedSource to one such image source." So the sidebar's files got names of their own.
MDN marks Safari's support as partial: it distorts SVG images that also use object-fit.
Why it matters: on a lazy image, sizes="auto" can't go stale, because it measures the layout you really have. Keep it off the hero, though. The hero should never be lazy, as Load the LCP resource early showed.
What will it download?
Each kind of image on Ravi's page needed its own markup. Read each row across: the kind of image, the markup that fits it, and what happened on Ravi's page.
| When the image… | Use | On Ravi's page |
|---|---|---|
| Changes width with the window | srcset with w descriptors, and a sizes that copies the layout. | The laptop took a 1200 px file for its 600 px slot, not the 2400. |
| Is the same size on every screen | srcset with x descriptors, and no sizes. | Asha's phone took the 3x avatar, and Ravi's desktop the 1x. |
| Needs a different crop on narrow screens | <picture>, with a <source> chosen by media. | Asha's phone took the square crop. Wider windows took the wide photo. |
| Is in a newer format, with a fallback | <picture>, with each <source> chosen by type. | The page used JPEG only. Image formats covers this case. |
| Is lazy-loaded, far down the page | sizes="auto", with a normal list after it. | The 300 px sidebar photo got files for a 300 px slot. |
Now sort these images. For each one, does it download about what its slot needs, or more?
- srcset with a sizes that matches the layout
- A 96 px avatar with 1x, 2x and 3x files
- A lazy image with sizes="auto"
- A <picture> with a square crop for narrow screens
- One 2400 px file for every screen
- srcset without sizes, on a 2x laptop, for a 600 px slot
- sizes="100vw" on a 300 px sidebar photo
- srcset without sizes, on a 1x desktop, for a 600 px slot
Sort each image by whether it downloads about what its slot needs.
Common mix-ups
- "srcset alone is enough." No. Without
sizes, the browser assumes the image fills the window. The laptop took the 2400 px file for a 600 px slot: 207.4 kB instead of 67.2. - "The browser picks by the window's width." No. It picks by the slot's width times the pixel ratio. With
sizes, Asha's 390 px phone took a 1200 px file, and Ravi's 1920 px desktop an 800 px one. - "sizes can be a percentage, like CSS." No. MDN: "you cannot use a percentage as the slot width." Use
vw,pxorcalc(). - "Phones need the smallest files." No. Their screens are small, but dense. Asha's 3x phone needed 1,074 pixels across the hero, and Ravi's 1x desktop only 600.
- "sizes="auto" works on any image." No. Only on lazy ones. On other images, the browser skips
autoand reads the rest of the list.
Practice exercises
Try each one before you open the hints.
What does the last line print?
// The hero on the two big screens, and on the tablet, 820 wide: its bytes without sizes, and with.
const bytes = {
laptop: { noSizes: 207388, sizes: 67243 },
desktop: { noSizes: 154427, sizes: 34637 },
tablet: { noSizes: 154427, sizes: 154427 },
};
for (const [device, { noSizes, sizes }] of Object.entries(bytes)) console.log(`${device}: ${(noSizes / 1000).toFixed(1)} kB → ${(sizes / 1000).toFixed(1)} kB`);It prints tablet: 154.4 kB → 154.4 kB. The tablet's window is under 1000 px, so sizes says the photo fills the window. That's the same as having no sizes, so the tablet took the same 2000 px file both times.
What does the last line print?
// The 96 px avatar: the file each device took (all three engines agreed), and each file's bytes.
const picked = { phone: "3x", tablet: "2x", laptop: "2x", desktop: "1x" };
const bytes = { "1x": 1867, "2x": 4869, "3x": 8598 };
for (const [device, file] of Object.entries(picked)) console.log(`${device}: avatar-${file}.jpg, ${(bytes[file] / 1000).toFixed(1)} kB`);
console.log(`3x is ${(bytes["3x"] / bytes["1x"]).toFixed(1)}× the bytes of 1x, for ${3 * 3}× the pixels`);It prints 3x is 4.6× the bytes of 1x, for 9× the pixels. The 3x file is three times as wide and three times as tall, so it has nine times the pixels. Its bytes grew less than that.
On the phone, with one 2400 px file, how many device pixels did the hero have for each CSS pixel?
6.7: 2400 pixels spread across 358 CSS pixels. That's more than twice what Asha's 3x screen can show.
On the phone, capped at 2x, how many device pixels did the hero have for each CSS pixel?
2.2: 800 pixels spread across 358 CSS pixels. That's about what a 2x screen needs, not the 3 Asha's phone could show.
Can one srcset mix width descriptors, like 800w, with density descriptors, like 2x?
No. MDN: "It is incorrect to mix width descriptors and pixel density descriptors in the same srcset attribute." Pick one kind: w for an image whose width changes, x for one that doesn't.
When srcset uses density descriptors, the browser counts src as one more file to choose from. Which descriptor does it give it?
1x. MDN: browsers "consider the URL in the src attribute (if present) as a candidate, and assign it a default descriptor of 1x". So src should be the file meant for 1x screens, as the avatar's is.
Check your understanding
For each question, find the slot's width, multiply it by the pixel ratio, and look for the smallest file that's enough.
Question 1 of 7In this example, what does the third line print?
Read the code, then predict// The hero's file on each device, with srcset and no sizes. Chrome, Firefox and WebKit picked the same. const picks = { "phone, 390 wide at 3x": 1200, "tablet, 820 wide at 2x": 2000, "laptop, 1440 wide at 2x": 2400, "desktop, 1920 wide at 1x": 2000, }; for (const [device, width] of Object.entries(picks)) console.log(`${device}: photo-${width}.jpg`);Choose an answer to see the explanation.
Question 2 of 7What does the third line of the slot example print?
Read the code, then predict// The hero's sizes, "(min-width: 1000px) 600px, 100vw", read for four window widths. const slotWidth = (windowWidth) => (windowWidth >= 1000 ? 600 : windowWidth); for (const windowWidth of [390, 820, 1440, 1920]) { console.log(`A window ${windowWidth} px wide: the slot is ${slotWidth(windowWidth)} px.`); }Choose an answer to see the explanation.
Question 3 of 7What does the last line of the bytes example print?
Read the code, then predict// The hero on the two big screens, where the layout shows it 600 px wide: its bytes without sizes, and with. const bytes = { laptop: { noSizes: 207388, sizes: 67243 }, desktop: { noSizes: 154427, sizes: 34637 }, }; for (const [device, { noSizes, sizes }] of Object.entries(bytes)) console.log(`${device}: ${(noSizes / 1000).toFixed(1)} kB → ${(sizes / 1000).toFixed(1)} kB`);Choose an answer to see the explanation.
Question 4 of 7An <img> has srcset with width descriptors and no sizes. What does the browser assume about its slot?
Choose an answer to see the explanation.
Question 5 of 7Why does a phone 390 CSS pixels wide pick a 1200 px file for a photo that fills the window?
Choose an answer to see the explanation.
Question 6 of 7When is <picture> the right tool, rather than srcset on an <img>?
Choose an answer to see the explanation.
Question 7 of 7Where does sizes="auto" work?
Choose an answer to see the explanation.
Key takeaways
- A screen needs the image's shown width times its pixel ratio, in pixels across. A small 3x phone can need more than a big 1x desktop.
- List the widths you have in srcset, with w descriptors. The browser takes the smallest file that's big enough.
- Give sizes the width the layout shows the image. Without it, the browser assumes the whole window.
- For an image that's the same size on every screen, use x descriptors: 1x, 2x, 3x.
- Use <picture> with media for a different crop, and with type for newer formats.
- Capping 3x screens at 2x about halved the phone's bytes. Look at your photos before you decide.
- On lazy images, sizes="auto" measures the layout for you. Put a normal list after it.
Remember the one-liner.
Each screen needs the shown width times its pixel ratio, and not much more.
Sources
The facts on this page come from these docs:
- Window: devicePixelRatio property, MDN.
- Using responsive images in HTML, MDN.
- NASA Images and Media Usage Guidelines, NASA.
- <img>: The Image Embed element, MDN.
- HTML Standard: Images, WHATWG.
- Descriptive syntaxes, web.dev, Learn Images.
- resolution (media feature), MDN.
- <resolution>, MDN.
Coming next: Loading images well, also in Loading content fast: LCP. Ravi's page now sends each screen the right file. Next, you'll decide when each image loads: lazy loading, decoding, priorities, placeholders, and keeping space for every image.