Gradient backgrounds for websites: full-page CSS that stays readable and fast
How to add a gradient background to a website: full-page CSS without stripes, text that passes contrast, hero heights on phones, and smooth scrolling.
To put a gradient behind a whole website, set it on body and give html at least the height of the window:
html {
min-height: 100%;
}
body {
margin: 0;
background: linear-gradient(to bottom, #ffcf9f, #f472b6, #4c1d95);
}That covers most sites. Four things go wrong after that: the gradient repeats in stripes on short pages, background-attachment: fixed makes scrolling expensive, text stops being readable somewhere along the gradient, and a 100vh hero runs under the toolbar on phones. Each one has a short fix, and the measurements below come from headless Chrome 154.
Why a body gradient repeats in stripes
The background you set on body is painted across the whole window, but the gradient image is sized to the root element, html. On a short page html is only as tall as its content, so the gradient is that tall too and the browser tiles it down the rest of the window.
In an 800 by 800 pixel window with 200 pixels of content, the gradient above renders as four red-to-blue bands, 200 pixels each. Either fix gives one smooth gradient from top to bottom:
/* Option 1: the root element is at least as tall as the window. */
html {
min-height: 100%;
}
/* Option 2: the body is at least as tall as the window. */
body {
min-height: 100vh;
}On a long page both stretch the gradient over the full document. A 10,000 pixel page spreads three colours over 10,000 pixels, so each screen shows mostly one colour. If you want every screen to show the whole gradient, pin it to the window instead.
Pin the gradient with a fixed layer, not background-attachment
background-attachment: fixed keeps the gradient still while the content scrolls over it. It works, but in our Chrome 154 test it repainted the background on every scrolled frame. A fixed pseudo-element looks the same and, in the same test, barely painted at all:
body::before {
content: "";
position: fixed;
inset: 0;
z-index: -1;
background: linear-gradient(to bottom, #ffcf9f, #f472b6, #4c1d95);
}We scrolled a long text page for 3 seconds in headless Chrome at 800 by 800 and counted trace events, twice per version:
| Version | Paint events | Raster time |
|---|---|---|
background-attachment: fixed | 182 | 395–435 ms |
position: fixed pseudo-element | 7 | 6–7 ms |
| Gradient that scrolls with the page | 7 | 15 ms |
With fixed, the browser painted once per frame for the whole scroll. With the pseudo-element it painted 7 times in total, the same as a plain gradient that scrolls away. The simplest setup leaves body without a background when you use the layer. If html has a background of its own, a background on body paints over the layer. If only body has one, it moves to the canvas behind the layer and does no harm.
Keep text readable along the whole gradient
A gradient has many background colours, and the text has to pass contrast on the worst one. WCAG asks for 4.5:1 for body text and 3:1 for large text (24px, or about 18.7px bold). Here is the example gradient, its three stops and the two midpoints, against white and against near-black #0f172a:
| Background | White text | #0f172a text |
|---|---|---|
#ffcf9f peach (top) | 1.43:1 | 12.48:1 |
#faa0aa peach-pink midpoint | 1.97:1 | 9.07:1 |
#f472b6 pink | 2.65:1 | 6.74:1 |
#a048a6 pink-violet midpoint | 5.29:1 | 3.38:1 |
#4c1d95 violet (bottom) | 10.95:1 | 1.63:1 |
No single text colour passes everywhere. Dark text works on the top half, white text on the bottom third, and both fail somewhere in between. You have three ways out: put text only where it passes, switch the text colour between sections, or darken the gradient under the text with a scrim.
A scrim is a translucent black layer. CSS takes several backgrounds on one element, so it can sit in the same rule as the gradient:
.hero {
background:
linear-gradient(rgb(0 0 0 / 0.5), rgb(0 0 0 / 0.5)),
linear-gradient(to bottom, #ffcf9f, #f472b6, #4c1d95);
}How dark it needs to be depends on the lightest colour under the text. For white text over the peach stop:
| Scrim | Peach becomes | White text on it |
|---|---|---|
| 30% black | #b2916f | 2.93:1 |
| 40% black | #997c5f | 3.89:1 |
| 50% black | #806850 | 5.23:1 |
At 40% the peach passes for large text only. At 50% every point of the gradient passes 4.5:1 with white text. Browsers blend the scrim in plain sRGB: Chrome rendered the peach under a 50% scrim as #806750, within one unit of the computed value.
A full scrim dulls the whole gradient. To keep the colour, darken only the box behind the text. The demo shows all three:
Ship faster
White text at the top of the gradient.
Ship faster
White text at the top of the gradient.
Ship faster
White text at the top of the gradient.
import type { ReactNode } from "react";
/**
* Text on a website gradient, three ways, from the gradient backgrounds for
* websites blog post. All plain CSS. The gradient runs peach to pink to deep
* violet, so white text fails on the light top without a scrim.
*/
const gradient = "linear-gradient(to bottom, #ffcf9f, #f472b6, #4c1d95)";
const scrim = "linear-gradient(rgb(0 0 0 / 0.5), rgb(0 0 0 / 0.5))";
function Panel({
title,
background,
children,
}: {
title: string;
background: string;
children: ReactNode;
}) {
return (
<figure className="flex flex-col gap-2">
<div
className="relative flex h-56 flex-col justify-start overflow-hidden rounded-lg p-5"
style={{ background }}
>
{children}
</div>
<figcaption className="text-xs text-muted">{title}</figcaption>
</figure>
);
}
const copy = (
<>
<p className="relative text-lg font-semibold text-white">Ship faster</p>
<p className="relative mt-1 text-sm text-white">
White text at the top of the gradient.
</p>
</>
);
export function WebsiteGradientScrimDemo() {
return (
<div className="grid w-full gap-4 p-4 sm:grid-cols-3">
<Panel title="No scrim: 1.43:1 on the peach stop" background={gradient}>
{copy}
</Panel>
<Panel
title="50% black scrim over everything: 5.23:1 or better"
background={`${scrim}, ${gradient}`}
>
{copy}
</Panel>
<Panel title="50% scrim behind the text only" background={gradient}>
<div className="rounded-md bg-black/50 p-3">{copy}</div>
</Panel>
</div>
);
}
Hero heights on phones: use svh
A full-screen gradient hero is usually min-height: 100vh. On phones, 100vh is the height of the window with the browser toolbar hidden, so on first load the bottom of the hero sits under the toolbar and anything centred in it sits low. 100svh is the height with the toolbar showing, so the whole hero fits:
.hero {
min-height: 100vh; /* older browsers */
min-height: 100svh;
}The small, large and dynamic viewport units (svh, lvh, dvh) work in Chrome 108, Firefox 101 and Safari 15.4 and later, according to MDN's compatibility data. dvh follows the toolbar as it moves. That sounds better, but the hero then changes height while the visitor scrolls. svh holds still.
Joining gradient sections without seams
Two sections with their own gradients, stacked edge to edge, show a hard line where they meet unless the colours match there. Either end the first gradient on the colour the second begins with, or fade the section into the page background:
.section {
position: relative;
isolation: isolate;
background: linear-gradient(135deg, #ffcf9f, #f472b6, #4c1d95);
}
.section::after {
content: "";
position: absolute;
inset: auto 0 0 0;
height: 40%;
z-index: -1;
background: linear-gradient(to bottom, transparent, var(--background, #fff));
}isolation: isolate keeps the fade's z-index: -1 inside the section, so it sits over the section's gradient and under its text. The fade covers the bottom 40%, the same proportion the Hero Background component below uses.
CSS, image, video or a live gradient
A CSS gradient adds nothing to download and paints once, until it resizes or moves. It can move with @property and keyframes, but animating its colours, angle or background-position paints it again every frame. Sliding the whole gradient layer with transform avoids that, because the compositor moves it. The other options each buy something CSS can't do cheaply:
| Option | What it adds | Cost |
|---|---|---|
| CSS gradient | Nothing to download | Shapes are lines, ellipses and cones; animating them repaints |
| Image (AVIF, WebP, PNG) | Any texture, including grain | Bytes, and one fixed resolution |
| Video loop (MP4) | Any motion | About 1 MB for a clean 10-second 1080p clip; grain makes it far bigger |
| Live WebGL or WebGPU canvas | Motion at any size, colours from CSS variables | About 52 KB of gzipped JavaScript up front, and GPU time while it plays |
Background video vs animated gradient has the video measurements, including why grain multiplies the file size. CSS gradient vs WebGL gradient traces the paint cost of animated CSS gradients and covers when a canvas is worth it.
A live gradient background in React
For a React site, the free Hero Background component is a full-width section on a live mesh gradient. It installs with the shadcn CLI into any project with a components.json, no account needed:
npx shadcn@latest add https://gradients.sh/r/hero-background.jsonimport { HeroBackground } from "@/components/hero-background";
export function Hero() {
return (
<HeroBackground>
<h1 className="text-5xl font-semibold text-white">Your headline</h1>
</HeroBackground>
);
}The section is at least 560px tall, and its bottom 40% fades into var(--background) so the next section starts clean (fade={false} turns that off). Before the first frame draws, the canvas shows a CSS gradient in the same colours. It renders only while it's on screen and the tab is visible, and visitors who prefer reduced motion get one still frame. Pass kind="aurora" or another style for a different look, or open the gradient editor to pick colours. The preset library has the ready-made looks, sorted into collections.
Related
- Animated gradient background for shadcn/ui sets up Hero Background step by step, with theme colours.
- How to make a gradient follow light and dark mode themes a background with CSS variables and
light-dark(). - Sunset gradients in CSS has seven warm palettes with hex codes, and why a sunset is ordered from the horizon up.
- How to add film grain to a gradient adds texture to a background without an image.