How to make a seamless looping gradient animation
Why gradient loops jump, and how to build one that doesn't in CSS or in a shader.
Updated
A gradient animation loops seamlessly when its last frame leads straight into its first: the same picture, moving the same way. In CSS, that means animating something periodic by exactly one period, with linear timing: slide a repeating gradient by one tile width, or turn an angle by 360deg. In a shader, it means feeding time into the noise as a point that travels once around a circle, so it ends exactly where it started.
The gradient below is drawn the second way. It repeats on a fixed cycle, and you can't see where.
Ship faster
import { MeshGradient } from "@/components/mesh-gradient";
export function MeshGradientDemo() {
return (
<MeshGradient className="flex h-[360px] items-end p-8">
<h2 className="text-3xl font-semibold tracking-tight text-white">
Ship faster
</h2>
</MeshGradient>
);
}
Why loops jump
A visible seam has one of three causes:
- The last frame doesn't match the first. A gradient slides 90% of the way instead of 100%, or a colour stop ends on a different colour than it began with.
- The picture matches but the motion doesn't. With
ease-in-out, the colours slow to a stop at the end of each loop and start again. Withalternate, they reverse. Both loop without a jump, but your eye catches the pause or the turn. - A video repeats a frame. If an exported clip contains both the frame at 0 seconds and the frame at exactly one loop length, the player shows the same image twice in a row at every repeat, which reads as a hitch.
Each technique below avoids all three.
In CSS: move one full period
The simplest seamless loop slides a repeating gradient sideways by exactly one background width:
.hero {
background: linear-gradient(to right, #6366f1, #ec4899, #6366f1);
background-size: 200% 100%;
animation: gradient-pan 12s linear infinite;
}
@keyframes gradient-pan {
from {
background-position: 0% 50%;
}
to {
background-position: 200% 50%;
}
}
@media (prefers-reduced-motion: reduce) {
.hero {
animation: none;
}
}The background is twice the element's width, so moving it from 0% to 200% shifts it by one full copy. The gradient starts and ends on the same colour, so the copies join without an edge. Keep it horizontal: a diagonal gradient doesn't tile cleanly. Animated gradient backgrounds in Tailwind CSS has the Tailwind version and a softer blob variant.
For a rotating gradient, register the angle with @property so the browser can animate it, then turn it by one full turn:
@property --angle {
syntax: "<angle>";
inherits: false;
initial-value: 0deg;
}
.hero {
background: conic-gradient(
from var(--angle) at 30% 40%,
#6366f1,
#ec4899,
#22d3ee,
#6366f1
);
animation: spin-gradient 10s linear infinite;
}
@keyframes spin-gradient {
to {
--angle: 360deg;
}
}
@media (prefers-reduced-motion: reduce) {
.hero {
animation: none;
}
}0deg and 360deg draw the same gradient, and the conic gradient ends on the colour it started with, so there is no seam around the circle or across the loop. Without @property, the browser treats --angle as a string and the animation jumps between the two values instead of turning. We rendered both CSS examples at the start and end of their loops in Chrome, and the frames are pixel-identical.
CSS loops share one limit: they move a fixed picture. The shapes never change, they only slide or spin.
In a shader: walk time around a circle
Soft mesh gradients usually come from noise: a smooth random field that you sample at each pixel and map to colours. Animating it is where loops go wrong.
- Add time as a third noise coordinate,
noise(x, y, t), and the pattern changes smoothly forever but never comes back. There is no loop. - Use
noise(x, y, sin(t))and it comes back, but by retracing its path: the motion plays forward, then in reverse.
The fix is to give time two coordinates and move it around a circle. Sample 4D noise at (x, y, r·cos θ, r·sin θ), where θ goes from 0 to 2π once per loop. Time travels through the noise field on a closed path, so the pattern keeps changing in one direction and arrives back at its first frame exactly when the loop ends.
In GLSL, with any 4D simplex noise (for example webgl-noise, MIT licensed):
uniform float u_time; // seconds since start
uniform float u_loop; // loop length in seconds
uniform vec2 u_resolution; // canvas size in pixels
out vec4 outColor;
void main() {
vec2 p = gl_FragCoord.xy / u_resolution.y * 1.5;
// Walk once around a circle per loop. Radius sets how far the pattern travels.
float phase = 6.2831853 * fract(u_time / u_loop);
vec2 circle = 0.6 * vec2(cos(phase), sin(phase));
float n = snoise(vec4(p, circle)) * 0.5 + 0.5;
outColor = vec4(mix(vec3(0.31, 0.27, 0.9), vec3(0.93, 0.29, 0.6), n), 1.0);
}We ran this in WebGL2 with a 10-second loop. The frames at 0, 10 and 20 seconds are identical to the pixel, and frames 0.02 seconds apart across the seam differ by at most 2 of 255 in any channel, the same as any two neighbouring frames.
Two numbers control the result:
- The radius is how far the loop travels through the noise. A small circle gives gentle breathing, a large one gives big changes in shape. It is the "how much it moves" setting.
- The loop length is how long one lap takes. To make the motion faster without changing its character, shorten the loop, or go around the circle a whole number of times per loop (multiply the phase by 2, 3, ...). A fractional number of laps ends partway round the circle and jumps.
This is how the gradients.sh engine animates every kind. Its shaders only see time as a point on a circle, so the motion never jumps. A component left at its default settings makes one lap per loopSeconds. A layered gradient gives each noise layer its own offset circle, so the layers move independently but all close the loop together. In the editor, the Speed slider changes how fast the point goes around, so at Speed 0.5 one lap takes twice as long. When the editor exports a video, it rounds Speed to a whole number of laps so the file still holds a closed loop.
Make the grain loop too
Film grain is random per frame, which seems to rule out a perfect loop. It doesn't, if the randomness is seeded. Seed the grain from the frame number modulo the number of frames in one loop: at 60 fps and a 10-second loop, frame 600 uses the same grain as frame 0. The grain still flickers like film, and the loop stays exact. The gradients.sh engine does this both on the page and in exports.
Export the loop as a video
To render a loop to MP4 or WebM, render round(loopSeconds × fps) frames at i / fps and leave out the frame at exactly loopSeconds, because that frame is frame 0 again:
const loopSeconds = 10;
const fps = 30;
const count = Math.round(loopSeconds * fps); // 300 frames
const times = Array.from({ length: count }, (_, i) => i / fps);
console.log(count, times[0], times.at(-1)); // 300 0 9.966666666666667The last frame is one frame short of the loop length, and the player's repeat supplies the rest. Choose a loop length that is a whole number of frames at your frame rate (10 seconds at 24, 30 or 60 fps all are), or the rounding stretches the timing slightly.
To skip the code, the gradients.sh editor exports any gradient as an MP4 that holds exactly one loop. On the free plan, MP4s stop at 10 seconds, so set Loop to 10 seconds or less first. Export an animated gradient as a seamless MP4 loop covers the settings and how to use the file on a site, in slides or in an edit.
Use it in React
The free gradients.sh components handle the loop, the grain and the export rules for you. Install one with the shadcn CLI:
pnpm dlx shadcn@latest add https://gradients.sh/r/mesh-gradient.jsonimport { MeshGradient } from "@/components/mesh-gradient";
<MeshGradient className="h-[360px]" loopSeconds={12} speed={1}>
{children}
</MeshGradient>;loopSeconds (4 to 30) sets how often the animation repeats, and speed (0 to 2) how far it travels per loop. Visitors who prefer reduced motion see one still frame. The Mesh Gradient docs list every prop.
Related
- Animated gradient backgrounds in Tailwind CSS compares the CSS approaches with a live gradient.
- Export an animated gradient as a seamless MP4 loop turns a loop into a video file.
- Stripe-style animated mesh gradient builds a hero from the same looping mesh.
- Background video vs animated gradient compares a looping video with a live gradient on file size and cost.
- How to add film grain to a gradient covers grain in CSS and in a shader.
- Ordered dithering in a shader explains why a Bayer pattern stays fixed while the loop moves under it.