A reusable `GradientBorder` wrapper that surrounds its children with a 2px border of orange, rose, violet and sky that turns slowly, plus a blurred bloom of the same colours spilling onto the page behind it. The demo wraps a white pricing card on a warm, light background and includes a three-step speed control so you can see slow, steady and fast. On phones the copy sits above the card and the card fills the width.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties, ReactNode } from "react";
// Seconds for one full turn of the colours.
const speeds = [
{ name: "Slow", seconds: 14 },
{ name: "Steady", seconds: 7 },
{ name: "Fast", seconds: 3 },
];
const features = [
"Unlimited client boards and review links",
"Version history going back 180 days",
"Brand kits shared across every project",
"Priority rendering on export",
];
/** Wrap anything in this to give it the rotating border and the soft bloom behind it. */
function GradientBorder({ children, seconds }: { children: ReactNode; seconds: number }) {
return (
<div className="agbc-frame group relative" style={{ "--agbc-turn": `${seconds}s` } as CSSProperties}>
{/* Bloom: the same colours, blurred and spilling past the card */}
<div aria-hidden="true" className="pointer-events-none absolute -inset-2 overflow-hidden rounded-3xl opacity-50 blur-2xl transition-opacity duration-500 group-hover:opacity-80">
<span className="agbc-light" />
</div>
{/* Border: 2px of padding lets the spinning colours show around the white card */}
<div className="relative overflow-hidden rounded-3xl p-0.5 shadow-xl shadow-rose-900/10">
<span aria-hidden="true" className="agbc-light" />
<div className="relative rounded-[22px] bg-white">{children}</div>
</div>
</div>
);
}
export default function AnimatedGradientBorderCardWrapper() {
const rootRef = useRef<HTMLElement>(null);
const [paused, setPaused] = useState(false);
const [speed, setSpeed] = useState(1);
// Stop the rotation while the section is scrolled out of view.
useEffect(() => {
const el = rootRef.current;
if (!el || typeof IntersectionObserver === "undefined") return;
const observer = new IntersectionObserver(([entry]) => setPaused(!entry.isIntersecting));
observer.observe(el);
return () => observer.disconnect();
}, []);
return (
<section
ref={rootRef}
data-paused={paused}
className="animated-gradient-border-card-wrapper relative overflow-hidden bg-orange-50 px-5 py-16 sm:px-8 sm:py-20"
>
<style>{`
@keyframes animated-gradient-border-card-wrapper-spin {
from { transform: translate(-50%, -50%) rotate(0deg); }
to { transform: translate(-50%, -50%) rotate(360deg); }
}
@keyframes animated-gradient-border-card-wrapper-rise {
from { opacity: 0; transform: translateY(22px) scale(0.98); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
.animated-gradient-border-card-wrapper .agbc-rise {
animation: animated-gradient-border-card-wrapper-rise 0.8s cubic-bezier(0.22, 1, 0.36, 1) both;
}
/* A square bigger than the card, painted with a colour wheel and spun about its centre. */
.animated-gradient-border-card-wrapper .agbc-light {
position: absolute;
left: 50%;
top: 50%;
width: 240%;
aspect-ratio: 1 / 1;
background: conic-gradient(from 0deg, #fb923c, #f43f5e, #c026d3, #6366f1, #38bdf8, #fbbf24, #fb923c);
animation: animated-gradient-border-card-wrapper-spin var(--agbc-turn, 7s) linear infinite;
will-change: transform;
}
.animated-gradient-border-card-wrapper[data-paused=true] * {
animation-play-state: paused;
}
@media (prefers-reduced-motion: reduce) {
.animated-gradient-border-card-wrapper .agbc-rise { animation: none; }
.animated-gradient-border-card-wrapper .agbc-light { animation: none; transform: translate(-50%, -50%) rotate(35deg); }
}
`}</style>
<div aria-hidden="true" className="pointer-events-none absolute -left-32 top-10 h-96 w-96 rounded-full bg-rose-200/50 blur-3xl" />
<div aria-hidden="true" className="pointer-events-none absolute -right-24 bottom-0 h-96 w-96 rounded-full bg-amber-200/60 blur-3xl" />
<div className="relative mx-auto grid max-w-5xl items-center gap-12 lg:grid-cols-2 lg:gap-16">
<div className="agbc-rise">
<p className="text-xs font-semibold uppercase tracking-widest text-rose-700">Pinwheel for studios</p>
<h2 className="mt-4 text-4xl font-semibold leading-tight tracking-tight text-slate-950 sm:text-5xl">The plan nine-person teams grow into.</h2>
<p className="mt-5 max-w-md text-base leading-7 text-slate-700">
Everything in Solo, plus the shared pieces a studio fights over: brand kits, client review links and a history long enough to settle arguments.
</p>
<div className="mt-8">
<p id="agbc-speed-label" className="text-sm font-medium text-slate-700">
Border speed
</p>
<div role="group" aria-labelledby="agbc-speed-label" className="mt-3 inline-flex rounded-full border border-slate-900/10 bg-white p-1 shadow-sm">
{speeds.map((option, index) => (
<button
key={option.name}
type="button"
aria-pressed={speed === index}
onClick={() => setSpeed(index)}
className={`h-10 rounded-full px-4 text-sm font-semibold transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-600 focus-visible:ring-offset-2 ${
speed === index ? "bg-slate-950 text-white" : "text-slate-700 hover:bg-slate-100"
}`}
>
{option.name}
<span className={`ml-1.5 font-mono text-xs ${speed === index ? "text-slate-300" : "text-slate-500"}`}>{option.seconds}s</span>
</button>
))}
</div>
</div>
</div>
<div className="agbc-rise mx-auto w-full max-w-sm" style={{ animationDelay: "0.12s" }}>
<GradientBorder seconds={speeds[speed].seconds}>
<div className="p-7 sm:p-8">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-slate-950">Studio</h3>
<span className="rounded-full bg-gradient-to-r from-orange-500 via-rose-500 to-fuchsia-600 px-3 py-1 text-xs font-semibold text-white">Most picked</span>
</div>
<p className="mt-6 flex items-baseline gap-2">
<span className="text-6xl font-semibold tracking-tight text-slate-950">£24</span>
<span className="text-sm text-slate-600">per seat, each month</span>
</p>
<p className="mt-3 text-sm leading-6 text-slate-600">Billed yearly. Add or remove seats whenever the team changes.</p>
<ul className="mt-7 space-y-3 border-t border-slate-200 pt-7">
{features.map((feature) => (
<li key={feature} className="flex items-start gap-3 text-sm text-slate-800">
<span className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-rose-100 text-rose-700">
<svg viewBox="0 0 20 20" fill="none" aria-hidden="true" className="h-3 w-3">
<path d="m5 10.5 3.2 3.2L15 6.8" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</span>
{feature}
</li>
))}
</ul>
<a
href="#"
className="mt-8 flex h-12 items-center justify-center rounded-full bg-slate-950 text-sm font-semibold text-white transition-transform duration-300 ease-out hover:-translate-y-0.5 active:translate-y-0 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-600 focus-visible:ring-offset-2"
>
Start the 14-day trial
</a>
<p className="mt-3 text-center text-xs text-slate-600">No card needed until day 15.</p>
</div>
</GradientBorder>
</div>
</div>
</section>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/animated-gradient-border-card-wrapper.json
Give a wrapper `overflow: hidden`, a border radius and a couple of pixels of padding. Inside it, place a large square with a `conic-gradient` background and rotate it with a keyframe, then put your card on top with a solid background. The padding is the only place the gradient shows.
`border-image` does not follow `border-radius`, so the corners come out square. The padded wrapper approach keeps rounded corners and also lets the same layer be reused for the glow.
It is a second clipped wrapper, a little larger than the card, containing the same spinning square with a blur filter. Its colours stay in step with the border because both use the same animation and duration.
Only a transform is animated, so the work stays on the compositor. The blur is applied to a layer that does not change size. The animation is paused while the section is off screen.
Yes. `GradientBorder` accepts any children. For a wide, short block the square still covers it because its width is set relative to the wrapper.
Related: Button with Rotating Gradient Glow Border, Breathing Glow Border Status Panel, Single Price Card with Included List and Guarantee.
More borders and glows or all free UI components. Start for free to have a full site built around it.
We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.