Animating Conic Gradients in React: Keyframe Transitions for Dynamic Spinners
The Spinner That Ate Your Friday Afternoon
You've been there. It's 4:47 PM. Your design system needs a loading state for the new dashboard widget, and the designer handed you a Figma frame showing a gorgeous conic gradient spinner—a sleek, sweeping arc of color that rotates with buttery smoothness. "Should be quick," you tell yourself. Forty-five minutes later, you're staring at a static gradient that refuses to budge, your transition: background-image 0.3s declaration sitting there like a suggestion box that nobody opens.
Here's the thing: conic gradients are stubborn creatures. They don't animate the way transform or opacity do. But once you understand why they fight back—and the specific keyframe techniques that tame them—you'll never lose another Friday to a frozen spinner again.
Why Your Conic Gradient Laughs at Your Transitions
The Core Problem: Gradients Aren't Interpolatable
CSS transitions work by interpolating between two values. transform: rotate(0deg) to rotate(360deg)? Easy—the browser calculates every intermediate step. But background: conic-gradient(from 0deg, #3498db, #9b59b6, #3498db) to conic-gradient(from 360deg, ...)? The browser shrugs. Background images, including gradients, are not part of the animatable CSS property set in any practical sense. Your transition declaration is ignored silently, and there's no error to debug.
The Mistake Almost Everyone Makes First
Most React developers reach for the obvious solution: a JavaScript-driven angle update. Something like this:
const [angle, setAngle] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setAngle(a => (a + 2) % 360);
}, 16); // ~60fps
return () => clearInterval(id);
}, []);
// Then in render:
<div style={{ background: `conic-gradient(from ${angle}deg, ...)` }} />
This "works" in the sense that the gradient moves. But you've just created a React re-render every 16 milliseconds. On a single spinner, you might not notice. On a dashboard with twelve loading widgets? You're forcing 720 re-renders per second across your component tree. Your CPU fan knows. Your users' battery drains know.
The Real Fix: CSS Custom Properties and @property
Step 1: Register an Animatable Custom Property
The breakthrough comes from the CSS @property rule, which lets you register a custom property with a specific syntax type. When you tell the browser that --angle is an <angle>, it suddenly knows how to interpolate it—making it fair game for keyframe animations.
@property --angle {
syntax: '<angle>';
initial-value: 0deg;
inherits: false;
}
This registration is the unlock. Without it, --angle is treated as a string, and strings don't interpolate. With it, the browser treats --angle like any other animatable numeric value.
Step 2: Define Keyframes That Target the Custom Property
Now your keyframes animate the property itself, not the background shorthand:
@keyframes spin {
to {
--angle: 360deg;
}
}
.spinner {
background: conic-gradient(from var(--angle), #3498db, #9b59b6, #3498db);
animation: spin 1s linear infinite;
}
No JavaScript interval. No React re-renders. The animation runs entirely on the compositor thread, off the main thread entirely. That's the difference between a spinner that jitters under load and one that stays smooth even when your data layer is doing heavy lifting.
Browser Support Reality Check
@property is supported in Chrome 85+, Edge 85+, Safari 16.4+, and Firefox 128+. For older browsers, the spinner simply won't animate—but it will still render as a static gradient, which is an acceptable graceful degradation for a loading indicator. You're not breaking functionality; you're withholding a flourish.
Building the React Component Without Shooting Yourself in the Foot
The Component Structure
Here's where most pitfalls guides would give you a generic snippet. Instead, let's build this for a real scenario: a design system component that needs to accept a size prop, a color palette, and respect reduced-motion preferences.
type SpinnerProps = {
size?: number;
colors?: [string, string, string];
};
function ConicSpinner({ size = 48, colors = ['#3498db', '#9b59b6', '#3498db'] }: SpinnerProps) {
return (
<div
className="conic-spinner"
style={
{
width: size,
height: size,
'--c1': colors[0],
'--c2': colors[1],
'--c3': colors[2],
} as React.CSSProperties
}
/>
);
}
The CSS Layer (Where the Magic Lives)
@property --angle {
syntax: '<angle>';
initial-value: 0deg;
inherits: false;
}
.conic-spinner {
--angle: 0deg;
border-radius: 50%;
background: conic-gradient(from var(--angle), var(--c1), var(--c2), var(--c3));
animation: spin 1s linear infinite;
mask: radial-gradient(circle, transparent 55%, black 56%);
-webkit-mask: radial-gradient(circle, transparent 55%, black 56%);
}
@keyframes spin {
to { --angle: 360deg; }
}
@media (prefers-reduced-motion: reduce) {
.conic-spinner {
animation: none;
}
}
Three things to notice here. First, the mask property punches a hole in the center, turning a full disc into a ring—because nobody wants a spinning pie chart as a loader. Second, the --angle custom property is declared on the element itself as a fallback initial value, which matters for browsers that don't support @property but do support custom properties in gradients. Third, the reduced-motion query kills the animation entirely; a static gradient ring is a perfectly acceptable loading indicator for users who've asked their OS to calm things down.
Performance Pitfalls: What Still Goes Wrong
The Mask Repaint Trap
Here's a subtle one. If you apply mask to an element that also has a transform animation, some browsers will repaint the mask on every frame instead of compositing it. The fix: separate concerns. Put the spinning conic gradient on an inner element and the mask on an outer wrapper. This keeps the compositor happy and your frame budget intact.
A conic gradient spinner animating via @property runs at roughly 0.1ms per frame on a mid-range laptop. The same spinner driven by JavaScript setInterval at 60fps costs about 2.4ms per frame just in React reconciliation overhead—before you factor in the actual paint. On a page with ten spinners, that's the difference between 1ms and 24ms of main-thread work per frame. The 16.67ms budget for 60fps doesn't have room for the latter.
The "I Forgot Will-Change" Problem
If your spinner lives inside a scrolling container or a frequently-updating parent, add will-change: --angle to hint the browser to promote the element to its own compositor layer. But don't leave it there permanently—will-change is a memory cost, and spinners typically unmount when loading finishes. Apply it when the component mounts, remove it when it unmounts, or just accept the small cost for short-lived loading states.
The Color-Stops-Too-Many Problem
A conic gradient with twelve color stops looks beautiful in Figma and performs like a dog in production. Each additional stop increases the paint complexity. For spinners, three to five stops is the sweet spot. More than that and you're asking the compositor to do work it doesn't need to do for a component nobody stares at for more than a few seconds.
Wrapping Up: What to Remember When the Spinner Strikes Back
The next time a designer drops a conic gradient spinner into your sprint, you have a clear path. Register the custom property with @property, animate it with keyframes, mask it into a ring, and let the compositor thread do the heavy lifting. Skip the JavaScript interval approach entirely—it's a performance trap disguised as a quick fix. Respect reduced motion. Keep your color stops lean. Separate your mask from your animated element if you're seeing repaint jank.
And if you're building this into a shared component library, document the @property dependency clearly. The next developer who picks up your spinner component shouldn't have to spend their Friday afternoon rediscovering why the gradient won't move. That's the whole point of learning from pitfalls—once you've fallen in, you owe it to the next person to leave a rope.