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…

import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"

/**
 * A registered custom property is what makes the conic sweep animatable —
 * plain `--vars` are strings and would jump instead of rotate. Ships inside the
 * component via React 19 hoisted <style>; duplicates dedupe by href.
 */
const STYLES = `@property --zg-angle{syntax:"<angle>";inherits:false;initial-value:0deg}
@keyframes zg-spin{to{--zg-angle:360deg}}`

/**
 * Every stop is a theme token, so the border re-skins with the host palette —

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/gradient-border-button.json

Prompt

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@property gives --zg-angle a 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 hover mode ships the animation already declared but paused, 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 honestyanimation: none removes 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.

On This Page