CSE 134B Web Client Languages
  1. Presentation
  2. Images
  3. CSS
  4. Images in CSS

Presentation · Images 05

Images in CSS

CSS can fetch an image, generate one from nothing, choose between formats and densities, cut an element into a shape and fit a photograph into a box. All of that power is for decoration, and the first skill is knowing when an image is decoration at all.

The previous page put content images in the HTML, where they have alternative text, intrinsic dimensions and a place in the document. This page covers the other half of the split from What an Image Costs: images that are presentation, written in the style sheet. It assumes the background properties from the CSS section's Properties page and goes further: the image functions, the properties other than background that take an image, the tools for fitting replaced content, and the costs that come with putting an image where the browser finds it late.

1. Content or presentation: where each image belongs

The test from page 01 still decides everything: if the image failed to load, would the reader miss something? If so, it is content, and it goes in the HTML. If the page would only look plainer, it is presentation, and CSS is usually the better home.

The reason is not tidiness. An image in CSS has a different set of properties from an image in the HTML, and they suit decoration and fail content:

<img> in the HTMLImage in CSS
Alternative textalt, read by screen readers and shown when the image failsNone. Assistive technology does not see background images at all
DiscoveryEarly: the browser's preload scanner sees it while the HTML is still arrivingLate: only after the CSS has loaded and a rendered element matches the rule
PrintingPrintsBackground images do not print unless the reader turns on a browser setting
Reader modes, feeds, copy and paste, image searchCarried along with the contentUsually dropped
Changing with the designNeeds <picture> and markup changesA media query or a custom property in the style sheet
Forced colors (high contrast)ShownGradients are removed; url() images are kept but may lose their contrast

Every row is a reason a photograph of the product, a chart or a diagram must not be a background image, and every row is also a reason a texture, a scrim or a decorative flourish should be one. A reader who prints a recipe wants the text, not the watercolor wash behind it. A screen reader user gains nothing from hearing about a gradient.

The Images deck described three methods for presentational images. Two are CSS: a plain background-image, and image-set(), which lets the style sheet offer densities and formats. The third is an <img> in the HTML with an empty alt="". That is legitimate when the decoration needs what only the HTML gives: early discovery, srcset with width descriptors, lazy loading, or a place in the document flow. The empty alt must be present. An <img> with no alt at all is treated as a content image whose description is missing, and many screen readers will read out the file name instead.

<!-- Decoration that needs srcset and lazy loading: an img with an empty alt --> <img src="ridge-800.jpg" srcset="ridge-800.jpg 800w, ridge-1600.jpg 1600w" sizes="100vw" alt="" width="800" height="200" loading="lazy">

2. Background images and layers

The Properties page lists the eight background longhands, the shorthand and how layers stack. This section adds what matters when the background is a photograph rather than a color.

Sizing: cover and contain

A raster image has an intrinsic size and an intrinsic aspect ratio, and the box it decorates almost never matches either. background-size decides what gives way:

The same 3 by 2 photograph in a square box with three background-size valuesThree square boxes of the same size. With background-size auto, the image is drawn at its intrinsic size from the top left corner, and its right and bottom parts fall outside the box and are not painted. With cover, the image is scaled until it fills the box, and strips on the left and right are cropped away. With contain, the whole image fits inside the box, leaving empty bands above and below it.dashed: the part of the image that falls outside the box and is not paintedautointrinsic size, from the top leftcoverfills the box, crops the sidescontainfits whole, leaves bands
One 3:2 photograph in a square box. cover never leaves a gap and contain never crops; each pays for that with the other.

For a photograph behind text, cover is almost always right, and it brings a responsibility: the crop changes with the box, so on a narrow phone a wide photograph loses most of its width. background-position chooses which part survives. A percentage aligns the same point of the image and the box, so center 30% keeps the subject that sits a third of the way down the photograph in view at every width.

contain suits a logo or an illustration that must be seen whole, usually with background-repeat: no-repeat, since a contained image smaller than its box will otherwise tile. Tiling is what repeat is for: a small texture or a pattern, with space and round to avoid cut-off tiles at the edges.

Positions and writing direction

The house style uses logical properties, so the natural wish is a logical position: a decorative image at the start of a heading, wherever "start" is in the reader's language. The CSS Backgrounds Level 4 draft defines keywords for that, such as x-start and inline-start, but in October 2026 only Safari 26 supports the x-start family, and Chrome and Firefox support none of them. They are not Baseline, so use physical keywords and flip them for right-to-left text with :dir(), which is Baseline:

@layer components { h2:where([data-flourish]) { padding-inline-start: 2.5rem; background: url("/images/flourish.svg") no-repeat left center / 1.75rem; &:dir(rtl) { background-position: right center; } } }

Layers, a scrim, and a color underneath

The most useful background on most sites is two layers and a color: a semi-transparent gradient over a photograph, over a solid color. Each part has a job. The photograph sets the mood. The gradient, called a scrim, darkens the photograph so that light text keeps its contrast wherever it lands. The color shows while the photograph loads, if it fails, and if the reader has images turned off, and the text is still readable over it.

@layer components { :root { --scrim: oklch(18% 0.03 260 / 0.65); --lake: url("/presentation/images/demos/media/lake-800.jpg"); } header:where([data-hero]) { min-block-size: clamp(12rem, 40vh, 24rem); padding: clamp(1rem, 4vw, 3rem); color: white; background-color: oklch(30% 0.05 240); background-image: linear-gradient(to bottom, transparent, var(--scrim) 55%), var(--lake); background-position: center 40%; background-size: cover; } }

The demo below puts this and six other techniques on one page. Each example is plain HTML, and every image that is decoration comes from the style sheet.

background-attachment, cautiously

background-attachment: fixed pins the image to the viewport while the content scrolls over it. It is not Baseline: Safari on iOS recognizes fixed and ignores it, and in other browsers it makes the page repaint more as it scrolls. If the effect is the point of a design, the design will not survive on half the phones that load it. Treat it as an enhancement that can quietly fail. local, which scrolls the background with an element's own scrolling content, is useful for scroll shadows and is safer.

Background images can be the LCP

Largest Contentful Paint (LCP) is the time until the largest image or text block in the viewport is drawn, and it is the main measure of how fast a page feels. A background image loaded with url() counts as a candidate; a gradient does not. So a full-width hero photograph in CSS is usually the page's LCP element, and it is also the image the browser discovers last. Section 9 deals with that.

3. Gradients are images

In CSS a gradient is not a color. It is an <image> value, generated by the browser at the size of the box, so it goes anywhere an image goes: a background layer, a list marker, a border image, a mask. It costs no request and no bytes beyond the text of the rule, and it stays sharp at any size and density, which is more than any raster file can say.

FunctionColors changeTypical use
linear-gradient()Along a line, at an angle or toward a sideScrims, fades, subtle surface shading
radial-gradient()Outward from a point, as a circle or ellipseVignettes, spotlights, soft glows
conic-gradient()Around a point, like a clock handPie charts, color wheels, angled patterns
repeating-*-gradient()The same, repeated along its line, radius or angleStripes, rings, hazard tape

Two stops at the same position make a hard edge instead of a blend, which turns gradients into a drawing tool. A repeating linear gradient with hard stops is a stripe pattern; a conic gradient with hard stops is a pie chart. The interpolation can also be chosen: in oklch blends through a perceptually even color space and avoids the gray, muddy middle that a blend between two saturated colors gets in the default sRGB. Color spaces in gradients have been Baseline since 2024.

@layer components { :root { --gold: oklch(78% 0.16 80); --teal: oklch(62% 0.1 185); } hr { block-size: 0.25rem; border: 0; background: linear-gradient(to right in oklch, var(--gold), var(--teal)); } section:where([data-closed]) { background: repeating-linear-gradient(45deg, oklch(70% 0.12 60 / 0.25) 0 0.5rem, transparent 0.5rem 1rem), Canvas; } }

Gradients are where the "eliminate" step from page 01 pays off most often. A fade, a stripe, a soft glow or a tinted panel that once shipped as a PNG is now one line of CSS. Drawing whole illustrations in gradients is possible and is a fine puzzle, but past a few layers an SVG is easier to read and to change; SVG and Vector Images draws that line.

4. image-set(), with type() for formats

A background image written with url() is one file for every reader. The HTML has srcset and <picture> to offer choices; CSS has image-set(). It takes a list of images, each with a resolution (a density such as 1x or 2x, the same idea as an srcset pixel density descriptor) and optionally a type, the image's MIME type in a type() function. The browser chooses one and downloads only that one.

The lake photograph at 800 by 533, as encoded by the site's build script
Filetype()Bytes
lake-800.avifimage/avif21,823
lake-800.webpimage/webp28,728
lake-800.jpgimage/jpeg33,646
@layer components { header:where([data-hero]) { /* Fallback first: a browser that cannot parse the next line keeps this one. */ background-image: url("lake-800.jpg"); background-image: image-set( "lake-800.avif" type("image/avif"), "lake-800.webp" type("image/webp"), "lake-800.jpg" type("image/jpeg") ); } }

The rule for choosing is short. The browser first drops every candidate whose type it cannot decode. Among candidates with the same resolution, only the first one listed is kept. From what remains, it picks the resolution that best suits the screen's device pixel ratio, and it may weigh other things too, such as a data-saver setting. So list formats from best to most compatible, as in <picture>, and the order does the work:

How a browser that decodes AVIF picks from image-set() on a DPR 2 screenFour candidates in order: lake-800.avif at 1x, lake-1600.avif at 2x, lake-800.jpg at 1x and lake-1600.jpg at 2x. Step 1, can the browser decode the type: yes for all four. Step 2, is it the first candidate at its density: yes for the two AVIF files, no for the two JPEG files, which came later at the same densities. Step 3, does its density suit DPR 2: no for the 1x AVIF, yes for the 2x AVIF. The result is one request, for lake-1600.avif.1. type itcan decode?2. first atits density?3. suitsDPR 2?"lake-800.avif" type("image/avif") 1x"lake-1600.avif" type("image/avif") 2x"lake-800.jpg" type("image/jpeg") 1x"lake-1600.jpg" type("image/jpeg") 2xyesyesyesyesyesyesnononoyesoutoutOne request: lake-1600.avif
Unsupported types drop out, later duplicates of a density drop out, and the density decides. A browser without AVIF would drop the first two rows at step 1 and fetch a JPEG.

Combining both ideas means listing every format at every density, best format first. That is verbose, so most sites settle for one: formats for a background shown at a fixed size, or densities for a small, sharp-edged raster such as a texture.

Support has changed since the deck called image-set() with type() a Firefox-only feature in early 2023. Chrome dropped its old prefixed, URL-only version and shipped the full function in version 113, and Safari added type() in Safari 17. image-set() has been Baseline since September 2023 and Baseline widely available since March 2026. AVIF itself has been Baseline widely available since July 2026, so a list of AVIF then JPEG covers essentially every current browser.

Two limits remain. image-set() has no width descriptors and no sizes: it chooses by density, not by how wide the box is. To change a background with the layout, use media or container queries, as the deck's "Method 1" slide did. And a media query on resolution, the old way to send a sharper background to high-density screens, is now only a fallback:

.hero { background-image: url(lake-800.jpg); } @media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 192dpi) { .hero { background-image: url(lake-1600.jpg); } } header:where([data-hero]) { background-image: url("lake-800.jpg"); background-image: image-set("lake-800.jpg" 1x, "lake-1600.jpg" 2x); }

5. List markers: list-style-image and ::marker

A list item's bullet is a marker box, and the Properties page covers its basic styling. Three ways exist to put a picture there, and they differ in what they can do.

list-style-image replaces the bullet with any image: a file, a data: URL, or a gradient. It is supported everywhere, and it is blunt. The image is drawn at its intrinsic size, with no property to size or nudge it, so an SVG marker needs a width and height in its own markup. And because a CSS image is a separate document, it cannot use the page's currentColor; its colors are fixed, including in dark mode. Pick a color that works on both backgrounds, and keep list-style-type as the fallback:

ul:where([data-trails]) { list-style-type: square; list-style-image: url("/images/pin.svg"); }

::marker selects the marker box itself. Only a short list of properties applies: color, the font properties, white-space, content, and animations and transitions. content is the interesting one, because it accepts a string, a counter or an image, so li::marker { content: url(pin.svg) " "; } would replace the bullet. The catch is Safari: its ::marker applies only color and font-size, which is why ::marker as a whole is not Baseline. Use it for color and font weight, which work everywhere, and do not rely on content there.

ol { li::marker { color: var(--accent); font-weight: 700; } }

A string is the third option, and often the best: list-style-type: "▸ " uses those characters as the marker. Text takes the list's color, scales with its font, and adapts to dark mode for free.

Before these, marker images were faked with a background on each item. It still appears in old themes, and it is worth recognizing:

ul.trails { list-style: none; } ul.trails li { background: url(bullet.gif) no-repeat 0 6px; padding-left: 16px; }

The fake bullet is lost when backgrounds are not printed or colors are forced, it sits at a fixed pixel offset that breaks as soon as the text size changes, and removing the real marker can remove the list semantics in Safari as well. A real marker keeps all of that.

6. content: url(), border-image and mask-image

Three more properties take an image. Each solves a problem that once needed an extra file or extra markup.

Generated images and their alt text

The content property of ::before and ::after can insert an image: content: url(icon.svg). Unlike a background, a generated image is something assistive technology may read, so it has a place for alternative text. A slash after the content introduces it:

a:where([href$=".pdf"])::after { content: url("/images/file.svg") / " (PDF)"; } a:where([target="_blank"])::after { content: "↗" / "opens in a new tab"; } h2::before { content: url("/images/flourish.svg") / ""; }

The text after the slash is what a screen reader announces in place of the image or symbol, and an empty string marks it as decoration. The syntax is Baseline since July 2024, when Firefox 128 added it. A browser too old for the slash drops the whole declaration, so if the generated image is essential, declare a plain content line first as a fallback. Keep the habit from the Properties page, though: generated content is still presentation. Anything the reader must have belongs in the HTML.

border-image

border-image paints a border from an image instead of a style and color. With a raster file it slices the image into nine regions, four corners, four edges and a middle, and stretches or repeats the edges along the box: the old way to build a decorative frame. With a gradient it is much simpler, and it is the common use today:

blockquote { padding: 1rem; border: 0.5rem solid var(--teal); border-image: linear-gradient(to right, var(--gold), var(--teal)) 1; }

The 1 is the slice: it tells the browser to use the whole gradient for every edge. The plain border line comes first because border-image draws in the space the border reserves, and because the border is what remains if the image is turned off. The limit to know: border-image ignores border-radius, so the corners stay square. For a rounded gradient border, layer two backgrounds with different background-clip values instead.

mask-image

A mask decides which parts of an element are visible. Where the mask image is opaque, the element shows; where it is transparent, the element is cut away; partial transparency fades it. Any image works as a mask: an SVG, a PNG with transparency, or a gradient.

The pattern this unlocks is the recolorable icon. An SVG drawn as a background keeps its own colors, but an SVG used as a mask over an element whose background is currentColor takes the color of the text around it, including hover states, dark mode and forced colors:

a:where([rel="next"]) { display: inline-flex; align-items: center; gap: 0.375em; &::after { content: ""; inline-size: 1em; aspect-ratio: 1; background-color: currentColor; mask: url("/images/arrow.svg") center / contain no-repeat; } } img:where([data-fade]) { mask-image: linear-gradient(to bottom, black 60%, transparent); }

The second rule fades a photograph out toward its bottom edge with a gradient mask; the color in a mask gradient does not matter, only its opacity. Unprefixed mask-image and the mask shorthand have been Baseline since December 2023, when Chrome 120 dropped its need for the -webkit- prefix, and Baseline widely available since June 2026. Older tutorials that write every mask property twice, once with -webkit-, were right at the time; for current browsers the prefix is no longer needed.

7. Fitting replaced content: object-fit, object-position, aspect-ratio

Sometimes the image is content and belongs in the HTML, but the layout needs it in a box of a different shape: a grid of square thumbnails, a card with a 16:9 media area, a round avatar. An <img> is a replaced element, one whose content comes from outside the document, and CSS has a pair of properties that do for its content what background-size and background-position do for a background.

object-fitWhat happens to a 3:4 photograph in a square box
fill (initial)Stretched to the box: the cat is squashed sideways
containScaled to fit whole, with empty bands at the sides
coverScaled to fill, with the top and bottom cropped
noneDrawn at its intrinsic size, cropped by the box
scale-downWhichever of none and contain gives the smaller image
@layer components { ul:where([data-gallery]) { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 10rem), 1fr)); gap: 0.5rem; img { inline-size: 100%; block-size: auto; aspect-ratio: 1; object-fit: cover; object-position: 50% 25%; } } }

aspect-ratio: 1 makes the box square whatever its width, object-fit: cover fills it, and object-position chooses the crop. Its initial value is 50% 50%, the center; for portraits 50% 25% usually keeps a face in frame. The width and height attributes stay on the <img> regardless: they give the browser the image's own ratio before the file arrives, and the CSS ratio overrides it for layout. Fluid and responsive images explains how those attributes prevent layout shift; the Layout page puts these boxes in grids.

All three properties are Baseline widely available. object-fit also applies to <video>, which is how a background-style video fills a hero area without distortion.

8. Dark mode, print and forced colors

Because a presentational image lives in the style sheet, it can change with the reader's situation without a line of HTML changing. Three situations matter.

Dark mode

A scrim tuned for a light page can look washed out on a dark one, and a bright photograph behind a dark interface glares. A prefers-color-scheme query swaps the layer, or, better, a custom property used by the layer:

@layer components { :root { --scrim: oklch(18% 0.03 260 / 0.55); --texture: url("/images/paper-light.webp"); } @media (prefers-color-scheme: dark) { :root { --scrim: oklch(10% 0.02 260 / 0.75); --texture: url("/images/paper-dark.webp"); } } body { background: var(--texture) Canvas; } }

Since September 2026, light-dark() also accepts two images, light-dark(url(day.webp), url(night.webp)), and picks one by the element's color scheme. It is newly Baseline, so pair it with the media query version for readers on browsers from before that month. Only the image the reader needs is downloaded either way.

Print

Browsers do not print background images and colors by default; Chrome, Firefox and Safari each have a print option, off unless the reader turns it on, that saves ink by leaving them out. That default is usually right, and it is one more reason content must not be a background. A print style sheet should plan for it: drop the scrim and the photograph, put the text back to dark on white, and keep any rule that the meaning depended on.

@media print { header:where([data-hero]) { background: none; color: black; } /* A rare background that must print: a color key in a chart legend */ figure:where([data-chart]) li::before { print-color-adjust: exact; } }

print-color-adjust: exact asks the browser to print an element's backgrounds even when the reader has not, and it is Baseline since May 2025, when Chrome 136 dropped its -webkit- prefix. Use it for the rare background that carries meaning, never to force a decorative photograph onto a reader's printer. Chrome and Safari do not print backgrounds of <body> itself even then.

Forced colors

Forced colors mode is the general name for Windows contrast themes and similar settings: the reader picks a small palette, and the browser replaces the page's colors with it. The CSS Color Adjustment specification says exactly what happens to images: background-image computes to none unless the value contains a url(). So gradients disappear, including scrims, while photographs and SVG files in url() remain, and the background color under them is replaced by the reader's background color.

The consequence is subtle. A hero with white text over a photograph and a dark scrim loses the scrim in forced colors, and the text color is forced too, so contrast over the photograph is no longer under the author's control. Chromium-based browsers draw an opaque plate behind text in this mode to keep it readable, but the safer plan is to let the reader's palette win:

@media (forced-colors: active) { header:where([data-hero]) { background-image: none; } }

The forced-colors media query is Baseline widely available. Mask icons, from section 6, behave well here, because their color comes from currentColor, which the forced palette sets. Images for People covers the reader settings that affect images in full, and DevTools can emulate both forced colors and print in its Rendering panel.

9. Preloading a background image, and why that fights the browser

An image in the HTML is found almost at once. Browsers run a preload scanner over the HTML as it streams in, and it starts requests for <img> sources long before the page is built. A background image has no such luck. The browser must download the style sheet, parse it, build the page, and find that a rendered element matches the rule with the url() in it. Only then does the request start.

When the request for the same photograph starts, in three casesThree timelines on the same scale, time running left to right. First, an img in the HTML: the image request starts while the HTML is still arriving and ends early. Second, a CSS background image: the request waits for the HTML, then the CSS, then rule matching, and starts and ends last. Third, a CSS background with a preload link in the head: the image request starts during the HTML, like the img, and runs at the same time as the CSS download, sharing the connection with it.<img> in the HTMLHTMLimageCSS background imageHTMLCSSmatch ruleimageCSS background + <link rel="preload">HTMLCSSimage (shares the connection)time
Not to scale. A background image starts last because the browser cannot know it is needed until the CSS is applied. A preload moves the request to the start, where it competes with the CSS for bandwidth.

For decoration that is fine, and even good: it is the "load when needed" step for free. For a hero photograph that is the page's LCP element, it is slow. The deck's answer still holds: tell the browser about the image early with a <link rel="preload"> in the <head>.

<head> <link rel="preload" as="image" href="/images/lake-800.avif" type="image/avif" fetchpriority="high"> <link rel="stylesheet" href="/css/site.css"> </head>

The as="image" attribute is required: it sets the request's priority and lets the browser match the preloaded response to the later request from the CSS. The type attribute makes a browser that cannot decode AVIF skip the preload instead of fetching a file it will never use. For a background that changes with the screen, imagesrcset and imagesizes carry the same values as an <img>'s srcset and sizes, and the media attribute limits a preload to a media query. imagesrcset and imagesizes have been Baseline widely available since June 2026.

<link rel="preload" as="image" type="image/avif" imagesrcset="/images/lake-800.avif 1x, /images/lake-1600.avif 2x">

Why it fights the browser

The deck put it plainly: with a preload, you start fighting the browser. The browser's own discovery is not a bug. It finds images in the order they are needed and weighs them against the CSS, fonts and scripts the page also needs. A preload overrides that judgment, and every way it can be wrong costs the reader:

  • It competes. A high-priority image download shares the connection with the style sheet. If the CSS arrives later because of it, the whole page renders later, not just the hero.
  • It can miss. The preload's URL must be exactly the file the CSS ends up choosing. Preload lake-800.jpg while image-set() picks lake-800.avif and the reader downloads both. Chrome warns in the console when a preloaded resource is not used within a few seconds of the page loading.
  • It goes stale. The preload is in the HTML and the image choice is in the CSS. When a redesign changes one and not the other, nothing fails visibly; the page simply gets slower.
  • It defeats laziness. A preloaded image is fetched even if the rule that uses it never matches, such as a hero hidden on small screens.

Often the better fix is to stop hiding the image. If the hero photograph is the largest thing on the screen and the page is slow without it, it may be content after all. An <img> with fetchpriority="high", laid under the heading with object-fit: cover from section 7, is found by the preload scanner, can use srcset with width descriptors, and needs no preload at all. Keep the preload for a background image that truly must be CSS and truly is in the first view. Image Performance treats preloading, priority and LCP in depth.

10. Summary

ConceptKey points
Where an image belongsContent in the HTML, presentation in the CSS. CSS images have no alt text, are found late, do not print by default and are dropped by reader modes. A decorative <img> needs alt="".
Backgrounds and layerscover fills and crops, contain fits and leaves bands; position chooses the crop. Logical position keywords are not Baseline, so flip with :dir(). Layer a scrim over the photograph and a color under both. fixed attachment is unreliable.
GradientsGradients are <image> values with no download: linear, radial, conic and repeating, with hard stops for patterns and in oklch for clean blends.
image-set()Baseline widely available. The browser drops unsupported type() values, keeps the first candidate at each density and picks by DPR. One file is downloaded. No width descriptors.
List markerslist-style-image works everywhere but cannot be sized or recolored. ::marker is safe for color and font; its content does not work in Safari. A string marker is often best.
Generated content, borders, maskscontent: url(x.svg) / "label" gives generated images alt text. border-image takes gradients but ignores border-radius. Unprefixed mask-image is Baseline; a mask over currentColor makes icons that follow the text color.
Fitting replaced contentaspect-ratio shapes the box, object-fit fits the image into it, object-position chooses the crop. Keep width and height on the <img>.
AdaptingSwap layers for dark mode with a media query or light-dark(); plan for backgrounds not printing; in forced colors, gradients vanish and url() images stay.
Preloading<link rel="preload" as="image"> fixes late discovery for a hero background, but competes with the CSS, can double-download and goes stale. Often an <img> with fetchpriority="high" is the better answer.