SVG Background Image in CSS: Data URIs, Sizing, Colour
An SVG background image in CSS is one line — background-image: url("icon.svg") — and nearly everything that goes wrong happens around it. The element has no height, a # in a data URI cuts the file short, background-size letterboxes a drawing you meant to stretch, and fill does nothing because the page's CSS never reaches inside the image. Below: file versus data URI with measured sizes, the escaping rules, a helper that encodes correctly, sizing, recolouring, tiling and accessibility.
SVG background image CSS: the basic rule
Point background-image at the file and set the other three background properties explicitly, because their defaults suit photographs rather than icons. background-repeat defaults to repeat, background-position to the top-left corner, and background-size to auto, which for an SVG depends on what the file declares (more on that in the sizing section).
/* A decorative icon beside text. The element gets its height
from the text, so the background has a box to paint in. */
.note {
padding-left: 1.75rem;
background-image: url("/img/info.svg");
background-repeat: no-repeat; /* default is repeat */
background-position: 0 0.15em; /* default is the top-left corner */
background-size: 1.25rem 1.25rem; /* default auto depends on the file */
}
/* An empty element has no size, so nothing paints until it has one. */
.status-dot {
display: inline-block;
width: 0.75rem;
height: 0.75rem;
background: url("/img/dot.svg") center / contain no-repeat;
}Two things catch people first. A background paints inside the element's box, so an empty <span> with no width or height paints nothing. And the SVG is loaded as an image, a restricted mode: according to MDN's SVG as an image, scripts are disabled and external resources such as stylesheets and images are not loaded unless they are inlined as data: URLs. A file that looks right when opened directly can lose its web font or its linked photo as a background. If it still does not appear, the SVG not showing checklist covers the rest.
File URL or data URI
There are two ways to supply the image: a URL pointing at a .svg file, or the markup itself inlined into the stylesheet as a data: URL. Neither is better in general; they put the cost in different places.
| url() to a file | Data URI in the CSS | |
|---|---|---|
| Requests | One per file, fetched only once a rule using it matches a rendered element | None; it arrives with the stylesheet |
| Caching | Cached on its own and shared by every page and stylesheet | Downloaded again whenever the stylesheet changes |
| First paint | Can pop in after the layout | Present on the first paint that uses the rule |
| Cost to CSS | None | Every byte adds to render-blocking CSS |
| Used in several rules | Free | Duplicated, unless held in one custom property |
| Content-Security-Policy | img-src must allow the host | img-src must allow data: |
| Best for | Large art, or art used on few pages | Small icons and tiles used on most pages |
The late fetch is the one people notice: a hover-state icon in its own file is requested on the first hover and can flash blank, so inline those. Caching cuts the other way: a large illustration inlined into the main stylesheet is downloaded again with every deploy, and delays first render on pages that never show it. The CSP row is easy to miss — with img-src 'self', Chrome blocked both a data URI background and a data URI mask in our test. When one data URI serves several rules, declare it once as a custom property:
/* Declared once, so the bytes appear in the stylesheet once. */
:root {
--icon-check: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M5 12l5 5L20 7' fill='none' stroke='%2316a34a' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
}
.checklist li {
padding-left: 1.5em;
background: var(--icon-check) 0 0.2em / 1em no-repeat;
}
.toast-success {
padding-left: 3rem;
background: var(--icon-check) 1rem center / 1.25rem no-repeat;
}URL-encoding vs base64, measured
A data URI can carry the SVG as percent-encoded text or as base64. Base64 exists to put binary bytes into text; SVG is already text, so encoding it again only makes it bigger. How much bigger is arithmetic. Base64 writes every three bytes as four characters. The globe.svg that create-next-app ships is 1,035 bytes, so its base64 body is 4 × ⌈1,035 ÷ 3⌉ = 1,380 characters, plus 26 for data:image/svg+xml;base64, — 1,406 in all. URL-encoded with the helper below, only 23 characters need escaping (3 #, 10 <, 10 >), each growing from one character to three: 1,035 + 46 + 19 for the prefix = 1,100. Base64 is 306 bytes, or 27.8%, larger.
| Sample | SVG bytes | URL-encoded URI | base64 URI | base64 larger by | Gzipped rule (URL / base64) |
|---|---|---|---|---|---|
| 24 × 24 dot tile | 114 | 147 | 178 | 21.1% | 163 / 201 |
| globe.svg (create-next-app) | 1,035 | 1,100 | 1,406 | 27.8% | 583 / 843 |
| This site’s logo, comments stripped | 1,735 | 1,908 | 2,342 | 22.7% | 640 / 1,135 |
| 8 KB illustration from our gallery | 8,218 | 8,329 | 10,986 | 31.9% | 3,808 / 5,377 |
Two details matter more than the headline. Stylesheets travel compressed, and base64 compresses badly: it encodes three bytes at a time, so a repeated run such as stroke="#FFFFFF" becomes different characters depending on where it falls, and gzip loses the repeats it feeds on — the logo's gap widens to 640 against 1,135 bytes. And URL-encoding only wins if you encode the minimum. encodeURIComponent over the whole file escapes every space, quote, slash and equals sign; for globe.svg that gave 1,494 bytes, more than base64.
/* URL-encoded: 147 characters, readable, colour editable in place. */
.dots {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Ccircle cx='12' cy='12' r='1.5' fill='%2394a3b8'/%3E%3C/svg%3E");
background-size: 24px 24px;
}
/* The same 114-byte SVG as base64: 178 characters, opaque. */
.dots {
background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PGNpcmNsZSBjeD0iMTIiIGN5PSIxMiIgcj0iMS41IiBmaWxsPSIjOTRhM2I4Ii8+PC9zdmc+");
background-size: 24px 24px;
}Which characters to escape, and why
Inside url("…") the SVG is read twice — first as a CSS string, then as a URL — and each layer treats some characters specially. MDN's data URL reference gives the general rule: reserved characters in the data must be percent-encoded. We checked the #, %, line-break and angle-bracket rows in Chrome 156 by rendering the same small SVG with and without the escape.
| Character | What goes wrong | Write it as |
|---|---|---|
# | Starts the URL fragment. Everything after it is dropped, the SVG ends mid-attribute and nothing renders. Hex colours trip this. | %23 |
% | The escape character. Followed by two hex digits it decodes as a byte: "5%e2" in a text element broke the file. A bare 50% survived only because no hex digits followed. | %25 |
| The wrapping quote | Ends the CSS string early. | The other quote, or %22 / %27 |
| Line breaks | A raw newline inside a CSS string invalidates the declaration. | A space |
\ | CSS reads it as the start of an escape. | %5C |
< > | Nothing in current browsers; the test SVG rendered with them raw. But in CSS inlined in an HTML style element, a </style> inside the SVG ends that element. | %3C %3E |
| Non-ASCII text | Works only if the stylesheet is decoded as UTF-8. | Percent-encoded UTF-8 |
The angle brackets are most of URL-encoding's overhead — 20 of the 23 escapes in globe.svg. If your data URIs only ever live in .css files, dropping them from the helper is safe and saves two bytes per tag; keeping them makes the output safe to paste anywhere.
A helper that encodes SVG properly
The rules fit in a dozen lines, with no dependencies, in Node or the browser. This is the exact function that produced the measurements above.
// svg-data-uri.mjs: URL-encode an SVG for CSS url("...")
export function svgToDataUri(svg) {
// A raw line break inside a CSS string is a parse error,
// so collapse runs of XML whitespace to a single space.
let body = svg.trim().replace(/[\t\n\r ]+/g, ' ')
// Swap attribute quotes for single quotes so the url("...")
// wrapper can stay double-quoted. Skipped when the file already
// uses single quotes: font-family="'Inter', sans-serif" would
// otherwise turn into broken XML.
if (!body.includes("'")) body = body.replace(/"/g, "'")
// Percent-encode % # < > " \ and anything outside printable
// ASCII. The u flag keeps emoji and other astral characters in
// one piece, so encodeURIComponent never sees half a pair.
body = body.replace(/[%#<>"\\]|[^ -~]/gu, encodeURIComponent)
return `data:image/svg+xml,${body}`
}Run it at build time, not on every page load. This script turns a folder of icons into custom properties for the var(--icon-check) pattern:
// build-icons.mjs: node build-icons.mjs > src/styles/icons.css
import { readFileSync, readdirSync } from 'node:fs'
import { svgToDataUri } from './svg-data-uri.mjs'
const lines = readdirSync('icons')
.filter((file) => file.endsWith('.svg'))
.map((file) => {
const name = file.slice(0, -'.svg'.length)
const uri = svgToDataUri(readFileSync(`icons/${file}`, 'utf8'))
return ` --icon-${name}: url("${uri}");`
})
console.log(`:root {\n${lines.join('\n')}\n}`)Two limits. The helper does not minify, so strip editor metadata and comments first — with SVGO or our SVG optimiser — and its whitespace collapse assumes the file does not rely on xml:space="preserve". To convert base64 you already have, including the editor's Copy Data URI output, decode it as UTF-8 and re-encode:
// From a base64 data URI (such as the editor's "Copy Data URI")
// to the smaller URL-encoded form.
import { svgToDataUri } from './svg-data-uri.mjs'
const copied = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PGNpcmNsZSBjeD0iMTIiIGN5PSIxMiIgcj0iMS41IiBmaWxsPSIjOTRhM2I4Ii8+PC9zdmc+'
// Decode the bytes as UTF-8, not Latin-1, so non-English text survives.
const bytes = Uint8Array.from(atob(copied.split(',')[1]), (c) => c.charCodeAt(0))
const svg = new TextDecoder().decode(bytes)
console.log(svgToDataUri(svg))
// data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Ccircle cx='12' cy='12' r='1.5' fill='%2394a3b8'/%3E%3C/svg%3EOne thing to know when the input comes from the editor: its export writes explicit width and height onto the root <svg>, which changes how background-size: auto treats it.
background-size, viewBox and preserveAspectRatio
background-size sets the box the image is drawn into. The SVG's own viewBox and preserveAspectRatio then decide how the drawing sits inside that box, and most sizing surprises come from forgetting the second half. MDN's scaling SVG backgrounds guide sets out the full algorithm; the short version depends on what the root element declares. We confirmed the first two rows in Chrome.
| The <svg> declares | background-size: auto | contain, cover or one length |
|---|---|---|
| viewBox only | No size, only a ratio, so it behaves like contain: a square icon in a 200 × 100 box draws at 100 × 100 | Scales, keeping the ratio |
| width, height and viewBox | Drawn at width × height: a 24 × 24 icon stays 24 px | Scales, keeping the ratio |
| Neither | Fills the background area | Fills the background area |
/* dot.svg declares viewBox="0 0 24 24" and no width or height:
a ratio but no size, so auto behaves like contain. */
.a { background: url("/img/dot.svg") no-repeat; }
/* Lengths set the box. Inside it, the file's preserveAspectRatio
still decides the fit: in a 200 x 100 element this square drawing
is centred, not stretched, and "left" changes nothing. */
.b { background: url("/img/dot.svg") left / 100% 100% no-repeat; }
/* Fit inside, or fill and crop, keeping the ratio. */
.c { background: url("/img/dot.svg") center / contain no-repeat; }
.d { background: url("/img/dot.svg") center / cover no-repeat; }
/* A divider that is meant to stretch says so in its own file. */
.wave { background: url("/img/wave.svg") bottom / 100% 80px no-repeat; }Rule .b is where people go wrong. 100% 100% on a 2:1 element makes a 2:1 image box, but the default preserveAspectRatio="xMidYMid meet" fits the square drawing inside it, centred — and left is ignored, because the box already fills the element. Alignment inside the box is the file's job: xMinYMid meet pins it left, none lets it stretch. The viewBox guide covers all nine alignments.
<!-- wave.svg: preserveAspectRatio="none" lets background-size
stretch the drawing to any ratio. Never do this to a logo. -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 80"
preserveAspectRatio="none">
<path d="M0 40C300 0 900 80 1200 40V80H0Z" fill="#f1f5f9"/>
</svg>SVGs generated in the SVG Maker AI dashboard come back with a viewBox and no width or height — the server strips them so the drawing scales — so they fall in the first row of the table. A file exported from the editor has width and height written on, so it falls in the second. Set background-size explicitly and neither detail matters.
Changing the colour of an SVG background
You cannot set fill on a background SVG from the page. The image is a separate document: your selectors do not match inside it, custom properties do not inherit into it, and currentColor resolves inside the image, not against the element wearing the background. We tested both: a circle with fill="currentColor" on an element with color: red, and one with fill="var(--brand)" with --brand set on the element. Both rendered black, the initial fill. The workarounds all take the colour from somewhere else:
- Use it as a mask. The SVG becomes a stencil and
background-colorsupplies the paint, so it can followcurrentColor, a theme or a hover state, at the cost of being one colour. Styling SVG with CSS covers the mechanics; the new part below is the forced colours fix. - Bake colour variants at build time. In a URL-encoded data URI the colour is readable text (
stroke='%2316a34a'), so a variant is a function call. - Keep separate files.
check-green.svgandcheck-grey.svgeach cache on their own. Fine for two colours, tedious for a palette. - A filter chain. Approximate, hard to maintain, and it filters everything else the element paints. A last resort.
- Stop using a background. If the drawing must follow the theme in several colours, inline it. SVG icons vs icon fonts and SVG in Next.js cover the inline routes.
/* The SVG supplies only the shape; the colour comes from the page,
so it follows the text colour, the theme and :hover. */
.icon-check {
display: inline-block;
width: 1.25rem;
height: 1.25rem;
background-color: currentColor;
-webkit-mask: var(--icon-check) center / contain no-repeat;
mask: var(--icon-check) center / contain no-repeat;
}
/* Forced colours mode replaces background-color with the page's
own background colour, and the icon vanishes. Opt this element
out and paint it in the system text colour instead. */
@media (forced-colors: active) {
.icon-check {
forced-color-adjust: none;
background-color: CanvasText;
}
}import { svgToDataUri } from './svg-data-uri.mjs'
// One drawing; the colour is decided at build time.
const tick = (colour) =>
svgToDataUri(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M5 12l5 5L20 7" fill="none" stroke="${colour}" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/></svg>`)
console.log(`.status-ok { background-image: url("${tick('#16a34a')}"); }
.status-ok:hover { background-image: url("${tick('#166534')}"); }`)For dark mode, the same choice applies: either the mask follows the text colour on its own, or you swap the URL inside a prefers-color-scheme media query in your own stylesheet.
Repeating SVG background patterns without seams
A small tile with background-repeat: repeat is the cheapest pattern there is — the URL-encoded dot tile above is 147 bytes. Two problems turn up at tile edges, and both are geometry rather than encoding.
The first is half-weight lines. Anything outside the viewBox is clipped, so a grid line drawn exactly on the tile edge loses half its stroke, and the tiled grid draws at half the weight you designed: 2 px instead of 4 px in our test at a 48 px tile. Move edge lines in by half the stroke width.
<!-- On the edge: half of each 2-unit stroke falls outside the
viewBox and is clipped, so the tiled grid draws at half weight. -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M0 0H24M0 0V24" stroke="#334155" stroke-width="2"/>
</svg>
<!-- Moved in by half the stroke width: full weight, and each line
still meets its neighbour in the next tile. -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M0 1H24M1 0V24" stroke="#334155" stroke-width="2"/>
</svg>The second is hairline seams. In Chrome, a solid tile repeated at a fractional 7.3 px showed no gaps with repeat, but with background-repeat: round — which rescales tiles so a whole number fits — the white element background bled through as light vertical seams. The fix costs nothing: put the ground colour on the element and leave the tile transparent, so any gap shows the right colour. Then prefer whole-pixel tile sizes and repeat.
.graph-paper {
/* Ground colour on the element, not in the tile: a hairline
between tiles then shows the same colour, not white. */
background-color: #0f172a;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Cpath d='M0 .5H32M.5 0V32' stroke='%23334155'/%3E%3C/svg%3E");
background-size: 32px 32px; /* whole CSS pixels */
background-repeat: repeat; /* not round, which rescales tiles */
}The tile arithmetic — half-drops, bricks, motifs that cross the edge — is in our SVG pattern guide. There is no pattern tool on this site; that page explains the maths and points to free generators that do it with sliders.
HiDPI sharpness and rendering cost
An SVG background is rasterised at the size it is painted, multiplied by the device pixel ratio, so one file is sharp at 1×, 2× and 3× with no @2x variant. When an SVG background looks soft, the cause is usually one of three things:
- It contains a bitmap. An SVG wrapping a PNG in an
<image>element is a raster image with extra steps. What an SVG file is shows how to spot one. - The drawing was made for another size. A 1-unit stroke in a 1,000-unit viewBox painted at 24 px is 0.024 px wide, which anti-aliases into a faint smear. That is the file, not the screen.
- The element is being scaled with a transform. While a scale animation runs, the browser may stretch an existing bitmap and re-rasterise only afterwards.
Rendering cost is about area and effects. A tiled pattern is a small drawing repeated; a full-viewport SVG with blur filters is rasterised at full size, and again whenever that size changes — resize, zoom, a container animating its width. Keep filters out of large backgrounds.
Accessibility: backgrounds say nothing
A CSS background is not in the accessibility tree. It has no alternative text, screen readers skip it, and by default browsers leave it out of printouts. The test is simple: if removing the image would lose information, it must not be a background.
<!-- Decorative: the text carries the meaning. A background is fine. -->
<p class="note">Changes are saved as you type.</p>
<!-- Icon-only control: the background says nothing to a screen
reader, so the accessible name has to come from the markup. -->
<button type="button" class="icon-btn icon-trash" aria-label="Delete draft"></button>
<!-- Content: not a background at all. -->
<img src="/img/route.svg" alt="Walking route from the station to the venue">Forced colours mode — Windows high contrast — adds a trap specific to the mask trick. MDN's forced-colors reference lists background-color among the properties replaced with system colours, while url() background images are left alone. So a plain SVG background survives, and a mask icon does not: its background-color becomes the page's own background colour and the icon vanishes. We reproduced this in Chrome with forced colours emulated. Setting background-color: CanvasText alone did not bring it back; it also needed forced-color-adjust: none, as in the mask example above. For contrast and naming patterns beyond backgrounds, see accessible SVG.
Where to go next
If you are making the SVG rather than only encoding it, the SVG editor lets you paste markup into its code panel, adjust shapes and colours, and copy it out as a file or as a data URI — base64, so run it through the helper above. The SVG optimiser page explains minifying before you encode; the optimiser itself runs in your browser from Dashboard → Tools, and its highest settings can be lossy, so compare the result. For tiles, start with the SVG pattern guide; for a motif to tile, the text to SVG route generates one from a prompt in the dashboard.
Further reading: how to style SVG with CSS for inline SVG, the viewBox guide for the scaling model behind the sizing table, and optimising SVG for size and speed before anything goes into a data URI.
Frequently asked questions
- How do I use an SVG as a background image in CSS?
- Point background-image at the file with url("icon.svg"), then set background-repeat, background-position and background-size explicitly, because the defaults repeat the image from the top-left corner. The element also needs a size: an empty span with no width or height paints nothing. You can link a .svg file or inline the markup as a data URI; both work in every current browser.
- Should an SVG background be a file or a data URI?
- Inline small icons and tiles used on most pages as data URIs: they arrive with the stylesheet and never pop in late. Link large art, or art used on only a few pages, as a file: it is cached on its own and does not bloat render-blocking CSS or get downloaded again every time the stylesheet changes. If a Content-Security-Policy is set, data URIs need data: in img-src.
- Is it better to URL-encode or base64 an SVG data URI?
- URL-encode it. SVG is already text, and base64 turns every three bytes into four characters. On four real files we measured base64 data URIs 21 to 32 percent larger than minimally URL-encoded ones, and the gap widened after gzip. Encoding only the characters that need it matters: running encodeURIComponent over a whole file produced a result larger than base64 for one sample.
- Which characters need to be escaped in an SVG data URI?
- Always escape # as %23, because it starts the URL fragment and truncates the SVG, and % as %25, because it is the escape character. Avoid the quote that wraps the CSS string, remove raw line breaks, and escape backslashes as %5C. Angle brackets are optional in current browsers but worth escaping as %3C and %3E so the URI is safe inside an HTML style element.
- Can I change the colour of an SVG background image with CSS?
- Not with fill. A background SVG is a separate document, so page selectors, custom properties and currentColor do not reach inside it. Instead, use the SVG as a mask-image and paint it with background-color, generate colour variants at build time, keep one file per colour, or inline the SVG. A filter chain also works, but only approximately.
- Why does background-size not stretch my SVG?
- background-size sets the box the image is drawn into, but the SVG's own preserveAspectRatio decides how the drawing fits inside that box. The default, xMidYMid meet, keeps the drawing's ratio and centres it, so a square icon set to 100% 100% in a wide element stays square. Add preserveAspectRatio="none" to the root svg element if the drawing is meant to stretch, as a wave divider is.
- Why are there lines between my repeating SVG background tiles?
- Usually it is geometry or rescaling. Lines drawn exactly on the tile edge lose half their stroke to clipping, so move them in by half the stroke width. background-repeat: round rescales tiles to fractional sizes, and in our Chrome test that let the element's background show through as hairline seams. Put the ground colour on the element as background-color and prefer whole-pixel tile sizes with repeat.
- Are SVG background images accessible?
- No. CSS backgrounds are not in the accessibility tree, have no alternative text and are not printed by default, so use them only for decoration. Meaningful images need an img with alt text or inline SVG with an accessible name, and icon-only buttons need an aria-label. Mask-based icons can vanish in forced colours mode unless you set forced-color-adjust: none and a system colour such as CanvasText.
