TL;DR: The first thing I tried when building a gradient border was exactly what you probably tried:
border-image: linear-gradient(). It looks clean in the CSS, the gradient shows up, and you feel like you're 30 seconds from done.
📖 Reading time: ~29 min
What's in this article
- The Problem: Border-Image Kills Border-Radius
- The Background-Clip Trick (Works Today, Everywhere)
- Adding the Animation with @keyframes and conic-gradient
- The @property Way — Animating Gradient Color Stops Directly
- Making It Reusable with CSS Custom Properties
- Gotchas I Hit in Production
- When to Use Which Technique
- A Real Example: Animated Border on a Pricing Card
The Problem: Border-Image Kills Border-Radius
The first thing I tried when building a gradient border was exactly what you probably tried: border-image: linear-gradient(). It looks clean in the CSS, the gradient shows up, and you feel like you're 30 seconds from done. Then you add border-radius: 12px and the corners snap back to a hard right angle. No warning, no fallback — just silently ignored. That's not a browser bug; it's a spec constraint. border-image and border-radius are fundamentally incompatible because the spec says border-image replaces the border style entirely, and the UA simply doesn't apply radius clipping to it.
The classic workaround you'll find on Stack Overflow threads from 2019 looks like this:
/* Looks promising, ruins your border-radius */
.card {
border: 3px solid transparent;
border-image: linear-gradient(135deg, #f06, #4a90e2) 1;
border-radius: 12px; /* This does nothing. Absolutely nothing. */
}
That border-image-slice: 1 shorthand is everywhere in tutorials. It works perfectly for rectangular elements. The moment your design requires rounded corners — cards, buttons, input fields, anything modern — you're stuck. Most tutorials at this point pivot to a pseudo-element wrapper: a ::before with z-index: -1, position: absolute, a slightly larger size, and the gradient applied as a background. That actually works, but it requires the parent to be position: relative, it breaks if you have overflow: hidden on the parent, and animating it means animating background-position on a pseudo-element, which has its own quirks. It's a hack piled on a hack.
What I actually wanted was something self-contained: one element, no wrappers, no JavaScript toggling classes, no position: absolute pseudo-elements leaking outside the layout. The approach that delivers this uses @property for animatable custom properties, background-clip: text technique adapted for borders, or — the cleanest path — combining background-origin and padding with a gradient on the element itself using background-clip: padding-box layered with a second background on the border box. That sounds complicated but reduces to about 8 lines of CSS once you understand the mental model.
Before you go further, check your support targets. Chrome 111+ handles @property with inherits: false and Houdini-based animation reliably. Firefox 128+ finally shipped @property support — before that, Firefox would render the custom property but silently skip the transition animation, which was a brutal debugging session I don't want you to repeat. Safari 16.4+ covers the @property and conic-gradient features you'll need for the rotating border trick. If you're targeting older Safari or Firefox ESR before 128, the pseudo-element fallback is your only real option — there's no polyfill that makes @property animation work without JavaScript.
The Background-Clip Trick (Works Today, Everywhere)
The most reliable approach I've found doesn't touch border-image at all — that property kills border-radius and you'll spend an hour figuring out why your rounded corners vanished. The pseudo-element trick is what actually works across Chrome, Firefox, Safari, and Edge without any asterisks.
The mental model is simple once you see it: your real element sits on top, and a ::before pseudo-element lives directly behind it. The pseudo-element is the gradient. The parent element has a solid background that covers most of the pseudo-element, leaving only a thin ring visible around the edges. That ring is your "border". No actual CSS border property is involved — it's pure layering.
Here's the full class you can drop in and use immediately:
.gradient-border {
position: relative; /* non-negotiable — without this the ::before escapes */
background: #1a1a2e; /* must be opaque, not transparent */
border-radius: 12px;
padding: 1.5rem;
z-index: 0; /* establishes a stacking context so ::before stays behind */
}
.gradient-border::before {
content: "";
position: absolute;
inset: -2px; /* controls border thickness — increase for a thicker border */
border-radius: inherit; /* this is the line that saves border-radius */
background: linear-gradient(
135deg,
#e052a0,
#f15c41,
#4facfe,
#00f2fe
);
z-index: -1; /* tucks behind the parent's background */
background-size: 300% 300%;
animation: gradient-spin 4s ease infinite;
}
@keyframes gradient-spin {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}
The reason border-radius: inherit on the pseudo-element is so critical: the pseudo-element is a sibling layer, not a child, so it doesn't automatically inherit the parent's corner clipping. Without that line, you get a rounded parent with a square gradient sticking out of the corners — looks broken immediately. The inset: -2px shorthand (equivalent to top: -2px; right: -2px; bottom: -2px; left: -2px) is what creates the visible ring. Change it to -4px for a chunkier border or -1px for something subtle.
The gotcha that bites almost everyone on first try: the parent's background must be a solid, opaque color. If you set background: transparent on the parent, the gradient from the pseudo-element bleeds through the entire card — you'll see the gradient fill, not a gradient border. If you're building on top of a page background that isn't white, match the parent's background color to the page, or use a background-color that matches the container it sits inside. This also means dark mode requires swapping that color — I handle it with a CSS custom property:
:root {
--card-bg: #ffffff;
}
[data-theme="dark"] {
--card-bg: #1a1a2e;
}
.gradient-border {
background: var(--card-bg);
}
One more thing: the z-index: 0 on the parent is doing real work here. It creates a new stacking context, which ensures z-index: -1 on the ::before places it behind the parent's background but not behind the entire page. Skip that z-index: 0 and the pseudo-element can end up behind the body background, making it completely invisible. That's the bug that usually takes 20 minutes to track down.
Adding the Animation with @keyframes and conic-gradient
Why conic-gradient and Not linear-gradient
The thing that caught me off guard early on was trying to animate a linear-gradient by rotating it — and hitting a wall. linear-gradient is not animatable. You can't tween between two gradient states because the browser treats them as discrete values, not continuous ones. conic-gradient sidesteps this entirely: the color sweep is defined by angle, and you're just rotating the entire pseudo-element from 0deg to 360deg. One CSS property, one @keyframes, done. That rotation is what creates the illusion of a spinning gradient band traveling around your border.
The Core Setup: Spinning ::before with conic-gradient
The technique stacks a positioned ::before pseudo-element behind the card, sized slightly larger than the parent using negative inset values. The conic-gradient lives there, and you spin it. The parent clips the overflow so only the border strip is visible. Here's the full working pattern:
.card {
position: relative;
border-radius: 12px;
padding: 2rem;
background: #1a1a2e; /* card background must be opaque */
overflow: hidden; /* clips the oversized ::before */
z-index: 0;
}
.card::before {
content: '';
position: absolute;
/* extend beyond the card so the gradient peeks through as a border */
inset: -3px;
border-radius: inherit; /* match the card's rounding */
background: conic-gradient(
from 0deg,
#ff6b6b,
#f7b731,
#26de81,
#45aaf2,
#a55eea,
#ff6b6b /* repeat first color so the loop is smooth */
);
z-index: -1;
animation: spin 3s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
The inset: -3px is your border width. Change it to -6px and you get a chunkier border. The from 0deg argument on conic-gradient sets the starting angle for the sweep — you don't need to touch it once the animation takes over. The duplicate #ff6b6b at the end is non-negotiable; without it you'll see a hard seam when the gradient loops.
The animation Shorthand and What Each Value Does
The one-liner animation: spin 3s linear infinite breaks down as: name (spin), duration (3s), timing function (linear), iteration count (infinite). The timing function matters more than it sounds — ease will make the spin visually stutter because it accelerates and decelerates, so the color band lunges forward then slows near the corners. linear gives you a constant angular velocity that reads as smooth. If you want a slower, more ambient effect, push the duration to 6s or 8s. Faster than 2s starts to look frantic unless you're building something explicitly loud.
Tuning the Glow Width and Color Stops
The "glow" thickness is the inset value. But if you want a softer bloom rather than a crisp line, add a filter: blur(6px) to the ::before and increase the inset to compensate — around -8px to -10px. The blur spreads the color outward past the card edge, giving you the neon-glow look without a single box-shadow. For tighter control over which colors appear as the dominant band, lean on hard stops in the gradient. A narrow hot spot surrounded by dark tones looks like a single beam orbiting the card:
background: conic-gradient(
from 0deg,
transparent 0deg,
transparent 70deg, /* most of the gradient is invisible */
#45aaf2 90deg, /* sharp blue beam */
#a55eea 130deg, /* bleeds into purple */
transparent 160deg,
transparent 360deg
);
Performance: Paint, Not Layout — But Watch the Count
Rotating a pseudo-element via transform: rotate() runs on the compositor thread in browsers that support it, which Chrome and Firefox do reliably since their respective 2021 compositor rework. You're not triggering layout recalculations on every frame. That said, each animated element does trigger a repaint of its own layer. One or two cards on screen: completely negligible. Fifteen card grid? Still probably fine. Twenty or more simultaneously animating? Add will-change: transform to the ::before so the browser promotes each pseudo-element to its own GPU layer upfront instead of promoting mid-scroll:
.card::before {
/* existing properties... */
will-change: transform; /* promotes to GPU layer; only use when you have 20+ */
}
Don't scatter will-change: transform everywhere as a default. Each promoted layer consumes GPU memory, and on mid-range mobile hardware — anything below a Snapdragon 8 Gen 1 tier — you'll burn through the available compositing budget before the page finishes loading. Profile first in DevTools Performance tab, look for dropped frames, then add it selectively. The animation will run without it; will-change is an optimization hint, not a requirement.
The @property Way — Animating Gradient Color Stops Directly
The thing that surprised me most about @property wasn't that it existed — it was realizing that without it, CSS literally cannot interpolate custom properties used inside gradient functions. The browser sees --angle: 360deg and thinks "string", not "angle". So when you try to animate it, you get a hard jump at the end of every loop instead of a smooth spin. @property fixes this by telling the browser what type a custom property holds, which unlocks actual interpolation.
Here's the registration you need at the top of your stylesheet:
@property --gradient-angle {
syntax: '<angle>';
inherits: false;
initial-value: 0deg;
}
Three lines. That's it. syntax: '<angle>' tells the browser this is a real angle value it can tween. inherits: false keeps it scoped so a parent's animation doesn't bleed into a child element's gradient. initial-value: 0deg gives the browser a starting point for interpolation — skip this and some browsers will refuse to animate it at all. Once that's registered, you can drive a conic-gradient directly with a keyframe that only touches --gradient-angle:
@keyframes spin-gradient {
to {
--gradient-angle: 360deg;
}
}
.card {
--gradient-angle: 0deg; /* initial state on the element */
border-radius: 12px;
padding: 3px; /* this IS the border thickness */
background: conic-gradient(
from var(--gradient-angle),
#6366f1,
#a855f7,
#ec4899,
#6366f1 /* repeat first stop so the loop is smooth */
);
animation: spin-gradient 4s linear infinite;
}
.card-inner {
background: #0f0f0f;
border-radius: 10px; /* 12px - 2px to stay inside */
padding: 1.5rem;
}
Compare this to the ::before rotation trick where you spin a pseudo-element with transform: rotate() and clip it under the real element. That approach works, but it forces you to deal with overflow: hidden, fixed dimensions, and the occasional paint glitch when the parent has border-radius. The @property approach animates the gradient itself — no fake layers, no clipping gymnastics. It's also dramatically easier to adjust: want the gradient to sweep the other direction? Flip to from 360deg to 0deg. Want it to pause? Drop in an animation delay. The whole thing is in one declaration.
The catch is real though. @property support was Chromium-only for a long time. Firefox finally shipped it in v128, which released in mid-2024. That sounds like good news, and it is — but "shipped in v128" means users on ESR (Firefox 115, which runs until mid-2025) won't have it. Check your analytics before treating this as baseline-safe. My personal threshold: if your audience skews developer or consumer Chrome-heavy, ship it. If you're building something for enterprise intranet where IT still pushes Firefox ESR, write the ::before fallback and layer @property on top with a @supports check:
/* Fallback: pseudo-element rotation approach for older Firefox */
.card {
position: relative;
}
/* @property enhancement when supported */
@supports (background: conic-gradient(from 0deg, red, blue)) {
/* This selector isn't a perfect @property detector, but combined with */
/* knowing FF128+ supports it, it's a practical enough proxy for most cases */
@property --gradient-angle {
syntax: '<angle>';
inherits: false;
initial-value: 0deg;
}
}
Honest caveat on that @supports pattern: there's no clean way to feature-detect @property registration itself in CSS yet. The conic-gradient check above targets syntax support but doesn't confirm @property interpolation works. In practice, I just put @property at the top of the file unconditionally — browsers that don't support it ignore the block, the custom property still works as a static value, and the animation degrades to a hard loop instead of a smooth one. Not ideal, but not broken either.
Making It Reusable with CSS Custom Properties
One Class to Rule Them All
The real unlock with CSS custom properties for gradient borders isn't the effect itself — it's that you write the animation logic exactly once and never touch it again. I spent too long copy-pasting gradient keyframe blocks between components before I just stopped and built a single utility class that accepts parameters. Here's the core of what I settled on:
/* Base utility — drop this in your global stylesheet or design tokens layer */
.animated-border {
--border-width: 2px;
--border-radius: 8px;
--animation-duration: 3s;
--gradient-colors: #6366f1, #a855f7, #ec4899;
position: relative;
border-radius: var(--border-radius);
background: #fff; /* or whatever your surface color is */
}
.animated-border::before {
content: "";
position: absolute;
inset: calc(var(--border-width) * -1);
border-radius: calc(var(--border-radius) + var(--border-width));
background: linear-gradient(var(--gradient-angle, 0deg), var(--gradient-colors));
z-index: -1;
animation: spin-gradient var(--animation-duration) linear infinite;
}
@property --gradient-angle {
syntax: "";
inherits: false;
initial-value: 0deg;
}
@keyframes spin-gradient {
to { --gradient-angle: 360deg; }
}
The @property declaration is the thing that makes the angle animatable at all — without it, the browser can't interpolate between angle values and you get a hard cut instead of a smooth rotation. This requires Chrome 85+, Firefox 128+, and Safari 16.4+. If you need to support older browsers, fall back to a background-position hack on a much larger gradient, but honestly the coverage is good enough now that I don't bother for most projects.
Slotting This Into a Real Design System
If you're on Tailwind, the cleanest approach is to register this class in a CSS layer below Tailwind's utilities, then use inline styles or a style attribute for the overrides. Don't fight Tailwind with @apply here — gradient borders involve pseudo-elements and stacking contexts that Tailwind wasn't designed to express. The utility class lives in CSS, the overrides live as inline custom properties or a wrapper class per component:
/* In your components.css, loaded after Tailwind base */
@layer components {
.animated-border {
/* defaults from above */
}
/* Per-component overrides */
.card-border {
--border-width: 2px;
--border-radius: 12px;
--animation-duration: 4s;
--gradient-colors: #0ea5e9, #6366f1, #8b5cf6;
}
.button-border {
--border-width: 1.5px;
--border-radius: 6px;
--animation-duration: 2s;
--gradient-colors: #f59e0b, #ef4444, #ec4899;
/* Faster spin feels more interactive on buttons */
}
}
For a vanilla CSS design system, I pin the defaults in :root and then override at the component selector level. The cascade does exactly what you want here — the most specific selector wins, and child elements inherit nothing weird because inherits: false on --gradient-angle keeps each instance's animation state isolated.
Card and Button: Side-by-Side Real Example
Here's the actual HTML markup pattern I use. The card needs a thicker border and slower animation because it's a larger surface — a fast-spinning thin line looks chaotic on something 300px wide. The button goes the other direction: thin border, fast animation, warmer colors to signal interactivity:
<!-- Card component -->
<div class="animated-border card-border">
<div class="card-inner">
<h3>Usage Stats</h3>
<p>Your API calls this month...</p>
</div>
</div>
<!-- Button component -->
<button class="animated-border button-border">
Upgrade Plan
</button>
<style>
/* The card needs explicit background on the inner wrapper
to cover the pseudo-element. The outer .animated-border
itself must have a transparent or matching background. */
.card-inner {
padding: 24px;
background: #ffffff;
border-radius: 10px; /* border-radius minus border-width */
}
/* Buttons are tricky — overflow: hidden kills the pseudo-element.
Don't use it here. Use the inset + z-index approach instead. */
.button-border {
background: #ffffff;
padding: 10px 20px;
cursor: pointer;
border: none;
position: relative; /* already set by .animated-border */
}
</style>
The gotcha I hit hard: overflow: hidden on the parent will clip the ::before pseudo-element that bleeds outside the bounds by --border-width. I had a card component with overflow: hidden for image rounding and spent 20 minutes confused why the border disappeared. Strip that property, or restructure so the overflow is on an inner wrapper that doesn't contain the pseudo-element. The other thing nobody mentions — if your component sits inside a transformed ancestor, the z-index: -1 on the pseudo-element creates a new stacking context and the border can disappear behind other elements. Switching to z-index: 0 on the pseudo-element and adding isolation: isolate to the component itself fixes it without breaking the layering.
Gotchas I Hit in Production
The one that burned me first: slapping overflow: hidden on the parent element. It feels natural — you want clean edges, you add overflow: hidden, and suddenly the entire gradient border vanishes. The pseudo-element (::before or ::after) is positioned outside the element's paint area, and overflow: hidden clips it. The fix is to ditch overflow: hidden entirely and instead apply border-radius directly on the pseudo-element to match your parent's radius. Paired with border-radius: inherit on the pseudo-element, you get clean corners without murdering the effect.
.card::before {
content: '';
position: absolute;
inset: -2px; /* controls border thickness */
border-radius: inherit; /* NOT overflow: hidden on the parent */
background: conic-gradient(from var(--angle), #ff6ec4, #7873f5, #4adede, #ff6ec4);
z-index: -1;
animation: rotate 4s linear infinite;
}
/* The parent needs position: relative and a z-index context */
.card {
position: relative;
border-radius: 12px;
/* background must be set here, NOT inline */
background: #1a1a2e;
}
Safari is its own category of pain. If you're combining an animated gradient border with a gradient text effect in the same component — think a card with a glowing title — Safari requires -webkit-background-clip: text explicitly. The non-prefixed background-clip: text alone doesn't work in Safari as of Safari 17. What makes this annoying is that Chrome will work fine without the prefix, so you'll ship it, it'll look great in your browser, and then someone on a Mac opens it and the text renders as a solid block of color.
.gradient-title {
background: linear-gradient(90deg, #ff6ec4, #7873f5);
-webkit-background-clip: text; /* required for Safari */
background-clip: text;
-webkit-text-fill-color: transparent;
color: transparent; /* fallback */
}
CSS-in-JS is where the inline background issue bites hardest. The pseudo-element technique works by layering: the pseudo-element sits behind the parent with z-index: -1, and the parent's background color creates the visual "gap" that makes it look like a border. The second you let styled-components (or Emotion, or MUI's sx prop) inject a background-color inline via style="", it overrides your stylesheet rule with higher specificity, and the gap color is wrong — or worse, transparent, so you see the gradient fill the entire card instead of just the border. Always control the background color through a class or a CSS custom property, never inline style if you're using this technique.
The z-index stacking context problem is the one that takes the longest to debug. If your animated border disappears behind content inside the card — say an image or a child div — it's because that child created a new stacking context (via transform, opacity, will-change, isolation: isolate, etc.). Your pseudo-element with z-index: -1 is now behind the parent's stacking context, not just behind the child. The fix I land on most often is isolation: isolate on the parent card, which creates a contained stacking context, and then managing z-indexes explicitly within that context:
.card {
position: relative;
isolation: isolate; /* contains stacking context — children can't escape it */
border-radius: 12px;
background: var(--card-bg);
}
.card::before {
z-index: -1; /* behind .card's content, but above whatever is behind .card */
}
.card img {
position: relative;
z-index: 1; /* explicit, because we know the context now */
}
Dark mode is the last one and it's subtle enough to ship to production unnoticed. The technique uses your background color as the "fill" inside the border. If you hardcode that as background: #ffffff or background: #1a1a2e, users with prefers-color-scheme: dark get a flash of the wrong color when the page loads — especially on systems where the OS theme is dark but the CSS hasn't painted yet. Use a CSS custom property tied to your theme tokens:
:root {
--card-bg: #ffffff;
}
@media (prefers-color-scheme: dark) {
:root {
--card-bg: #1a1a2e;
}
}
.card {
background: var(--card-bg); /* never hardcode this */
}
If you're using a CSS-in-JS library that injects theme tokens at runtime via JavaScript, that flash is even more pronounced because the JS has to execute before the variable resolves. The only clean solution is to define these as actual CSS custom properties in a <style> block in your <head> — not in a JS bundle — so they're available before the first paint.
When to Use Which Technique
The ::before pseudo-element rotation trick — where you create an oversized pseudo-element, apply a conic-gradient, rotate it with @keyframes, and clip the overflow — is the safest bet across the board. I reach for it when I know the project might hit Firefox below 128, older Safari builds, or anything running on a corporate device where browser updates lag six months behind. The trade-off is that the code is genuinely ugly: you're fighting z-index, position: absolute, overflow: hidden, and border-radius stacking all at once. It works, but maintaining it six months later feels like reading someone else's jQuery plugin.
/* The ::before rotation pattern — messy but compatible */
.card {
position: relative;
border-radius: 12px;
overflow: hidden; /* this clips the spinning pseudo-element */
}
.card::before {
content: '';
position: absolute;
inset: -50%; /* oversized so rotation doesn't show gaps at corners */
background: conic-gradient(from 0deg, #ff6ec4, #7873f5, #4adede, #ff6ec4);
animation: spin 3s linear infinite;
z-index: 0;
}
.card-inner {
position: relative;
z-index: 1;
background: #1a1a1a;
border-radius: 10px; /* 2px less than parent — this is the "border" thickness */
padding: 1.5rem;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
The @property approach is what I use on any green-field project targeting Chrome 85+, Firefox 128+, and Safari 16.4+. You register a custom property, animate it via @keyframes, and use it inside a conic-gradient. The result is maybe 20 lines of CSS versus 50, no DOM wrappers, no z-index gymnastics. The thing that caught me off guard when I first switched: you must declare @property at the top level, not inside a rule. Putting it inside a media query or nesting it breaks it silently in some browsers — no error, gradient just stops animating.
@property --angle {
syntax: '';
initial-value: 0deg;
inherits: false;
}
@keyframes rotate-gradient {
to { --angle: 360deg; }
}
.card {
border: 3px solid transparent;
border-radius: 12px;
background:
linear-gradient(#1a1a1a, #1a1a1a) padding-box,
conic-gradient(from var(--angle), #ff6ec4, #7873f5, #4adede, #ff6ec4) border-box;
animation: rotate-gradient 3s linear infinite;
}
border-image is a trap if you need any border-radius at all — the spec explicitly says border-radius is ignored when border-image is set, and every browser honors that faithfully. So I only pull it out for things like data tables with square cell borders, admin badges that are rectangles by design, or dividers. If you're doing a pricing card with rounded corners and reach for border-image, you'll spend an hour discovering why the corners look sharp before you find the spec note. Don't do that to yourself.
SVG animated stroke is the escape hatch for non-rectangular shapes. Hex cards, custom clip-path polygons, organic blob borders — CSS gradients follow the box model and they simply cannot trace an arbitrary path. With SVG you animate stroke-dashoffset or use a linearGradient with an animateTransform, and the stroke follows whatever d attribute you define. The downside: you're now managing an inline SVG or a background SVG, which complicates responsive sizing and theming. Perfectly worth it for a hero illustration, probably overkill for a UI card component.
Here's how I actually make the call:
- Need Firefox < 128 or Safari < 16.4:
::beforerotation. No other option handles it cleanly. - Modern browsers only, rectangular with border-radius:
@propertyevery time — cleaner, animatable, no wrapper divs. - Rectangular, no border-radius, minimal code:
border-imagewith a gradient. Three lines and done. - Non-rectangular shape (hex, blob, polygon): SVG stroke. CSS will not do what you want here regardless of technique.
- Design system with mixed browser targets: ship the
::beforeversion as the base, layer the@propertyversion inside a@supportsblock so modern browsers get the cleaner code automatically.
The @supports progressive enhancement pattern is underused here. You write one component, browsers self-select the best implementation, and you don't maintain two separate codebases:
/* baseline: ::before rotation for broad support */
.card { position: relative; overflow: hidden; border-radius: 12px; }
.card::before { /* ...rotation setup... */ }
/* override with @property where supported */
@supports (background: conic-gradient(from var(--angle, 0deg), red, red)) {
.card { overflow: visible; } /* no longer need to clip */
.card::before { display: none; }
/* @property and border-box gradient take over */
}
A Real Example: Animated Border on a Pricing Card
The pricing card is probably the highest-stakes UI element on most SaaS landing pages — it either converts or it doesn't. I added an animated gradient border to ours after A/B testing a plain border vs the animated one, and the animated version kept more people engaged on the page. Here's the full implementation I actually ship, not a stripped-down demo that breaks when you add real content.
The trick is using a conic-gradient on a pseudo-element that sits behind the card, then rotating it with a CSS custom property. The card itself gets a solid background so the pseudo-element only peeks through at the edges. No JavaScript, no canvas, no SVG filter hacks.
<!-- HTML -->
<div class="pricing-card">
<h4>Pro Plan</h4>
<p class="price">$49 / month</p>
<ul>
<li>Unlimited projects</li>
<li>10GB storage</li>
<li>Priority support</li>
</ul>
<button>Get Started</button>
</div>
/* CSS */
@property --angle {
syntax: "<angle>";
initial-value: 0deg;
inherits: false;
}
@keyframes spin-border {
to { --angle: 360deg; }
}
.pricing-card {
position: relative;
background: #0f0f13; /* must be opaque — transparent kills the illusion */
border-radius: 16px;
padding: 2rem;
z-index: 0;
animation-name: none; /* card itself doesn't animate */
}
.pricing-card::before {
content: "";
position: absolute;
inset: -2px; /* 2px = border thickness */
border-radius: inherit;
background: conic-gradient(
from var(--angle),
#6366f1, #8b5cf6, #ec4899, #6366f1
);
z-index: -1;
animation: spin-border 4s linear infinite;
}
/* Hover: speed up to 1.2s */
.pricing-card:hover::before {
animation-duration: 1.2s;
}
/* prefers-reduced-motion: slow it way down instead of removing it */
@media (prefers-reduced-motion: reduce) {
.pricing-card::before {
animation-duration: 12s;
}
}
Two things catch people out here. First, @property for --angle is load-bearing — without registering it as an <angle> type, the browser can't interpolate between degree values and the animation just snaps instead of spinning. Chrome 85+, Firefox 128+, Safari 16.4+ all support it, so you're fine for any browser released after mid-2023. Second, the inset: -2px on the pseudo-element is your border thickness. Want a chunkier 4px border? Change it to -4px. Keep it consistent with border-radius: inherit or you'll get visible square corners peeking out from behind the rounded card.
For a subtler "pulse" effect — think dashboard widgets or admin UI where a spinning gradient would feel garish — swap the rotation animation for an opacity or background-size pulse:
@keyframes pulse-border {
0%, 100% { opacity: 0.6; }
50% { opacity: 1; }
}
/* Replace the spin-border animation on ::before with this */
.pricing-card::before {
background: conic-gradient(
from 45deg, /* static angle — no rotation */
#6366f1, #8b5cf6, #ec4899, #6366f1
);
animation: pulse-border 3s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.pricing-card::before {
animation-duration: 8s;
}
}
The pulse version reads as "active" without being distracting — I use it on status indicator cards and the "current plan" highlight in settings pages. The spinning version is better for CTAs where you actively want the user's eye pulled toward the card. Don't use spinning borders on more than one or two elements per page or the whole UI starts to feel like a MySpace profile circa 2006.
On the reduced-motion handling: please don't just do animation: none in that media query. The gradient border is still a useful visual affordance for users who get motion sick — they just don't want things flying around at 60fps. Slowing it to 8–12s means the border still shifts color almost imperceptibly, which is perfectly accessible and still looks intentional. This literally takes two lines of CSS and I've seen so many devs skip it entirely. If you're building this out for a full SaaS product and want to know which tools pair well with this kind of polished UI, the Essential SaaS Tools for Small Business in 2026 guide covers the component libraries and deployment setups worth pairing with a custom CSS design system like this.
Browser DevTools Tips for Debugging This
Chrome's Animations Panel Is the One Tool Most Devs Miss Here
I spent way too long refreshing the page to visually check whether my timing was off before someone pointed me to the Animations panel. Open it with Ctrl+Shift+P → type "Show Animations" → hit Enter. Once it's open, trigger the page load or hover state — Chrome will capture every running animation in a timeline you can scrub. You can pause the gradient rotation mid-frame, slow it down to 10% speed, and actually see what's happening at each keyframe. For animated gradient borders specifically this is gold because the rotation is continuous and fast — your eyes can't catch subtle easing problems at normal speed.
The panel also shows you the animation name, duration, and iteration count in a visual lane. If your border animation isn't showing up there at all, that's a signal: either the element isn't in the DOM yet when you opened the panel, or your animation is running on a pseudo-element and Chrome isn't capturing it. In that case, try clicking the record button manually, then triggering whatever causes the animation. One gotcha — if you're animating --angle via @property, Chrome sometimes won't show it as a distinct animation lane. You'll see the element animating visually but the panel treats it as a style mutation, not a formal animation. That's expected behavior, not a bug in your CSS.
Firefox Is Actually Better for Inspecting Pseudo-Elements Live
Chrome's DevTools still doesn't let you cleanly live-edit pseudo-element styles inline the way Firefox does. In Firefox, open the Inspector panel, find your element, and expand the node — ::before and ::after show up as actual child nodes in the DOM tree. Click on ::before, and in the Rules panel on the right you'll see its full computed styles including the background declaration with the full conic-gradient() string. You can double-click that value and type a new angle, change stop colors, adjust the gradient position — and it reflects instantly on the page without a page reload.
This is genuinely faster for iteration than editing your source file and saving. I'll rough out the color stops directly in the Firefox inspector until the gradient looks right, then copy the final value back into my CSS. The one thing to watch: if your conic-gradient is referencing a custom property like var(--angle), Firefox will show the computed value, not the variable reference. So you'll see something like conic-gradient(from 127deg, #ff0080, #7928ca, #ff0080) rather than the variable. Useful for debugging the output, but remember to edit the variable definition itself if you want the change to stick.
Checking Whether @property Is Actually Registered
This one trips people up constantly. You write your @property block, reference var(--angle) in your gradient, and... the animation doesn't work. Before assuming your keyframes are wrong, check if the property registration even landed. Open DevTools, select the element, go to the Computed tab, and search for your property name — something like --angle. If @property is working correctly, you'll see it listed with its type annotation next to it, like <angle>. That type annotation is the tell. A plain CSS custom property with no @property registration will appear in the Computed tab too, but without any type information — just the raw string value.
@property --angle {
syntax: ''; /* this is what makes interpolation possible */
inherits: false;
initial-value: 0deg;
}
If the Computed tab shows --angle as a bare string value like "0deg" in quotes rather than as a resolved angle type, your @property declaration wasn't picked up. Most common reasons: a syntax error in the block (missing semicolons, wrong property name casing), or the stylesheet containing @property loaded after the property was first used. Also worth knowing — @property is scoped to the document, not to any specific element, so it should show up in the Computed panel for any element you inspect, not just the one using it. If it's absent entirely, check the Sources panel for CSS parse errors on that file.
Disclaimer: This article is for informational purposes only. The views and opinions expressed are those of the author(s) and do not necessarily reflect the official policy or position of Sonic Rocket or its affiliates. Always consult with a certified professional before making any financial or technical decisions based on this content.
Originally published on techdigestor.com. Follow for more developer-focused tooling reviews and productivity guides.
Top comments (0)