Images are one of the most valuable assets on a business website. They show the work, products, people, quality and proof that help visitors understand and trust a business.
The problem is that images are also heavy. A common result is a slow website, noticeably reduced image quality, or simply using fewer images to avoid the problem. None is a good tradeoff when the images themselves are doing important work.
Instead, we use a sizing method to determine the appropriate image size. By combining the page layout, surrounding elements, screen size and a little maths, we give the browser the information it needs to choose the best possible image for each visitor’s device.
Sizing from the layout
The image width comes from the space available to it on the page. Depending on the layout, that can be the viewport, the main content area beside a sidebar, or a container that has already reached its max-width.
Padding, gaps and other occupied space reduce that width before the image gets its share.
For a 1000px viewport with 32px padding on each side:
1000 - 32 - 32 = 936px
The content width is 936px. If the image occupies all of it:
const padding = "32px"
const sizes = `calc(100vw - 2 * ${padding})`Equal columns
Equal columns are the simplest case: padding and gaps take their space first, and the columns divide whatever remains evenly.
For two columns inside a 1000px viewport with 32px page padding and a 32px gap:
1000 - 64 - 32 = 904px
The two columns share those 904px, giving each one 452px.
const padding = "32px"
const gap = "32px"
const sizes = `calc((100vw - 2 * ${padding} - ${gap}) / 2)`The final / 2 represents the two columns sharing the remaining width.
For three equal columns, there are two gaps:
const sizes = `calc((100vw - 2 * ${padding} - 2 * ${gap}) / 3)`Number of gaps = number of columns - 1.
Unequal fr columns
I tend to use grid for layouts and fr units to size columns. With unequal columns, we need to work out what fraction of the remaining width belongs to each column.
For:
grid-template-columns: 1fr 0.75fr;Multiplying both values by 4 removes the decimal:
1 × 4 = 4
0.75 × 4 = 3
So the ratio is 4:3, or seven total parts:
1frcolumn:4/70.75frcolumn:3/7
For an image in the 0.75fr column:
const sizes = `calc((100vw - 2 * ${padding} - ${gap}) * 3 / 7)`Some useful ratios:
| Grid | Ratio | First column | Second column |
|---|---|---|---|
1fr 1fr |
1:1 |
1/2 |
1/2 |
1fr .5fr |
2:1 |
2/3 |
1/3 |
1fr .75fr |
4:3 |
4/7 |
3/7 |
1fr .25fr |
4:1 |
4/5 |
1/5 |
The ratio comes from multiplying both values by whatever removes the decimal.
.5 → multiply by 2
.25 or .75 → multiply by 4
The resulting parts are added together to get the total.
Images spanning multiple grid columns
An image spanning several equal grid columns includes both the columns and the gaps between them.
In a six-column grid:
6 columns = 5 gaps
A two-column span contains:
2 columns + 1 gap
A three-column span contains:
3 columns + 2 gaps
The width of one column:
const columnWidth = `((100vw - 2 * ${padding} - 5 * ${gap}) / 6)`That is the grid width after page padding and all five gaps, divided between six equal columns.
For a two-column span:
const sizes = `calc(2 * ${columnWidth} + ${gap})`For a three-column span:
const sizes = `calc(3 * ${columnWidth} + 2 * ${gap})`Image width = column width × columns spanned + gaps inside the span.
Image-specific space
Some space belongs to the overall layout, while other space belongs only to the image itself. The order matters.
Page padding and grid gaps affect the space available before the image gets its column share. A border or picture frame around the image affects the image after that share has been calculated.
For an image in the 3/7 column:
const sizes = `calc((100vw - 2 * ${padding} - ${gap}) * 3 / 7 - 2 * ${pictureFrame})`Not:
const sizes = `calc((100vw - 2 * ${padding} - ${gap} - 2 * ${pictureFrame}) * 3 / 7)`The first calculation removes the full frame from the image after its column width is known.
The second incorrectly gives the frame the same 3/7 share as the column.
Layout-wide space is removed before calculating the image share. Image-specific space is removed afterward.
Images inside sliders
Sliders have another layer because each image is sized from the slider container, the number of visible slides and the space between them.
For:
slidesPerView: 1.25
spaceBetween: 16
1.25 means one complete slide plus a quarter of the next one is visible.
The slide width is:
(slider width - (slides per view - 1) × gap) / slides per view
For 1.25 slides:
const slidesPerView = 1.25
const sliderGap = "16px"
const sizes = `calc((100vw - (${slidesPerView} - 1) * ${sliderGap}) / ${slidesPerView})`This works out as:
(100vw - 0.25 × 16px) / 1.25
If the slider container also has horizontal padding:
const sizes = `calc((100vw - 2 * ${padding} - (${slidesPerView} - 1) * ${sliderGap}) / ${slidesPerView})`As with the other cases, 100vw only makes sense when the slider itself is working from the viewport width. A slider inside a column or capped container starts from that available width instead.
Breakpoints
Each sizes condition represents a point where the image-width calculation changes.
Common causes include:
- a different column structure
- different page padding or gap
- the image becoming visible or hidden
- a sidebar entering the layout
- the container reaching its maximum width
const sizes = `(min-width: LARGE) LARGE_SIZE, (min-width: DESKTOP) DESKTOP_SIZE, (min-width: TABLET) TABLET_SIZE, MOBILE_SIZE`
Conditions are evaluated from left to right.
A breakpoint that does not change anything affecting the image width does not need its own condition.
Container max-width
A container eventually stops following the viewport, so the image calculation has to stop following it too.
Before that point, the calculation may start from the viewport:
const sizes = `calc(100vw - ...)`
Once the container reaches its maximum width, the calculation can use that fixed width instead:
const sizes = `calc(80rem - ...)`
For example:
const sizes = `(min-width: CAP_POINT) calc((80rem - 2 * ${padding} - ${gap}) / 2), (min-width: 64rem) calc((100vw - 2 * ${padding} - ${gap}) / 2), ...`
The cap point is not necessarily the same number as the container max-width.
It is the viewport width at which that container actually has enough room to reach its maximum.
Sidebars
Anything sitting beside the main content can reduce the width available to it, even when it is not inside the image container.
For example:
Sidebar: 18rem
Main container: max-width: 80rem
While the main container is still fluid:
const sizes = `calc((100vw - 18rem - 2 * ${padding} - ${gap}) / 3)`With no additional space between the sidebar and main container, the main container reaches its 80rem maximum when the viewport reaches:
18rem + 80rem = 98rem
After that point, the calculation starts from the known 80rem container:
const sizes = `(min-width: 98rem) calc((80rem - 2 * ${padding} - ${gap}) / 3), ...`
The sidebar is not subtracted again because it sits beside the 80rem container, not inside it.
Fluid padding and gaps
Fluid padding and gaps can be carried directly into the image calculation.
const padding = "clamp(2rem, 1.95rem + 0.25vw, 2.25rem)"
const gap = "clamp(2rem, 1.95rem + 0.25vw, 2.25rem)"
const sizes = `calc((100vw - 2 * ${padding} - ${gap}) / 2)`No fixed approximation is needed.
Using the same values as the real layout keeps the image calculation aligned with it at intermediate viewport sizes.
Hidden images
An image hidden below a breakpoint can use an effectively nonexistent fallback size:
const sizes = `(min-width: 40rem) ${visibleSize}, 1px`
The 1px fallback describes the image as effectively having no useful display width below that breakpoint.
A lazy-loaded image that remains hidden may not be requested at all.
Source widths
sizes describes the expected rendered width. widths provides the source files the browser can choose between.
<Image
src={image}
widths={[320, 480, 640, 768, 960, 1200]}
sizes={sizes}
alt=""
/>The useful candidates depend on the range the image can realistically occupy, with additional room for higher-density displays.
A 300px rendered image roughly needs:
- DPR 1:
300px - DPR 2:
600px
There is little value generating very large sources for an image that can never need them.
Checking the result
The browser’s choice can be checked against the rendered width, calculated sizes, available source widths and device pixel ratio.
Device pixel ratio:
window.devicePixelRatio
At DPR 1, a 300px rendered image needs roughly 300px of source resolution. At DPR 2, the same rendered size can require roughly 600px.
Browser cache also matters during testing. A previously downloaded larger candidate may remain in use even after the calculation has changed.
Disabling cache and reloading gives a cleaner test of which source the browser would currently choose.
Quick reference
For every image:
- Find the width controlling the layout.
- Remove page padding and other layout-wide occupied space.
- Remove grid gaps.
- Work out the image’s share of the remaining width.
- Remove image-specific space such as borders or frames.
- Repeat for each breakpoint where that calculation changes.
- Switch to the fixed container width once it reaches its cap.
- Account for sidebars or other elements that delay that cap.
- Provide source widths covering the resulting sizes and expected DPRs.
Layout width → remove layout space → image share → remove image-specific space.