Building an animated gradient border for React
Every few months I'd reach for the same effect: a card or a button with a gradient border that slowly rotates, the colors chasing each other around the edge. It's a small thing, but it makes an interface feel alive. And every time, I'd paste the same fiddly CSS, fight with pseudo-elements, forget how the mask trick worked, and eventually get it close enough.
So I packaged it properly. gradient-border-react is a tiny component — plus a hook — that wraps
any element in an animated gradient border. No animation library, no canvas, no stylesheet to
import. This is the write-up of the two parts that were actually interesting to get right.
What it does
The API is deliberately small. You wrap your content and it animates:
import { GradientBorder, presets } from 'gradient-border-react';
<GradientBorder colors={presets.aurora} radius={16} borderWidth={2} glow>
<div style={{ padding: 24 }}>Anything you like</div>
</GradientBorder>;Props cover the obvious knobs — colors, borderWidth, radius, duration, direction,
paused, background, and a soft glow. There are eight presets, and if you'd rather paint the
look onto your own element, the useGradientBorder() hook hands you the raw style objects.
The CSS: a border that's only a border
The naive approach — put a gradient background on a box — fills the whole box, not just the
edge. The classic fix is a mask:
background: conic-gradient(/* … */);
padding: 2px; /* = the border thickness */
mask:
linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
mask-composite: exclude; /* punch the center out */Two stacked white fills, one clipped to the content box, composited with exclude, leave only the
padding-width ring painted. Your content sits inside, untouched. That's the border.
The harder part is making it move. You can't transition a conic-gradient's angle directly, and
plain CSS custom properties animate in discrete jumps — no smooth interpolation. The unlock is
@property:
@property --gb-angle {
syntax: '<angle>';
initial-value: 0deg;
inherits: false;
}
@keyframes gb-spin {
to {
--gb-angle: 360deg;
}
}Once the browser knows --gb-angle is an <angle>, it will interpolate it, and
conic-gradient(from var(--gb-angle), …) rotates buttery-smooth on the compositor — no JavaScript
in the hot path. The library injects that one block of keyframes a single time, the first time any
border mounts, and reuses it forever after.
The React part: surviving StrictMode
This is where a lot of "works on my machine" component libraries quietly break. React 19's
StrictMode simulates an unmount/remount in development: it runs your effect cleanup, then your
setup again. If you set up a browser API (an observer, a listener, a style injection) in a ref
callback and tear it down in a separate useEffect, the cleanup fires but the ref callback
doesn't re-run — and your setup is gone for good. Tests in jsdom won't catch it.
The fix is boring and reliable: keep the entire lifecycle inside one effect, and make setup idempotent.
useEffect(() => {
injectKeyframes(document); // bails early if the <style> already exists
}, []);injectKeyframes checks for its <style> by id before doing anything, so StrictMode's double
invoke — or a thousand mounted borders — only ever produce one keyframe block.
The same single-effect discipline drives the reduced-motion support. A usePrefersReducedMotion
hook subscribes to (prefers-reduced-motion: reduce) (with the legacy Safari addListener
fallback), all inside one effect, and when it's true the component simply omits the animation
property. The ring renders as a static gradient instead of spinning. Nothing to configure — it just
respects the setting.
A nice side effect of pushing all the real work into a pure buildStyles() function is that it's
trivial to test. The style math has no DOM dependency, so it hit 100% coverage without any
elaborate mocking, and the component is just a thin shell around it.
Try it
pnpm add gradient-border-react
# or: npm install gradient-border-reactimport { GradientBorder, presets } from 'gradient-border-react';
export function Avatar() {
return (
<GradientBorder colors={presets.candy} radius={999} borderWidth={3} glow>
<img src="/me.jpg" alt="" width={72} height={72} style={{ borderRadius: 999 }} />
</GradientBorder>
);
}It's MIT-licensed, ships ESM + CJS + types, and works with React 18 and 19. There's a live demo that shows every prop at once if you want to see the variations side by side.
What's next
A few ideas I'm sitting on: a speed-on-hover interaction, gradient conic offset control so the
seam can be hidden behind a specific corner, and a SolidJS port since the core is just CSS. If you
end up using it — or it breaks on some browser I didn't test — I'd love to hear about it on the
issue tracker.
End of essay



