Buttons
Gradient Border Button
A rotating conic-gradient ring around a solid core, so the border carries the colour and the label keeps full contrast.
Preview in your theme
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/gradient-border-button.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "GradientBorderButton" component using
class-variance-authority (cva). No animation library — one registered custom
property does all the work.
Contract
- forwardRef<HTMLButtonElement, GradientBorderButtonProps>; the props interface
extends React.ButtonHTMLAttributes<HTMLButtonElement> plus VariantProps of the
root and border-layer cva configs. All native props spread onto the <button>;
type="button" is set before the spread so a consumer can still make it submit.
- motionMode: "spin" | "hover" | "static" (default "spin").
- thickness: "hairline" | "thin" (default "hairline") — literally the button's
padding (p-px / p-0.5); the padding IS the visible border width.
- size: "sm" | "md" | "lg" (default "md") — sets the root radius and the core's
height / horizontal padding / text size.
- No state, no effects, no browser APIs: it stays a server component.
Behavior
- Register the sweep angle so it can be animated:
@property --zg-angle { syntax: "<angle>"; inherits: false; initial-value: 0deg }
plus @keyframes zg-spin { to { --zg-angle: 360deg } }. Unregistered custom
properties are plain strings and would jump instead of rotate.
- Three layers: the <button> is the ring box (overflow-hidden + rounded), an
aria-hidden absolute span paints
conic-gradient(from var(--zg-angle), <token stops>) across the full box, and a
relative z-10 span is the solid core that covers everything except the padding
band.
- spin: the layer runs zg-spin 5s linear infinite and drops to 1.8s on
group-hover. hover: the same animation ships `paused` in the shorthand and
group-hover flips animation-play-state to running. static: no animation at all.
- Every mode lifts the ring from opacity-80 to opacity-100 on hover, so even the
static mode answers the pointer.
- prefers-reduced-motion: motion-reduce sets animation: none on the layer, which
wins over the hover duration / play-state overrides because it kills the
animation name; the static gradient, the hover opacity lift and the whole
button keep working.
- disabled: pointer-events-none, the button fades and the ring fades further
(group-disabled:opacity-50) so it reads as desaturated rather than missing.
Rendering & styling
- Semantic tokens only. The stop list starts and ends on var(--primary) (so the
loop closes seamlessly and the ring inherits the brand) with var(--chart-2),
var(--chart-4), var(--chart-1), var(--chart-3) in between; the core is
bg-background + text-foreground; focus is ring-ring with ring-offset-2 +
ring-offset-background so it stays visible outside the gradient edge.
No hex, no rgb(), no oklch() literals — the ring re-skins with the host theme.
- The core uses rounded-[inherit] so one radius token drives both edges.
- Ship @property + @keyframes from inside the component with a React 19 hoisted
<style href="..." precedence="medium"> tag; duplicates dedupe by href.
- Merge consumer className via cn(); the root is isolate + relative so the layer
can never escape its stacking context.
Customization levers
- Palette: the conic stop list is the only colour decision — drop the chart
stops for [var(--primary), var(--accent), var(--primary)] to go mono-brand, or
pair one token with `transparent` for a comet-tail sweep. Always repeat the
first stop last, or the loop shows a seam.
- Speed: 5s (idle) and 1.8s (hover) — keep the hover value at roughly a third of
idle so the acceleration reads as intent, not as a glitch.
- Thickness: add a "bold" variant with p-1 for hero-sized buttons; the core needs
no change because it inherits the radius.
- Core surface: bg-background is the default; bg-card keeps the button readable
when it sits on a tinted section, and bg-transparent turns it into a glass ring.
- Size: the core's h/px/text triple per size; add an "icon" size (square, no
horizontal padding) for icon-only actions.
- Motion default: ship motionMode="hover" as the default if several of these
live on one page — a wall of always-spinning borders is noisy.Concepts
- Gradient stroke, not fill — the colour lives in a 1px band while the core stays
bg-background, so the label contrast is a constant instead of something you have to test against every gradient stop. - Padding as border — the ring's width is the button's own padding, which means one variant axis changes the border thickness without touching radius, layout or the core.
- Registered angle —
@propertygives--zg-anglea real<angle>type; only typed custom properties interpolate, which is why the sweep rotates smoothly instead of snapping at each keyframe. - Paused-by-default motion — the
hovermode ships the animation already declared butpaused, so hovering flips one property rather than restarting a fresh animation from angle 0. - Token-driven palette — the stops are
var(--primary)plus the--chart-*ramp, so the same component reads as brushed steel in a monochrome theme and picks up the brand hue the moment the host palette has one, with zero code changes. - Reduced-motion honesty —
animation: noneremoves the animation name entirely, which is why the hover duration and play-state overrides can't resurrect it; the frozen gradient and every interaction remain.