Animating Gradients with Registered Properties

Part of Keyframe Architecture & State Mapping in Core CSS Animation Fundamentals.

The problem

A call-to-action button has a gradient border that should rotate slowly. A card highlight should shift from violet to orange on hover. A hero background should drift between two gradient angles. Every attempt to transition background-image from one gradient to another produces an instant swap — the transition is declared, and nothing interpolates.

The workaround people reach for, animating background-position over an oversized gradient, works for some cases but not for angles or colour changes, and it repaints anyway.

Root cause analysis: images do not interpolate, typed values do

background-image: linear-gradient(...) is an <image> value. CSS has no interpolation defined between two arbitrary images, so a change between gradients is discrete. That is true even when the two gradients differ only by a single angle.

Registered properties are typed. A custom property registered with @property and syntax: "<angle>" is an angle, which interpolates. If the gradient reads that property — linear-gradient(var(--sweep), …) — then animating the property changes the gradient every frame, and the image is rebuilt from the interpolated value. The same works for <color> stops and <percentage> positions.

Unregistered properties do not help. An ordinary custom property is a token string. Animating it flips at 50%, so the gradient still swaps.

Every frame repaints. A gradient generated from an animated value has to be re-rasterised each frame on the main thread. For a 44px button border that is trivial. For a full-viewport hero it is a continuous paint of the entire viewport, which is the scenario described in gradient, shadow and blur repaint costs. Registered properties also cost a style recalculation per frame for each element.

Transitioning background-image against transitioning a registered angleSame visual intent; only the typed approach interpolates.Transitioning background-image against transitioning a registered anglebackground-image transitionGradient is an image valueNo interpolation definedSwaps instantlyNo animation@property --sweep: <angle>Gradient reads var(--sweep)Angle interpolates per frameGradient repainted each frameSmooth, paint cost
Same visual intent; only the typed approach interpolates.

Step-by-step resolution

Animating a gradient borderRegister, reference, animate, then measure.Animating a gradient border1Register --sweep as <angle> with initial value 0deg.A typed, interpolable angle2Use conic-gradient(from var(--sweep), ...) as the border image.The gradient follows the property3Animate --sweep from 0deg to 360deg with linear timing.Continuous rotation4Register colour stops as <color> if they change on hover.Colours cross-fade5Record a trace and read Paint time for the element.Confirms the cost is acceptable6Stop the rotation under reduced motion.
Register, reference, animate, then measure.

Production code pattern

@property --sweep {
  syntax: "<angle>";
  inherits: false;
  initial-value: 0deg;
}
@property --glow-a {
  syntax: "<color>";
  inherits: false;
  initial-value: #7c3aed;
}
@property --glow-b {
  syntax: "<color>";
  inherits: false;
  initial-value: #f97316;
}

/* Rotating gradient border on a small button: cheap enough to paint per frame. */
.cta {
  border: 2px solid transparent;
  border-radius: 999px;
  background:
    linear-gradient(var(--color-surface), var(--color-surface)) padding-box,
    conic-gradient(from var(--sweep), var(--glow-a), var(--glow-b), var(--glow-a)) border-box;
  animation: sweep 6s linear infinite;
  transition: --glow-a 300ms ease-out, --glow-b 300ms ease-out;
}
.cta:hover {
  --glow-a: #c2410c;
  --glow-b: #7c3aed;
}
@keyframes sweep { to { --sweep: 360deg; } }

/* Large hero: do NOT animate the gradient itself; cross-fade two static layers. */
.hero {
  position: relative;
  isolation: isolate;
  background: linear-gradient(135deg, #312e81, #7c3aed);
}
.hero::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: -1;
  background: linear-gradient(200deg, #7c3aed, #c2410c);
  animation: hero-shift 12s ease-in-out infinite alternate;
}
@keyframes hero-shift { from { opacity: 0; } to { opacity: 1; } }

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

Rendering Impact: paint per frame for the button’s conic gradient, composite for the hero. The button’s border area is a few thousand pixels, so repainting it every frame is affordable; the hero uses the opacity cross-fade from animating colours without repaint because repainting a full-width gradient every frame is not.

inherits: false on the registered properties matters for performance. An inheriting animated property invalidates the style of every descendant each frame; the button’s label and icon would be restyled sixty times a second for a border effect.

Paint per frame for gradient animationsMid-range phone, DPR 2.6.Paint per frame for gradient animationsbudget 16.7 msConic border on a 180px button0.6 msRegistered-angle gradient, 400px card3.2 msRegistered-angle gradient, full-width hero12.9 msTwo static gradients cross-faded0.1 ms
Mid-range phone, DPR 2.6.

Verification checklist

Constraints and trade-offs

  • Without @property support, the gradient swaps at the animation midpoint rather than interpolating.
  • Colour interpolation of registered <color> values happens in the default colour space, which can pass through greyish midpoints for complementary colours.
  • Each animated gradient element costs a style recalculation and a paint per frame.
  • A cross-fade of two gradients looks slightly different from true interpolation of stops or angles.
  • Infinite gradient animations keep painting while off-screen unless paused.

Choosing an interpolation strategy for colour stops

Registered <color> properties interpolate in the default colour space, which for two saturated complementary colours — violet and orange, say — passes through a muddy midpoint. There are three ways to shape that.

Add an intermediate stop. Keyframes with a middle colour, such as a warm pink between violet and orange, steer the path through a vivid hue. This is the most predictable approach and works everywhere registered properties do.

Use a colour-space-aware gradient. Gradients accept an interpolation hint, such as linear-gradient(in oklch, ...), which controls how colours blend across the gradient. It does not change how the animated stop values themselves interpolate over time, but it makes each rendered frame look more vivid.

Cross-fade instead. Two static gradients blended by opacity avoid the question entirely, with the midpoint being a simple alpha mix. For large backgrounds this is also the cheaper option, so it often wins on both counts.

Whatever the approach, check the midpoint frame in the Animations drawer rather than only the start and end. Gradient animations that look right at rest can look dull for the half second users actually watch.

Frequently asked questions

Why won’t my gradient transition?

Gradients are images, and CSS cannot interpolate between images. Register the changing angle, colour or stop as a typed custom property and animate that instead.

Is animating a gradient expensive?

It repaints the gradient every frame. On small elements this is negligible; on large areas it can consume most of the frame budget.

Can I animate gradient stop positions?

Yes. Register the position as a or and use it in the gradient’s stop list.

What is the cheapest way to change a large gradient?

Stack a second gradient on a pseudo-element and animate its opacity, which the compositor can handle without repainting.