Issue №126 Summer 2026A journal of dev tools, libraries & small ideasUpdated weekly
frontend · 4 min read
frontendJune 4, 2026 · 4 min

gradient-border-react

Animated gradient border that flows around any element

gradient-border-react — live demo screenshot
01
frontend

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-react
import { 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

About the author
Er An Khoo