I Deleted a Photo and Shipped a Gradient
The hero photo needed an 85-90 percent overlay just to stay legible. One token-driven CSS motif replaced both, drift included, reduced-motion safe.
TL;DR
Replacing a hero photo with a tiled CSS gradient exposed a simple truth: the old JPEG was drowned under an 85-90 percent overlay, so the overlay color was doing the real branding work. The new pattern uses color-mix, a design token, and a transform-only drift animation gated by prefers-reduced-motion. Result: one asset deleted, zero downloads, better accessibility.
I had the commit staged. Delete /images/bg-website.jpg, strip the overlay, swap in one div with a gradient. My cursor hovered over the squash button and I caught myself hesitating, because for three years that photo was the hero of this site. Deleting it felt like removing a load-bearing wall.
My first guess was that a replacement background meant new work: a fresh asset, maybe an export from Figma, or worse, an animation library to make it "alive". I even opened the assets folder looking for candidates before I remembered the constraint that actually mattered. The old photo was muted by an overlay anyway: bg-muted/85 in light mode, bg-header/90 in dark. The artwork was too bright, so we drowned it at 85 to 90 percent opacity. Read that again. I was shipping a JPEG so I could hide it.
he finding
The new background is one div, aria-hidden="true", with a Tailwind arbitrary background:
tiled with background-size: 100px 100px. The color comes from --color-accent, so light mode, dark mode, and any future rebrand pick it up automatically. No asset pipeline, no alt text debates, no 200 KB download for something sitting under an overlay.
The mechanics are less exotic than they look. color-mix() takes color values and returns the mix in a given colorspace, oklab by default; when the declared percentages total under 100%, mixing with transparent works through an alpha multiplier [9]. That transparent 92% is not a typo, it is exactly how you get a whisper of accent instead of a solid bar. And the tiling: background-size with two values sets width and height, each relative to the background positioning area [10]. Two gradients per 100-pixel tile, repeated across the hero. That is the whole motif.
he drift
A static gradient would have been fine. But stripes that sit perfectly still look like a rendering bug, so the commit adds drift: the pattern slides 120 pixels sideways over 28 seconds, then loops. The keyframes are the waypoints and the animation property is what drives them [1], nothing more:
@keyframes hero-stripes {
from {
transform: translate3d(0, 0, 0);
}
to {
transform: translate3d(120px, 0, 0);
}
}
@media (prefers-reduced-motion: no-preference) {
.hero-motif {
animation: hero-stripes 28s linear infinite;
}
}
The media query is the part I would have skipped two years ago. It gates the animation so users with reduced motion enabled get the stripes as a static layer, only the drift is off [2]. And the animation itself is transform-only, which means it never triggers layout or paint and stays on the compositor [4]. A 28-second loop of a background-position animation would have been a different story on low-end phones.
One detail worth stealing: aria-hidden="true" removes the decorative layer from the accessibility tree entirely [5]. Screen readers never meet the stripes. They were never content; they are texture.
hy the overlay was the smell
Here is the opinion part, and I will die on this hill: photo backgrounds behind heavy overlays are dead weight. If your hero photo needs bg-muted/85 on top of it to be legible, the photo is not doing branding work, the overlay color is. You are paying bytes and maintenance for a texture you deliberately suppress. The gradient version keeps the same texture role, costs nothing to download, inherits its color from a design token, and animates at 60fps for free.
The commit diffs were two files, frontend/src/app/globals.css and frontend/src/components/home/hero.tsx. Net effect: an asset deleted, an overlay deleted, roughly a dozen lines of CSS added.
I hesitated over the squash because deleting a photo felt like losing something. It was the opposite. The photo was the thing standing between the hero and a token-driven CSS motif that carries the brand better. Next time a design leans on "a background image with an overlay to tone it down", I am going to ask what the overlay is doing, because nine times out of ten that is the real design, and the photo is just its delivery mechanism.