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:

VersionPaint eventsRaster time
background-attachment: fixed182395–435 ms
position: fixed pseudo-element76–7 ms
Gradient that scrolls with the page715 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:

BackgroundWhite text#0f172a text
#ffcf9f peach (top)1.43:112.48:1
#faa0aa peach-pink midpoint1.97:19.07:1
#f472b6 pink2.65:16.74:1
#a048a6 pink-violet midpoint5.29:13.38:1
#4c1d95 violet (bottom)10.95:11.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:

ScrimPeach becomesWhite text on it
30% black#b2916f2.93:1
40% black#997c5f3.89:1
50% black#8068505.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.

No scrim: 1.43:1 on the peach stop

Ship faster

White text at the top of the gradient.

50% black scrim over everything: 5.23:1 or better

Ship faster

White text at the top of the gradient.

50% scrim behind the text only

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:

OptionWhat it addsCost
CSS gradientNothing to downloadShapes are lines, ellipses and cones; animating them repaints
Image (AVIF, WebP, PNG)Any texture, including grainBytes, and one fixed resolution
Video loop (MP4)Any motionAbout 1 MB for a clean 10-second 1080p clip; grain makes it far bigger
Live WebGL or WebGPU canvasMotion at any size, colours from CSS variablesAbout 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.json
components/hero.tsx
import { 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.