Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Beginner
  • 25 minutes
  • Examples run with Node.js 24.21.0 and quickjs 0.32.0
  • By MySmartCoPilot
Free: 25 minutes

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.

Fitting, densities, rendering and icons, measured HTML · images-lab.html
<!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.
  • contain scales it, keeping its shape, until it just fits: all of it is visible, with empty bands where the shapes differ.
  • cover scales it, keeping its shape, until it covers the box, and crops the rest. This is the one for thumbnails, avatars and hero images.
  • none keeps the natural size and centres it, cropping whatever does not fit.
  • scale-down uses none or contain, whichever gives the smaller picture, so it never enlarges a small image.
A wide image of three bands in a square box with object-fit fill, contain, cover and none: squeezed, letterboxed, cropped, natural size.A 600 × 300 image of bands A, B and C, in a square boxfillABCsqueezed to fitcontainABCwhole image, bands aboveand belowcoverBedges of A and C croppednoneBnatural size: part of B

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.

  1. 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.
  2. 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.
  3. 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.
  4. 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 a 600 x 300 image JavaScript · object_fit.mjs
// 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

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 that crop towards the subject CSS · thumbnails.css
/* 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.

Aspect Ratio Calculator Work out a ratio from a width and height, or the missing side from a ratio.

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 16:9 embed and a background video CSS · embeds.css
/* 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 background in two densities and two formats CSS · image-set.css
/* 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 sprite at eight times its size CSS · pixel-art.css
/* 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.

Inline icons, an icon-only button and a mask icon HTML · icons.html
<!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.

SVG Optimizer Shrink SVG icons before you inline them, with a preview of the result.

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.

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-fit changes how the picture fits its box, never the box: cover crops, contain letterboxes, fill stretches, none keeps the natural size.
  • object-position percentages line up the same point of picture and box, so they work as a focal point for cover.
  • An image is inline, which leaves a gap below it for descenders; display: block removes it.
  • aspect-ratio: 16 / 9 sizes embeds; a video gets its ratio from its width and height attributes.
  • image-set() picks a background by density and type; Chromium and WebKit skip an SVG labelled type("image/svg+xml"), so give SVG files no type. Content images belong in <img> with srcset.
  • image-rendering: pixelated keeps enlarged pixel art sharp.
  • Inline SVG with currentColor follows 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.

  1. Question 1 of 8 A wide product photo must be shown whole, never cropped, in a square card. Which value?

    Choose one answer.

    Show the answer to question 1

    Answer: object-fit: contain

    contain scales the picture until all of it fits, keeping its shape, and leaves empty bands above and below. cover would crop the sides, fill would distort the photo and none would show it at its natural size.

  2. Question 2 of 8 An image box is 150 × 150 px. What size is the box after object-fit: cover?

    Choose one answer.

    Show the answer to question 2

    Answer: Still 150 × 150 px; only the picture inside is scaled and cropped.

    object-fit works inside the box and never changes its size: all seven values left the lab's boxes at 150 × 150 px in every engine.

  3. Question 3 of 8 An image sits alone in a <div>, and the <div> is a few pixels taller than the image. Why?

    Choose one answer.

    Show the answer to question 3

    Answer: The image is inline, sitting on the baseline, and the line keeps room below it for descenders.

    With font: 16px/1.5, the lab measured a 106 px tall <div> around a 100 px image. display: block or vertical-align: middle on the image removes the gap.

  4. Question 4 of 8 An <iframe> has inline-size: 100% and aspect-ratio: 16 / 9 in a 360 px wide column. How tall is it, in px?

    Type a number.

    Show the answer to question 4

    Answer: 202.5 px (anything from 202 to 203 counts)

    360 × 9 ÷ 16 = 202.5, which is what all three engines measured.

  5. Question 5 of 8 An icon SVG uses fill="currentColor" and is shown with <img> inside a blue link. What colour is the icon?

    Choose one answer.

    Show the answer to question 5

    Answer: Black, because the SVG is a separate document whose current colour is its own default.

    Page CSS does not reach inside an SVG loaded as an image, so currentColor is resolved inside the SVG, where the colour is black. Inline the SVG, or use it as a mask, to follow the link colour.

  6. Question 6 of 8 image-set(url(logo.svg) type("image/svg+xml"), url(logo.png) type("image/png")): what does Chromium show?

    Choose one answer.

    Show the answer to question 6

    Answer: The PNG, because Chromium treats image/svg+xml as a type it cannot use in image-set().

    In the lab, Chromium and WebKit skipped the SVG with that type and used the PNG, while Firefox used the SVG. List an SVG without type().

  7. Question 7 of 8 A 32 × 32 game sprite is shown at 256 × 256 px and looks blurry. What fixes it?

    Choose one answer.

    Show the answer to question 7

    Answer: image-rendering: pixelated

    Browsers smooth enlarged bitmaps by default. pixelated keeps every pixel a hard square; in the lab, a pixel next to a cell edge was grey with smoothing and pure black with pixelated.

  8. Question 8 of 8 In a dark Windows contrast theme, which icons inside a link stay visible?

    Choose every answer that is right.

    Show the answer to question 8

    Answer:

    • An inline SVG with fill: currentColor
    • A mask icon with @media (forced-colors: active) { background-color: LinkText }

    With Chromium's dark forced palette, the inline SVG and the fixed mask icon turned yellow, the link colour. The <img> icon stayed black on the black page, and the plain mask icon's background was replaced with the page colour.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.