Design tools draw mesh gradients by bending a grid of colored points. CSS has nothing like that. What the web calls a mesh gradient is several radial gradients stacked in one background, each one a patch of color that fades to transparent, with a solid color underneath. Because the patches overlap and blend, the result reads as one soft, uneven field of color.

Whether a mesh suits a page at all is a separate question, covered in Gradient, mesh, or glass? This article is about building one.

The basic structure

Here is a four-color mesh. Each radial-gradient() places a color at a point and fades it to transparent halfway to its edge. The last item in the list is a plain color, which fills everything the gradients do not cover.

css
.mesh {
  background:
    radial-gradient(at 18% 20%, #47e4e0 0px, transparent 50%),
    radial-gradient(at 80% 16%, #5f81e4 0px, transparent 50%),
    radial-gradient(at 74% 82%, #f67ff5 0px, transparent 50%),
    radial-gradient(at 22% 78%, #f6f6d9 0px, transparent 50%),
    #3b3486;
}

Layer order

Background layers paint in reverse order: the first gradient in the list is on top, and each later one sits beneath it. Where two patches overlap, the earlier one covers the later one in proportion to its opacity at that point. Swapping two layers changes which color dominates the overlap, so if one color looks muddy or hidden, try moving it up the list before changing its position.

In the background shorthand, a color is only allowed in the final layer. Writing it anywhere else makes the whole declaration invalid and the element shows no background at all. If you prefer separate properties, put the gradients in background-image and the color in background-color; the result is the same.

css
/* Equivalent longhand form */
.mesh {
  background-color: #3b3486;
  background-image:
    radial-gradient(at 18% 20%, #47e4e0 0px, transparent 50%),
    radial-gradient(at 80% 16%, #5f81e4 0px, transparent 50%),
    radial-gradient(at 74% 82%, #f67ff5 0px, transparent 50%),
    radial-gradient(at 22% 78%, #f6f6d9 0px, transparent 50%);
}

Positions and the size of each patch

at 18% 20% sets the center of the gradient as a position inside the element. When you give only a position, radial-gradient() uses its defaults: an ellipse sized to farthest-corner. That default has two effects that are easy to miss.

  • The patch size depends on the position. The gradient's 100% is the distance to the farthest corner of the box, so a point near a corner gets a bigger patch than a point in the middle. A stop at 50% therefore covers more area for colors near the edges.
  • The patch shape follows the element's aspect ratio. On a wide hero, every ellipse is wide; on a tall mobile screen, the same CSS produces tall ellipses. The mesh will look different at different sizes even though nothing about it changed.

Both boxes above use exactly the same declaration. If you need patches that keep their size and shape regardless of the box, give each gradient an explicit size: a circle with a length, or an ellipse with two lengths.

css
.mesh-fixed {
  background:
    radial-gradient(circle 360px at 18% 20%, #47e4e0, transparent),
    radial-gradient(520px 300px at 80% 16%, #5f81e4, transparent),
    #3b3486;
}

Fixed sizes behave better across breakpoints, but on small screens a 520px patch may cover the whole element. A common compromise is to keep percentages and adjust positions in a media query for narrow screens.

Transparent stops and soft falloff

The second color stop decides how far each patch spreads. #47e4e0 0px, transparent 50% reaches full transparency halfway to the farthest corner. Raise it to 70% for broad, overlapping color; lower it to 35% for distinct spots with more base color between them.

transparent is defined as transparent black. Current browsers interpolate gradients with premultiplied alpha, so fading a color to transparent does not pass through a gray tint. If you need to support an engine that shows a dark fringe, fade to the same color at zero alpha instead, such as rgb(71 228 224 / 0). It produces the same result everywhere.

A two-stop fade drops off linearly, which can make the center of a patch look like a hard dot. Two ways to soften it:

css
/* A color hint moves the midpoint of the transition */
radial-gradient(at 18% 20%, #47e4e0, 15%, transparent 55%)

/* Extra stops shape the falloff by hand */
radial-gradient(
  at 18% 20%,
  #47e4e0 0%,
  rgb(71 228 224 / 0.6) 20%,
  rgb(71 228 224 / 0.2) 38%,
  rgb(71 228 224 / 0) 55%
)

The hint, a lone percentage between two stops, sets where the color is halfway between them. Putting it close to the first stop makes the patch fade quickly at first and then trail off, which looks softer than a straight line.

Choosing the base color

The base color shows in every gap between patches, so it sets the overall tone more than any single layer. A dark base with lighter patches gives a glowing look; a pale base with saturated patches looks like watercolor. A base taken from the same hue family as the patches keeps the gaps from looking like holes.

If text sits on the mesh, check contrast against both the lightest patch and the base, at the sizes where the text actually lands. Because patches move with the aspect ratio, a heading that is readable on desktop can end up over a pale patch on mobile.

Performance and banding

A static mesh is cheap. The browser rasterizes the gradients when it paints the element, and does not need to repaint unless the element changes size or something on top of it changes. Costs show up in three situations:

  • Animating gradient values or background-position. Each frame repaints every layer across the full element. On a full-screen hero with eight layers, that is a lot of pixels per frame.
  • Adding filter: blur() to make it softer. The gradients are already soft; a large blur adds a lot of work for little visible change.
  • background-attachment: fixed on a large element, which can force repaints while scrolling in some browsers.

Dark, low-contrast meshes can show banding: visible steps between shades, because 8-bit color does not have enough levels across a large, subtle area. A faint noise texture over the mesh breaks up the steps. Use a small tiled PNG or an SVG feTurbulence filter at low opacity, on a pseudo-element so it does not affect the content.

css
.mesh {
  position: relative;
  isolation: isolate;
}

.mesh::after {
  content: "";
  position: absolute;
  inset: 0;
  z-index: -1;
  background: url("/textures/noise.png");
  opacity: 0.06;
  pointer-events: none;
}

CSS or PNG

The CSS version is a few hundred bytes, stays sharp at any size, and can be edited later. Its weakness is the one shown above: it reflows with the element, so the composition changes with the aspect ratio.

A PNG keeps one exact composition. Use it where CSS is not available or not reliable: Open Graph and social images, email, slides, video, design files. For the web, a PNG has real costs: a full-width image is far larger than the CSS, it can soften when scaled up, and it crops rather than reflows on a different shape. For a page background, CSS is usually the better choice; export an image when the mesh has to leave the browser.

Animating a mesh without repainting everything

The cheapest way to make a mesh feel alive is to leave the gradients alone and move the layer that holds them. Put the mesh on an oversized pseudo-element and animate its transform. Transforms can run on the compositor, so the gradients are painted once.

css
.hero {
  position: relative;
  overflow: hidden;
  isolation: isolate;
}

.hero::before {
  content: "";
  position: absolute;
  inset: -20%;
  z-index: -1;
  background:
    radial-gradient(at 18% 20%, #47e4e0 0px, transparent 50%),
    radial-gradient(at 74% 82%, #f67ff5 0px, transparent 50%),
    #3b3486;
  animation: mesh-drift 24s ease-in-out infinite alternate;
}

@keyframes mesh-drift {
  to {
    transform: translate(4%, -3%) rotate(6deg);
  }
}

@media (prefers-reduced-motion: reduce) {
  .hero::before {
    animation: none;
  }
}

To move individual color points, register their positions with @property so the browser knows they are percentages and can interpolate them. Without registration, custom properties change in a single jump. This repaints the gradient every frame, so keep it to a small element or a few layers.

css
@property --mesh-x {
  syntax: "<percentage>";
  inherits: false;
  initial-value: 18%;
}

.badge-mesh {
  background:
    radial-gradient(at var(--mesh-x) 20%, #47e4e0 0px, transparent 50%),
    radial-gradient(at 74% 82%, #f67ff5 0px, transparent 50%),
    #3b3486;
  animation: move-point 12s ease-in-out infinite alternate;
}

@keyframes move-point {
  to {
    --mesh-x: 62%;
  }
}

Keep the motion slow and small. A background that drifts over 20 seconds or more reads as atmosphere; one that visibly travels pulls attention from the content. Turn it off under prefers-reduced-motion, and if it loops for more than five seconds alongside other content, give people a way to pause it.

The keyframe details used here, including fill modes and timing functions, are covered in Writing CSS keyframe animations.

Making one with the GradientDeck mesh generator

The mesh generator gives you a preview with draggable color points, between two and eight of them, plus a base color. Each point has its own color picker, shuffle and remove buttons. Shuffle mesh (or the G key) picks new colors from the palette collection, new positions, and sets the base to the first color.

Copy CSS returns a single background declaration in the format shown at the top of this article: one radial-gradient() per point, each fading to transparent at 50%, followed by the base color. Change those 50% values after pasting if you want broader or tighter patches. Download PNG redraws the same layers on a canvas at twice the preview's size and saves mesh-gradient.png.

Below the generator is a collection of ready-made meshes built from curated palettes. Those copy as two lines, background-color and background-image, and their layers use different fade distances (60%, 50%, 40% and so on), so later colors form smaller patches. Paste either format, then resize the browser to see how the mesh behaves at the shapes your layout actually uses.

References

MDN: radial-gradient()

MDN: Using multiple backgrounds

MDN: @property

W3C: CSS Images Module Level 3