CSS Module 8 – Backgrounds, borders, images and effects
Images, video and SVG in CSS
Fit images and video with object-fit and aspect-ratio, pick sharp CSS backgrounds with image-set(), keep pixel art crisp and colour SVG icons.
What you will learn
- Size images and video inside boxes with object-fit, object-position and aspect-ratio
- Choose resolution-appropriate CSS background images with image-set()
- Use currentColor, fill and stroke to colour inline SVG icons
- Render pixel art and QR codes sharply with image-rendering
- Choose between inline SVG, an SVG image and a CSS mask for icons
Before you start
What you’ll learn · Before you start
What you will learn
- Size images and video inside boxes with object-fit, object-position and aspect-ratio
- Choose resolution-appropriate CSS background images with image-set()
- Use currentColor, fill and stroke to colour inline SVG icons
- Render pixel art and QR codes sharply with image-rendering
- Choose between inline SVG, an SVG image and a CSS mask for icons
Before you start
On this page
Images, videos and embedded frames are replaced elements: CSS sizes their box, but what is drawn inside comes from
a file. Two properties decide how the picture fits its box. object-fit chooses whether it is stretched
(fill, the default), letterboxed (contain), cropped (cover) or left at its natural size (none), and
object-position chooses which part stays in view when it is cropped. aspect-ratio gives a box its shape
before anything loads, which is how video players keep 16:9. For CSS background images, image-set() lets the
browser pick a sharper file on a high-density screen. And SVG icons follow the text colour with
fill: currentColor, but only when the SVG is inline in the page. The lab results below cover all of it in Chromium
153, Firefox 155 and WebKit 26.6, down to how icons behave in forced colours mode.
A box and a picture inside it
An <img> has a natural size, the size of its file, and a natural ratio. Without CSS the box takes that size; with
inline-size: 100% and block-size: auto it shrinks with its container and keeps the ratio. The width and height
attributes give the browser that ratio before the file arrives, so it can reserve the space, as the sizing lesson
showed; for <video> they do the same.
An image is an inline element, so it sits on the line’s baseline like a letter, and the line keeps room below the
baseline for descenders. That is the mysterious gap under images: the lab put a 100 px tall image alone in a
<div> with font: 16px/1.5 and the <div> was 106 px tall in Chromium and Firefox, and 105.95 px in WebKit. With
display: block on the image, the gap disappeared in all three, and so it did with vertical-align: middle or
bottom, which keep the image inline.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Images, video and SVG lab</title>
<link rel="stylesheet" href="thumbnails.css">
<style>
body { margin: 0; font: 16px/1.5 system-ui, sans-serif; background: #fff; }
/* 1. object-fit and object-position on a 600 x 300 image of three bands (red, green, blue) in 150 x 150 boxes. */
.fit { position: absolute; top: 0; inline-size: 150px; block-size: 150px; background: #eee; }
.fill { left: 0; object-fit: fill; }
.contain { left: 160px; object-fit: contain; }
.cover { left: 320px; object-fit: cover; }
.cover-left { left: 480px; object-fit: cover; object-position: 0% 50%; }
.cover-right { left: 640px; object-fit: cover; object-position: 100% 50%; }
.none { left: 800px; object-fit: none; }
.scale-down { left: 960px; object-fit: scale-down; }
/* 2. An image is inline, so it sits on the text baseline and leaves a gap under it. */
.frame { position: absolute; left: 0; top: 180px; inline-size: 200px; }
.frame.block img { display: block; }
.frame.middle img { vertical-align: middle; }
.frame.bottom img { vertical-align: bottom; }
/* 3. aspect-ratio sizes an embed; a video gets its ratio from its width and height attributes. */
.column { position: absolute; left: 240px; top: 180px; inline-size: 360px; }
.column iframe { inline-size: 100%; block-size: auto; aspect-ratio: 16 / 9; border: 0; }
.column video { inline-size: 100%; block-size: auto; }
/* 4. image-set(): red at 1x, blue at 2x. Then type(): blue means the browser chose the second image. */
.density { position: absolute; left: 640px; top: 180px; inline-size: 100px; block-size: 60px;
background-image: image-set(url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10'%3E%3Crect width='10' height='10' fill='red'/%3E%3C/svg%3E") 1x,
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20'%3E%3Crect width='20' height='20' fill='blue'/%3E%3C/svg%3E") 2x); }
.typed, .svg-typed { position: absolute; top: 180px; inline-size: 100px; block-size: 60px; }
.typed { left: 760px; } /* its image-set() is written by the script below, with PNGs drawn on a canvas */
.svg-typed { left: 880px; }
/* 5. A 4 x 4 checkerboard bitmap scaled up 40 times, smoothed and pixelated. */
.pixels { position: absolute; top: 300px; inline-size: 160px; block-size: 160px; }
.smooth { left: 0; }
.pixelated { left: 180px; image-rendering: pixelated; }
/* 6. Icons in a link: inline SVG with currentColor, the same SVG as an <img>, and a CSS mask. */
.links { position: absolute; left: 400px; top: 300px; }
a { color: #1a5fb4; }
a:hover { color: #b0124f; }
.icon { inline-size: 40px; block-size: 40px; vertical-align: middle; }
.icon path { fill: currentColor; }
.mask-icon { display: inline-block; inline-size: 40px; block-size: 40px; vertical-align: middle; background-color: currentColor;
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 40 40'%3E%3Cpath d='M0 0h40v40H0z'/%3E%3C/svg%3E") center / contain no-repeat; }
/* The same mask icon with a forced-colours rule: a system colour is never replaced. */
@media (forced-colors: active) { .mask-icon.fixed { background-color: LinkText; } }
/* 7. The thumbnail grid of thumbnails.css, with a portrait, a landscape and a square photo. */
.thumbs { position: absolute; left: 0; top: 620px; }
</style>
</head>
<body>
<img class="fit fill" id="fit-fill" alt="" width="600" height="300">
<img class="fit contain" id="fit-contain" alt="" width="600" height="300">
<img class="fit cover" id="fit-cover" alt="" width="600" height="300">
<img class="fit cover-left" id="fit-cover-left" alt="" width="600" height="300">
<img class="fit cover-right" id="fit-cover-right" alt="" width="600" height="300">
<img class="fit none" id="fit-none" alt="" width="600" height="300">
<img class="fit scale-down" id="fit-scale-down" alt="" width="600" height="300">
<div class="frame" id="inline-frame"><img alt="" width="200" height="100"></div>
<div class="frame block" id="block-frame"><img alt="" width="200" height="100"></div>
<div class="frame middle" id="middle-frame"><img alt="" width="200" height="100"></div>
<div class="frame bottom" id="bottom-frame"><img alt="" width="200" height="100"></div>
<div class="column"><iframe id="embed" title="A video player"></iframe><video id="video" width="1280" height="720"></video></div>
<div class="density"></div>
<div class="typed"></div>
<div class="svg-typed"></div>
<img class="pixels smooth" id="smooth" alt="" width="4" height="4">
<img class="pixels pixelated" id="pixelated" alt="" width="4" height="4">
<div class="thumbs" id="thumbs">
<img class="focus-top" alt="" width="300" height="600"><img alt="" width="600" height="300">
<img class="focus-left" alt="" width="600" height="300"><img alt="" width="400" height="400">
</div>
<p class="links">
<a href="#" id="icon-link"><svg class="icon" id="inline-icon" viewBox="0 0 40 40" aria-hidden="true"><path d="M0 0h40v40H0z"/></svg>
<img class="icon" id="img-icon" alt="" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 40 40'%3E%3Cpath fill='currentColor' d='M0 0h40v40H0z'/%3E%3C/svg%3E">
<span class="mask-icon" id="mask-icon"></span>
<span class="mask-icon fixed" id="fixed-icon"></span></a>
</p>
<script>
// The three-band test image, drawn as SVG so the page needs no image files.
const bands = `<svg xmlns="http://www.w3.org/2000/svg" width="600" height="300"><rect width="200" height="300" fill="red"/><rect x="200" width="200" height="300" fill="lime"/><rect x="400" width="200" height="300" fill="blue"/></svg>`;
for (const img of document.querySelectorAll('.fit')) img.src = 'data:image/svg+xml,' + encodeURIComponent(bands);
for (const img of document.querySelectorAll('.frame img')) img.src = 'data:image/svg+xml,' + encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="200" height="100"><rect width="200" height="100" fill="#5e5c64"/></svg>');
// A 4 x 4 checkerboard bitmap.
const c = document.createElement('canvas'); c.width = 4; c.height = 4;
const g = c.getContext('2d');
for (let y = 0; y < 4; y++) for (let x = 0; x < 4; x++) { g.fillStyle = (x + y) % 2 ? '#fff' : '#000'; g.fillRect(x, y, 1, 1); }
for (const img of document.querySelectorAll('.pixels')) img.src = c.toDataURL('image/png');
// One-colour PNGs for the type() cases: an unknown type is skipped, and so may be an SVG labelled with its type.
const png = (colour) => { const k = document.createElement('canvas'); k.width = k.height = 10; const x = k.getContext('2d'); x.fillStyle = colour; x.fillRect(0, 0, 10, 10); return k.toDataURL('image/png'); };
const svg = (colour) => 'data:image/svg+xml,' + encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"><rect width="10" height="10" fill="${colour}"/></svg>`);
document.querySelector('.typed').style.backgroundImage = `image-set(url("${png('red')}") type("image/x-made-up"), url("${png('blue')}") type("image/png"))`;
document.querySelector('.svg-typed').style.backgroundImage = `image-set(url("${svg('blue')}") type("image/svg+xml"), url("${png('red')}") type("image/png"))`;
window.addEventListener('load', () => {
const h = (sel) => document.querySelector(sel).getBoundingClientRect().height;
const boxes = [...document.querySelectorAll('.fit')].map((img) => { const r = img.getBoundingClientRect(); return `${img.classList[1]} ${r.width} x ${r.height}`; });
console.log(`object-fit boxes: ${boxes.join(', ')}`);
console.log(`inline image: frame ${h('#inline-frame')}px for a 100px image; display: block ${h('#block-frame')}px; vertical-align: middle ${h('#middle-frame')}px; bottom ${h('#bottom-frame')}px`);
console.log(`iframe at 16 / 9 in 360px: ${h('#embed')}px; video 1280 x 720 at 360px: ${h('#video')}px`);
console.log(`inline SVG path fill: ${getComputedStyle(document.querySelector('#inline-icon path')).fill}; mask icon background: ${getComputedStyle(document.getElementById('mask-icon')).backgroundColor}`);
const grid = document.getElementById('thumbs');
for (const width of [360, 600]) {
grid.style.inlineSize = `${width}px`;
const sizes = [...grid.querySelectorAll('img')].map((img) => { const r = img.getBoundingClientRect(); return `${Math.round(r.width * 100) / 100} x ${Math.round(r.height * 100) / 100}`; });
console.log(`thumbnail grid ${width}px wide: ${sizes.join(', ')}`);
}
});
window.afterHover = () => [
` inline SVG path fill: ${getComputedStyle(document.querySelector('#inline-icon path')).fill}`,
` mask icon background: ${getComputedStyle(document.getElementById('mask-icon')).backgroundColor}`,
];
// The points whose colour the measuring script reads from a screenshot of this page.
const fitSamples = (id, left) => [
{ name: `${id}, left (x 40)`, x: left + 40, y: 75 },
{ name: `${id}, right (x 110)`, x: left + 110, y: 75 },
{ name: `${id}, top middle (y 10)`, x: left + 75, y: 10 },
];
window.samples = [
...fitSamples('fill', 0), ...fitSamples('contain', 160), ...fitSamples('cover', 320), ...fitSamples('cover 0%', 480),
...fitSamples('cover 100%', 640), ...fitSamples('none', 800), ...fitSamples('scale-down', 960),
{ name: 'image-set density', x: 690, y: 210 }, { name: 'image-set: unknown type, then PNG', x: 810, y: 210 },
{ name: 'image-set: SVG typed image/svg+xml, then PNG', x: 930, y: 210 },
{ name: 'smooth, 3px left of a cell edge', x: 37, y: 320, size: 1 }, { name: 'pixelated, 3px left of a cell edge', x: 217, y: 320, size: 1 },
];
const iconPoints = () => ['inline-icon', 'img-icon', 'mask-icon', 'fixed-icon'].map((id) => {
const r = document.getElementById(id).getBoundingClientRect();
return { name: id, x: r.left + r.width / 2, y: r.top + r.height / 2 };
});
window.samples.push(...iconPoints(), { name: 'the page itself', x: 1180, y: 580 });
window.hover = '#icon-link';
window.hoverSamples = iconPoints();
</script>
</body>
</html> object-fit: cover, contain, fill, none and scale-down
object-fit never changes the box: in the lab, all seven <img> boxes stayed 150 by 150 px whatever the value. It
changes how the picture is scaled inside the box:
fill, the default, stretches the picture to the box and distorts it when the shapes differ.containscales it, keeping its shape, until it just fits: all of it is visible, with empty bands where the shapes differ.coverscales it, keeping its shape, until it covers the box, and crops the rest. This is the one for thumbnails, avatars and hero images.nonekeeps the natural size and centres it, cropping whatever does not fit.scale-downusesnoneorcontain, whichever gives the smaller picture, so it never enlarges a small image.
The same wide image in a square box, four ways
Text description of the diagram
The diagram shows a 600 by 300 pixel image made of three vertical bands, A, B and C, placed in the same square box four times.
- fill, the default: the image is squeezed to the square, so each band becomes a third of the box and everything looks narrower than it is.
- contain: the whole image is scaled down until it fits, keeping its shape, so it fills the width and leaves empty bands above and below it.
- cover: the image is scaled until it covers the whole square, keeping its shape. With the default centre position, band B fills the middle and only the inner edges of A and C stay in view; the rest is cropped.
- none: the image keeps its natural size and is centred, so only the middle of band B is visible.
The lab’s test image is 600 by 300 px, three bands of red, green and blue, in a 150 by 150 px box. The program works out where each value paints it:
// Where object-fit paints an image inside its box (CSS Images 3, section 5.5). The box never changes size; only the
// picture inside it is scaled and moved. object-position works like background-position: a percentage p puts the
// point p % across the image on the point p % across the box, so the image's offset is (box - image) × p.
function fitRect(fit, box, image, [px, py] = [50, 50]) {
const contain = Math.min(box.w / image.w, box.h / image.h);
const cover = Math.max(box.w / image.w, box.h / image.h);
if (fit === 'fill') return { x: 0, y: 0, w: box.w, h: box.h };
const scale = { contain, cover, none: 1, 'scale-down': Math.min(1, contain) }[fit];
const w = image.w * scale, h = image.h * scale;
return { x: (box.w - w) * (px / 100), y: (box.h - h) * (py / 100), w, h };
}
// The lab's test image: 600 x 300 px, three bands 200 px wide (red, green, blue), in a 150 x 150 px box.
const image = { w: 600, h: 300 }, box = { w: 150, h: 150 };
const bands = ['red', 'green', 'blue'];
const n = (v) => Math.round(v * 100) / 100;
// How many px of each band end up inside the box.
const visible = (r) => bands
.map((name, i) => {
const left = Math.max(0, r.x + (i * r.w) / 3), right = Math.min(box.w, r.x + ((i + 1) * r.w) / 3);
return right > left ? `${name} ${n(right - left)}` : null;
})
.filter(Boolean);
for (const [fit, pos] of [['fill'], ['contain'], ['cover'], ['cover', [0, 50]], ['cover', [100, 50]], ['none'], ['scale-down']]) {
const r = fitRect(fit, box, image, pos);
const label = `${fit}${pos ? ` ${pos[0]}% ${pos[1]}%` : ''}`;
console.log(`${label.padEnd(16)} ${n(r.w)} x ${n(r.h)} at (${n(r.x)}, ${n(r.y)}); px of each band in view: ${visible(r).join(', ')}`);
} Output
fill 150 x 150 at (0, 0); px of each band in view: red 50, green 50, blue 50 contain 150 x 75 at (0, 37.5); px of each band in view: red 50, green 50, blue 50 cover 300 x 150 at (-75, 0); px of each band in view: red 25, green 100, blue 25 cover 0% 50% 300 x 150 at (0, 0); px of each band in view: red 100, green 50 cover 100% 50% 300 x 150 at (-150, 0); px of each band in view: green 50, blue 100 none 600 x 300 at (-225, -75); px of each band in view: green 150 scale-down 150 x 75 at (0, 37.5); px of each band in view: red 50, green 50, blue 50
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node object_fit.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
The lab read the colour 40 px and 110 px from the left of each box. With fill and contain they were red and blue,
because the whole image is there, squeezed or letterboxed. With cover both were green: only 25 px of red and of
blue survive at the edges. All three engines agreed on every value.
object-position: the focal point
object-position works like background-position: a percentage lines up the same point of the picture and of the
box, so 0% 50% keeps the left edge in view, 100% 50% the right edge and the default, 50% 50%, the middle. With
cover, that makes it a focal point. In the lab, cover at 0% 50% showed red and green, and at 100% 50% green
and blue, as the program predicts.
A gallery of photos in different shapes is the everyday case: every thumbnail is square, nothing is stretched, and each photo can say where its subject is:
/* Square thumbnails from photos of any shape. Each <img> keeps its width and height attributes, so the browser
reserves space before the file arrives; CSS then makes every one square and crops instead of stretching. */
.thumbs {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
gap: 8px;
}
.thumbs img {
inline-size: 100%;
block-size: auto;
aspect-ratio: 1; /* wins over the ratio of the attributes */
object-fit: cover;
object-position: var(--focus, 50% 50%); /* where the subject is; the centre unless a class says otherwise */
border-radius: 8px;
}
/* Focal points for photos whose subject is not in the middle. */
.focus-top { --focus: 50% 20%; }
.focus-left { --focus: 25% 50%; }
.focus-right { --focus: 75% 50%; } The rule aspect-ratio: 1 wins over the ratio the width and height attributes give, because author CSS beats
those attributes. The lab loads this stylesheet and puts portrait, landscape and square photos in the grid at 360 px
and at 600 px wide: every thumbnail came out square in all three engines, 114.67 px and 113.6 px on a side, give or
take a hundredth.
Video and embeds: aspect-ratio
A <video> with width="1280" and height="720" already knows its ratio, so inline-size: 100% and
block-size: auto keep it 16:9. An <iframe> from a video site does not: its content is a separate page, so it needs
aspect-ratio: 16 / 9. In a 360 px column, the lab measured both at 202.5 px tall in all three engines.
/* A video player embed keeps 16:9 at any width. */
.embed {
inline-size: 100%;
aspect-ratio: 16 / 9;
border: 0;
}
/* A <video> with width and height attributes already knows its ratio: let it shrink with the page. */
video {
max-inline-size: 100%;
block-size: auto;
}
/* A muted background video that fills its banner and crops like a cover image. */
.banner { position: relative; min-block-size: 50vh; }
.banner video {
position: absolute;
inset: 0;
inline-size: 100%;
block-size: 100%;
object-fit: cover;
} A background video uses object-fit: cover exactly like a cover image. Sizing an <iframe> to the height of its own
content is a different feature, frame-sizing, which only Chromium-based browsers support so far, so it has limited
availability.
Sharper backgrounds with image-set()
An <img> chooses between files with the HTML srcset and sizes attributes. A CSS background has no attributes,
so CSS has image-set(): a list of images with a density such as 1x or 2x, or a file type, from which the
browser picks one. It is Baseline widely available.
/* A banner background at the right density and in the best format the browser can decode. The browser first drops
the formats it cannot decode, then picks the density closest to the screen's. */
.banner {
background-image: url("banner-1x.jpg"); /* for browsers without image-set() */
background-image: image-set(
"banner-1x.avif" type("image/avif") 1x,
"banner-2x.avif" type("image/avif") 2x,
"banner-1x.jpg" type("image/jpeg") 1x,
"banner-2x.jpg" type("image/jpeg") 2x
);
background-size: cover;
background-position: 50% 30%;
}
/* Leave type() off SVG files: Chromium and WebKit treat "image/svg+xml" in image-set() as a type they cannot use. */ The lab tested how the choice is made, with one-colour images so the colour shows which file won:
image-set() |
Chromium 153 | Firefox 155 | WebKit 26.6 |
|---|---|---|---|
a red 1x and a blue 2x, on a 1x screen |
red | red | red |
| the same on a 2x screen | blue | blue | blue |
type("image/x-made-up"), then type("image/png") |
the PNG | the PNG | the PNG |
an SVG with type("image/svg+xml"), then a PNG |
the PNG | the SVG | the PNG |
The browser drops the types it cannot decode, then picks the density nearest the screen’s. The last row is the
surprise: Chromium and WebKit treat image/svg+xml as a type they cannot use inside image-set(), so they skip the SVG
and fall back to the PNG, while Firefox keeps the SVG. Use type() for raster formats such as AVIF, WebP and JPEG, and
list an SVG without a type.
Content images, the ones a page is about, belong in HTML: an <img> has alt text for screen readers, and the
browser’s preload scanner finds it in the HTML and starts the download early. A background image is decoration that
the browser discovers only after the stylesheet has loaded and the rule has matched, which delays a large banner.
Sharp pixels with image-rendering
When a browser enlarges a bitmap, it smooths it, which suits photos and blurs pixel art, QR codes and screenshots of
tiny icons. image-rendering: pixelated keeps every pixel a hard square. The lab enlarged a 4 by 4 checkerboard 40
times and read a pixel 3 px inside a black square, next to a white one: with the default smoothing it was grey (111 in
Chromium and Firefox, 64 in WebKit), and with pixelated it was pure black in all three.
/* A 32 x 32 sprite, or a QR code, shown at eight times its size: keep every pixel a hard square instead of a blur. */
.sprite {
inline-size: 256px;
block-size: 256px;
image-rendering: pixelated;
} pixelated is Baseline widely available. crisp-edges, which lets the browser use another algorithm that also
avoids blurring, is newly available.
SVG icons that follow the text colour
Inline <svg> is part of the page, so CSS reaches its shapes: fill paints their inside, stroke their outline, and
currentColor means the colour of the text around them. An icon drawn with fill or stroke set to currentColor
takes the colour of its link, its hover colour and the colours of forced colours mode, with no extra rules. An SVG
loaded through <img> or url() is a separate document, processed in a secure mode without scripts or outside
resources, and the page’s CSS cannot reach inside it: its currentColor is its own default, black.
The lab put four icons in a link coloured #1a5fb4, with #b0124f on hover: an inline SVG with
fill: currentColor, the same SVG as an <img>, a mask icon, which is an empty <span> whose background colour
is currentColor, cut to the icon’s shape by mask with the SVG file, and the same mask icon with a forced-colours
rule:
| Icon | Normal | Hover | Forced colours, light | Forced colours, dark |
|---|---|---|---|---|
inline SVG, fill: currentColor |
link blue | hover pink | link colour | yellow link colour |
SVG in an <img> |
black | black | black | black on black |
mask icon, background-color: currentColor |
link blue | hover pink | white on white in Chromium | black on black in Chromium |
| mask icon with a forced-colors rule | link blue | hover pink | link colour | yellow link colour |
Normal mode was the same in all three engines. Forced colours mode was emulated in Chromium, with its light and dark
palettes, and in Firefox, which kept every visible icon visible in its light palette. Forced colours mode replaces a
background colour with the page’s own colour, which is why the plain mask icon vanished in Chromium; the fix sets a
system colour such as LinkText or CanvasText inside @media (forced-colors: active), because a system colour is
never replaced. The <img> icon is never recoloured, so on a dark contrast theme it disappears.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>SVG icons that follow the text colour</title>
<style>
/* Inline SVG is part of the page, so CSS reaches its shapes. currentColor is the colour of the text around it. */
.icon {
inline-size: 1.25em;
block-size: 1.25em;
vertical-align: -0.25em;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
a { color: #1a5fb4; }
a:hover { color: #b0124f; }
/* A mask icon: one element, the shape from an SVG file, the colour from the text. */
.mask-icon {
display: inline-block;
inline-size: 1.25em;
block-size: 1.25em;
vertical-align: -0.25em;
background-color: currentColor;
mask: url("arrow.svg") center / contain no-repeat;
}
/* Forced colours mode replaces background colours with the page colour, which would hide a mask icon. */
@media (forced-colors: active) {
.mask-icon { background-color: CanvasText; }
a .mask-icon { background-color: LinkText; }
}
</style>
</head>
<body>
<!-- A decorative icon beside its text: hidden from assistive technology, coloured by the link. -->
<a href="/search/">
<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><circle cx="10" cy="10" r="6"/><path d="M14.5 14.5 20 20"/></svg>
Search
</a>
<!-- An icon that is the only content of a button: the button needs a name. -->
<button type="button" aria-label="Next page">
<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 12h15M13 6l6 6-6 6"/></svg>
</button>
<!-- The same arrow as a mask icon, from a separate file. -->
<a href="/next/"><span class="mask-icon"></span> Next</a>
</body>
</html> Two accessibility rules come with icons. A decorative icon next to its text gets aria-hidden="true", so screen
readers do not announce it twice; an icon that is the only content of a button needs a name for the button, such as
aria-label="Next page". Inline SVG costs a little HTML on every page that uses it, so optimise the files first.
Practise: thumbnails that keep their subject
The exercise turns object-fit and object-position into two functions: one that says where a picture is painted,
and one that finds the object-position that keeps a photo’s subject in a square thumbnail.
Exercise · Medium · JavaScript
Crop gallery thumbnails towards their subject
A gallery shows every photo in a square thumbnail with object-fit: cover, and each photo comes with the position of its subject, in the photo's own pixels. Write two functions in gallery.mjs and export them. Round every number you return to two decimals.
The first, fitRect(fit, box, image, position), returns where the browser paints the image inside its box, as { x, y, w, h } in px relative to the box's top-left corner. fit is one of fill, contain, cover, none and scale-down; box and image are { w, h }; position is the object-position as two percentages, [50, 50] when it is left out. As in CSS, fill stretches the image to the box, contain scales it to fit inside, cover scales it to cover the box, none keeps its natural size and scale-down is the smaller of none and contain, all keeping the image's shape except fill. A percentage p places the image at (box − image) × p ÷ 100 on each axis.
The second, focusPosition(image, subject, box), returns the object-position percentages [px, py] that put the subject, { x, y } in image pixels, as close to the centre of a cover thumbnail as the photo allows. A percentage never goes below 0 or above 100, and on an axis where the scaled image exactly fills the box it is 50.
The sample tests import the two functions from gallery.mjs and run in your browser.
Starter code · gallery.mjs
/** Where object-fit paints `image` inside `box`, with object-position `position` (two percentages). */
export function fitRect(fit, box, image, position = [50, 50]) {
// Replace this line with your code.
return { x: 0, y: 0, w: box.w, h: box.h };
}
/** The object-position percentages that centre `subject` in a cover thumbnail, as far as the photo allows. */
export function focusPosition(image, subject, box) {
// Replace this line with your code.
return [50, 50];
} The sample tests · gallery.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { fitRect, focusPosition } from './gallery.mjs';
const image = { w: 600, h: 300 };
const box = { w: 150, h: 150 };
test('fill stretches the image to the box', () => {
assert.deepEqual(fitRect('fill', box, image), { x: 0, y: 0, w: 150, h: 150 });
});
test('contain fits the whole image and centres it', () => {
assert.deepEqual(fitRect('contain', box, image), { x: 0, y: 37.5, w: 150, h: 75 });
});
test('cover crops, and object-position moves the crop', () => {
assert.deepEqual(fitRect('cover', box, image), { x: -75, y: 0, w: 300, h: 150 });
assert.deepEqual(fitRect('cover', box, image, [0, 50]), { x: 0, y: 0, w: 300, h: 150 });
assert.deepEqual(fitRect('cover', box, image, [100, 50]), { x: -150, y: 0, w: 300, h: 150 });
});
test('none keeps the natural size; scale-down picks the smaller of none and contain', () => {
assert.deepEqual(fitRect('none', box, image), { x: -225, y: -75, w: 600, h: 300 });
assert.deepEqual(fitRect('scale-down', box, image), { x: 0, y: 37.5, w: 150, h: 75 });
assert.deepEqual(fitRect('scale-down', { w: 400, h: 400 }, { w: 120, h: 80 }), { x: 140, y: 160, w: 120, h: 80 });
});
test('a portrait photo in a square, rounded to two decimals', () => {
assert.deepEqual(fitRect('cover', { w: 96, h: 96 }, { w: 900, h: 1600 }, [50, 20]), { x: 0, y: -14.93, w: 96, h: 170.67 });
});
test('a subject in the middle stays at 50 %', () => {
assert.deepEqual(focusPosition(image, { x: 300, y: 150 }, box), [50, 50]);
});
test('a subject towards the right edge', () => {
assert.deepEqual(focusPosition(image, { x: 450, y: 150 }, box), [100, 50]);
assert.deepEqual(focusPosition(image, { x: 390, y: 150 }, box), [80, 50]);
});
test('positions are clamped to 0 and 100', () => {
assert.deepEqual(focusPosition(image, { x: 560, y: 40 }, box), [100, 50]);
assert.deepEqual(focusPosition({ w: 300, h: 600 }, { x: 150, y: 120 }, box), [50, 0]);
assert.deepEqual(focusPosition({ w: 300, h: 600 }, { x: 150, y: 200 }, box), [50, 16.67]);
}); A hint
For cover, the scale is Math.max(box.w / image.w, box.h / image.h). In focusPosition, the subject lands at the centre when the image's offset x satisfies x + subject.x × scale = box.w ÷ 2, and the offset is (box.w − scaled width) × p, so solve for p, then clamp it. One trap: (150 − 300) × 0 is -0 in JavaScript, and the tests compare strictly, so add 0 after rounding to turn -0 into 0.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (QuickJS): nothing is sent to mysmartcopilot.com. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Interview questions
Fresher · What is the difference between object-fit: cover and object-fit: contain?
Both scale an image or video inside its box while keeping its shape, and neither changes the size of the box. cover
scales the picture until it covers the whole box and crops whatever sticks out, so there are no empty areas but the
edges of the picture may be lost; object-position chooses which part stays. contain scales the picture until all of
it fits, so nothing is cropped but empty bands appear where the shapes of the picture and the box differ. A 600 by 300
image in a 150 by 150 box is drawn 300 by 150 with cover, losing half its width, and 150 by 75 with contain, with
a band 37.5 px tall above and below. Use cover for thumbnails and hero images, and contain for logos and product
shots that must be seen whole.
Key takeaways
object-fitchanges how the picture fits its box, never the box:covercrops,containletterboxes,fillstretches,nonekeeps the natural size.object-positionpercentages line up the same point of picture and box, so they work as a focal point forcover.- An image is inline, which leaves a gap below it for descenders;
display: blockremoves it. aspect-ratio: 16 / 9sizes embeds; a video gets its ratio from itswidthandheightattributes.image-set()picks a background by density and type; Chromium and WebKit skip an SVG labelledtype("image/svg+xml"), so give SVG files no type. Content images belong in<img>withsrcset.image-rendering: pixelatedkeeps enlarged pixel art sharp.- Inline SVG with
currentColorfollows the text colour everywhere, forced colours included; an SVG in<img>cannot be styled, and a mask icon needs a forced-colours rule.
Check yourself
8 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.
References
- CSS Images Module Level 3: the object-fit property (W3C CSS Working Group)
- CSS Images Module Level 3: the object-position property (W3C CSS Working Group)
- CSS Images Module Level 3: the image-rendering property (W3C CSS Working Group)
- CSS Images Module Level 4: image-set() (W3C CSS Working Group)
- CSS Box Sizing Module Level 4: aspect-ratio (W3C CSS Working Group)
- CSS Box Sizing Module Level 4: frame-sizing (W3C CSS Working Group)
- CSS 2: line height calculations and the strut (W3C CSS Working Group)
- HTML Standard: attributes for embedded content and images (WHATWG)
- HTML Standard: images, srcset and sizes (WHATWG)
- Scalable Vector Graphics (SVG) 2: the fill and stroke properties (W3C SVG Working Group)
- SVG Integration: referencing modes (W3C SVG Working Group)
- CSS Color Module Level 4: the currentcolor keyword (W3C CSS Working Group)
- CSS Color Adjustment Module Level 1: properties affected by forced colors mode (W3C CSS Working Group)
- Accessible Rich Internet Applications (WAI-ARIA) 1.2: aria-hidden (W3C)
- Web Content Accessibility Guidelines (WCAG) 2.2: Name, Role, Value (W3C)
- Optimize Largest Contentful Paint: when the resource is discovered (Google)
- object-fit (MDN Web Docs) (Mozilla)
- image-set() (MDN Web Docs) (Mozilla)
- SVG as an image (MDN Web Docs) (Mozilla)
- Web features explorer: object-fit (W3C WebDX Community Group)
- Web features explorer: image-set() (W3C WebDX Community Group)
- Web features explorer: image-rendering (W3C WebDX Community Group)
- Web features explorer: crisp-edges (W3C WebDX Community Group)
- Web features explorer: responsive iframes (frame-sizing) (W3C WebDX Community Group)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress