Animated Gradient Border: CSS Trick for Cards and Inputs
Animated gradient borders make cards and inputs pop without heavy JS. Here's exactly how to build them in CSS and React — no libraries, no hacks.
Why Animated Gradient Borders Are Worth Your Time
Honestly, the animated gradient border is one of those CSS effects that looks like it took a week but can be done in about 20 lines. It's used everywhere now — pricing cards, focus states on inputs, newsletter signup boxes, hero CTAs. And yet most tutorials either skip the gotchas or wrap it in a 40kb dependency you don't need.
This article is purely technical. We're going to build it from scratch using the conic-gradient + @property + animation approach, then look at how to drop it into a React component tree cleanly. We'll also cover the Tailwind v4.0.2 arbitrary-property path for those of you who don't want to touch a CSS file at all.
One thing to keep in mind: the trick has three distinct implementation paths, and which one you reach for depends on your browser support targets and whether you care about animating the gradient itself vs. just rotating a static one. Let's get into it.
How the CSS Technique Actually Works
The core idea is simple. You can't animate a gradient directly in CSS — gradients aren't animatable properties. So the workaround is to set the gradient as a background, then rotate a custom property (an angle) that the gradient references. That's what @property unlocks.
Here's the clearest mental model: your element gets a gradient background that's larger than the element itself, and you clip the visible area down to the border zone using two layers. The outer element shows the rotating gradient. An inner pseudo-element with a solid background sits on top, giving you the 'card interior' with a gradient border ring around it.
The alternative — and the one with better browser support back to Chrome 80 — is the conic-gradient + rotate trick where you don't animate the gradient at all. You rotate a conic-gradient pseudo-element behind the card using a keyframe. It looks identical to most people. Browser support for @property (which enables true gradient animation) sits at ~94% as of 2026, so both approaches are valid.
Pure CSS Implementation With @property
Here's the full working code. No build step, no framework, just browser-native CSS. The @property declaration tells the browser to treat --angle as an animatable number, which is what makes the gradient actually transition smoothly.
@property --angle {
syntax: '<angle>';
initial-value: 0deg;
inherits: false;
}
@keyframes spin-border {
from { --angle: 0deg; }
to { --angle: 360deg; }
}
.gradient-border-card {
--border-width: 2px;
--border-radius: 12px;
position: relative;
border-radius: var(--border-radius);
background: #0f0f10;
padding: 24px;
}
.gradient-border-card::before {
content: '';
position: absolute;
inset: calc(-1 * var(--border-width));
border-radius: calc(var(--border-radius) + var(--border-width));
background: conic-gradient(
from var(--angle),
#6366f1,
#a855f7,
#ec4899,
#f97316,
#6366f1
);
animation: spin-border 4s linear infinite;
z-index: -1;
}The inset trick is doing the heavy lifting here. By setting inset to the negative of the border width (2px in this case), the ::before pseudo-element bleeds out exactly 2px beyond the card edge on all sides. The border-radius on the pseudo needs to be the card's radius plus the border width to keep it looking crisp — skip this and you get little square corners poking out underneath.
React Component With Tailwind Arbitrary Properties
If you're working in a React + Tailwind project, you want this as a reusable component. The challenge is that Tailwind doesn't have built-in classes for conic-gradient borders, so you'll either write a small CSS module alongside your component or use Tailwind's arbitrary value syntax for one-offs. With Tailwind v4.0.2, the arbitrary CSS property approach is clean enough to be worth it.
// GradientBorderCard.tsx
import React from 'react'
interface Props {
children: React.ReactNode
borderWidth?: number
borderRadius?: number
speed?: number
className?: string
}
export function GradientBorderCard({
children,
borderWidth = 2,
borderRadius = 12,
speed = 4,
className = '',
}: Props) {
const style = {
'--border-width': `${borderWidth}px`,
'--border-radius': `${borderRadius}px`,
'--spin-speed': `${speed}s`,
} as React.CSSProperties
return (
<div
className={`gradient-border-card relative bg-neutral-950 p-6 ${className}`}
style={style}
>
{children}
</div>
)
}The CSS variables get threaded into the component via inline style, which means you can control the border width, radius, and animation speed from the JSX call site without touching any CSS files. Drop your actual CSS (the @property + keyframe + ::before rules) into a globals.css or a component-scoped .module.css — both work fine. The component itself stays clean. You'd call it like <GradientBorderCard borderWidth={3} speed={6}>...</GradientBorderCard>.
Animated Gradient Border on Focus for Inputs
Cards are the obvious use case, but the focus state for form inputs is where this effect actually earns its keep. A user clicking into a dark-mode email field and seeing a soft purple-to-blue gradient ring appear — that's the kind of micro-interaction that makes a product feel considered.
The approach for inputs is slightly different because you can't easily use ::before on a native input element (inputs are replaced elements and pseudo-elements behave unpredictably on them). The fix is to wrap the input in a div and apply the gradient effect to the wrapper on focus-within.
// GradientInput.tsx
export function GradientInput(
props: React.InputHTMLAttributes<HTMLInputElement>
) {
return (
<div className="gradient-border-input-wrap relative rounded-[10px]">
<input
{...props}
className="relative z-10 w-full rounded-[8px] bg-neutral-900 px-4 py-3
text-sm text-neutral-100 outline-none
placeholder:text-neutral-500"
/>
</div>
)
}Then in your CSS you'd add the ::before rule scoped to .gradient-border-input-wrap:focus-within::before. The animation only kicks in on focus, which means zero GPU cost when the input is idle. Use animation-play-state: paused by default and switch it to running on focus-within — that way the gradient doesn't jump back to 0deg every time the user clicks in.
Performance: GPU, Will-Change, and Composite Layers
Here's a thing that trips people up: rotating a conic-gradient on every frame is not free. The browser has to repaint the pseudo-element every frame because gradient backgrounds can't be composited as a separate layer the same way transforms and opacity can. On a page with three gradient-bordered cards this is totally fine. On a page with 30? You might notice jank on mid-range phones.
The recommended optimization is to use will-change: transform on the ::before pseudo and rotate it using transform: rotate(var(--angle)) rather than animating the background directly. This moves the animation work to the compositor thread. You do lose the smooth multi-stop gradient animation that @property gives you, but the rotating conic-gradient approach works with transforms and is far cheaper on the GPU.
Want a reference for other GPU-heavy background effects and how they compare? The aurora background for React article covers a similar set of performance tradeoffs for full-screen animated backgrounds. And if you're mixing gradient borders with a particles background, keep a close eye on your DevTools Rendering panel — overlapping GPU layers from both effects can stack up fast.
Pairing With Dark Mode and Glassmorphism
Gradient borders read best on dark backgrounds. On a white card the gradient can feel garish — the contrast ratio between a white surface and a vivid conic-gradient is too high. If you need light mode support, the cleanest approach is to reduce the opacity of the ::before pseudo-element to around 0.4-0.6 in light mode and keep it at 1.0 in dark.
Glassmorphism cards and gradient borders are a natural pair. A card with backdrop-filter: blur(12px) and background: rgba(255,255,255,0.08) sitting inside a gradient border ring looks genuinely sharp. If you haven't set up the glassmorphism pattern yet, the what is glassmorphism article is a solid primer on getting the backdrop-filter and background values right without it looking muddy.
One edge case: Safari still requires -webkit-backdrop-filter in addition to the standard property as of 2026. If you're using the glass + gradient border combo and something looks off on iOS, that's almost certainly the culprit. Also worth checking: does your theme toggle setup correctly swap the CSS custom properties that drive your gradient colors? Switching from a vivid purple-pink gradient in dark mode to a subtler slate-blue in light mode makes a big difference.
Accessibility and Reduced Motion
Constantly spinning borders are the kind of animation that can trigger discomfort in users with vestibular disorders. The prefers-reduced-motion media query exists exactly for this. You should always wrap your animation declaration in a check, or at minimum respect the query by slowing the animation to a near-stop.
The simplest implementation is to add one rule to your CSS: @media (prefers-reduced-motion: reduce) { .gradient-border-card::before { animation-duration: 60s; } }. That slows the spin to essentially nothing without removing the visual effect entirely — the gradient is still there, it's just not moving in a way that bothers anyone. Some developers prefer to kill the animation entirely in reduced-motion mode and show a static gradient border instead.
From a WCAG standpoint, a decorative spinning border doesn't fail any specific criterion on its own. But it's good practice and it's one line of CSS. There's no good argument for skipping it. If your component library exports this component, make reduced-motion respect part of the default behavior, not an opt-in.
FAQ
CSS gradients are treated as images by the browser, not as color values, so the animation engine doesn't know how to interpolate between two gradient definitions. @property changes that by letting you declare a custom property with a specific syntax type — in this case '<angle>' — which the browser CAN interpolate. Without @property, you fall back to the rotate-a-pseudo-element approach, which is the most compatible option anyway.
Yes. conic-gradient has had full Firefox support since Firefox 83. @property landed in Firefox 128 (released mid-2024). If you need to support anything older than Firefox 128, use the transform: rotate() approach on the ::before pseudo-element instead of animating --angle directly.
Set z-index: -1 on the ::before pseudo-element and make sure the card itself has position: relative and a non-transparent background. If your card background is transparent or uses backdrop-filter, you may need to restructure so the pseudo-element sits below a solid background layer. The inset + negative z-index pattern handles most cases cleanly.
Not without a workaround. overflow: hidden clips the ::before pseudo-element, so you'll see nothing. The fix is to move the gradient border pseudo-element to a wrapper div that doesn't have overflow: hidden, and apply overflow: hidden only to the inner content container.
Pass the speed as a CSS custom property via the style attribute: style={{ '--spin-speed': ${speed}s } as React.CSSProperties}. Then in your CSS reference it in the animation rule: animation: spin-border var(--spin-speed) linear infinite. This keeps the component API clean and avoids inline style conflicts with Tailwind.
The ::before pseudo-element's border-radius needs to equal the card's border-radius plus the border width. If your card is border-radius: 12px and your border is 2px, the pseudo needs border-radius: 14px. Mismatched values create that blocky corner artifact. Also check that anti-aliasing isn't being killed by transform: translateZ(0) on a parent — that can force pixel-snapping on some GPUs.