Your hero background is a JPEG. It could be zero kilobytes.
I audited a landing page last month and the heaviest thing on it was not the framework, not the fonts, not the analytics script. It was a 412 KB hero image.
The image was a gradient. A soft purple-to-teal wash with a bit of grain. Someone had built it in a design tool and exported it as a JPEG, because that is how we have always done it.
It was also the LCP element. So the single biggest number on the performance report was a decoration.
The real cost of a background image
A background image is never just a background image. Count what it actually costs you:
- One network request on the critical path, before the hero can render.
- Multiple exports if you support dark mode, or a second brand colour, or a seasonal campaign.
- Multiple resolutions if you care about retina. A 1x and a 2x, at minimum.
- A build step nobody remembers to re-run when the brand colour shifts two degrees.
- An LCP penalty, because you cannot lazy-load the thing above the fold.
Five costs, zero of which are the gradient itself. The gradient is trivial. Everything around it is the tax.
What CSS gives you for free
Here is an aurora wash in pure CSS. No image, no request, no export step:
.aurora {
background:
radial-gradient(60% 80% at 20% 10%, oklch(72% 0.18 300 / 0.55), transparent 70%),
radial-gradient(50% 70% at 80% 20%, oklch(70% 0.16 200 / 0.50), transparent 70%),
radial-gradient(70% 60% at 50% 90%, oklch(78% 0.14 160 / 0.40), transparent 70%),
#0b0b12;
}
That is it. Four layers, a few hundred bytes, and it scales to any viewport without a second export. Change 300 to 320 and you have a new brand direction — in one commit, not one afternoon.
The grain is the part people assume needs an image. It does not:
.grain::after {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
opacity: 0.28;
mix-blend-mode: soft-light;
background-image: url("data:image/svg+xml,\
<svg xmlns='http://www.w3.org/2000/svg' width='140' height='140'>\
<filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='3'/></filter>\
<rect width='140' height='140' filter='url(%23n)'/></svg>");
}
An inline SVG turbulence filter, URL-encoded into the stylesheet. It is a data URI, so it ships inside the CSS bundle — no extra round trip. The browser generates the noise procedurally.
And glass, which is the one everybody reaches for a PNG to fake:
.glass {
background: color-mix(in oklab, white 12%, transparent);
backdrop-filter: blur(18px) saturate(140%);
border: 1px solid color-mix(in oklab, white 22%, transparent);
}
backdrop-filter is genuinely doing the work here — it samples what is behind the element. A blurred PNG of a blurred background is a snapshot of a moment that stops being true the second anything moves behind it.
The part nobody warns you about
CSS backgrounds are cheap, not free. backdrop-filter and large animated gradients both cost GPU time, and on a low-end Android device a full-viewport animated blur can turn a smooth 60 fps into a stutter.
So the useful mental model is not "CSS good, images bad." It is tiering.
- Low tier — static CSS gradients, SVG turbulence, solid fills. Safe everywhere, including old devices.
-
Mid tier — animated gradients,
backdrop-filter,mix-blend-mode. Fine on modern hardware, measurable cost on cheap hardware. - High tier — WebGL / canvas particle fields. Beautiful, and the first thing that should degrade.
The tier is a property of the effect, and it should be documented next to the effect. That is exactly how the background section on Motif UI is organised — each background is labelled with its implementation (Pure CSS, CSS / SVG, CSS / WebGL) and its performance tier, so you can pick with your eyes open instead of discovering the cost in production.
Degrading well
Once you accept tiers, the fallback writes itself. Keep the static gradient as the base layer, and only add the expensive layer when the device can take it:
.motes { background: /* static gradient base */; }
@media (prefers-reduced-motion: no-preference) and (min-width: 768px) {
.motes::before { animation: drift 24s linear infinite; }
}
Two guards, both free. prefers-reduced-motion handles the users who asked for stillness. The width check is a crude but effective proxy for "probably not a 2016 budget phone."
For the genuinely low-end case, a @supports check on backdrop-filter keeps you honest:
@supports not (backdrop-filter: blur(1px)) {
.glass { background: rgb(22 22 30 / 0.92); }
}
A near-opaque panel instead of glass. Slightly less pretty, completely legible, and it never causes a repaint storm.
Two accessibility details that bite
Mark decoration as decoration. A purely visual background layer should be aria-hidden="true" if it is an element, and must never be the only carrier of meaning. If your gradient encodes a state — red for error, green for success — that is a text problem, not a background problem.
Check contrast against the worst case, not the average. A gradient means the contrast ratio varies across the panel. Measure your text against the lightest and darkest points the text actually sits on. A wash that passes at the top can fail badly at the bottom corner where the third radial layer lands.
What I would do this week
- Open DevTools, filter the network panel by
Img, and find the largest image above the fold. - Ask honestly: is this a photograph, or is it a gradient / noise / blur?
- If it is the latter, rebuild it as CSS. Keep the image as a
<picture>fallback only if you genuinely need one. - Write the tier next to the effect in a comment. Future-you will not remember.
The gradient was never the expensive part. The pipeline around it was.
I keep the ones I actually ship — with their tier labels and audit notes attached — over at Motif UI, if you would rather start from something measured than from a blank stylesheet.
Top comments (0)