Galvanize
An animated gradient border cycling amber to ember red to deep violet, like electroplating in progress. Hover accelerates the rotation and thickens the ring by a pixel. Pure CSS via a masked conic gradient.
cssfree
import * as React from "react";
import { cn } from "@/lib/utils";
export interface GalvanizeProps extends React.ComponentPropsWithoutRef<"button"> {
/** Three forge tones the border rotates through. Any CSS colors. */
colors?: [string, string, string];
/** Border radius in px. */
radius?: number;
/** Ring thickness in px at rest — thickens by 1px on hover. */
thickness?: number;
/** Seconds for one full rotation at rest. */
duration?: number;
/** Freeze the animation, holding the current frame. */
paused?: boolean;
}
/**
* Galvanize — an animated gradient border, like electroplating in progress.
* Amber, ember red, and deep violet rotate continuously around the edge; on
* hover the rotation visibly accelerates and the ring thickens by a pixel,
* as if current had increased. Pure CSS via a masked conic gradient — the
* gradient never bleeds into the label. Honors prefers-reduced-motion by
* freezing the ring at a static tri-tone gradient.
*/
export function Galvanize({
colors = ["#ff6b35", "#c1121f", "#5a189a"],
radius = 12,
thickness = 1.5,
duration = 5,
paused = false,
className,
style,
disabled,
children,
...rest
}: GalvanizeProps) {
return (
<button
data-crucible="galvanize"
className={cn(
"relative isolate inline-flex items-center justify-center overflow-hidden",
"bg-neutral-900 px-6 py-3 text-sm font-medium text-white",
"select-none transition-colors",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-950",
"focus-visible:ring-[color:var(--galvanize-c1)]",
"disabled:cursor-not-allowed disabled:opacity-50",
className
)}
style={
{
"--galvanize-c1": colors[0],
"--galvanize-c2": colors[1],
"--galvanize-c3": colors[2],
"--galvanize-thickness": `${thickness}px`,
"--galvanize-duration": `${duration}s`,
"--galvanize-play": paused ? "paused" : "running",
borderRadius: radius,
...style,
} as React.CSSProperties
}
disabled={disabled}
{...rest}
>
<style>{`
@keyframes crucible-galvanize-spin {
to { transform: rotate(360deg); }
}
[data-crucible="galvanize"] .galvanize-mask {
position: absolute;
inset: 0;
border-radius: inherit;
padding: var(--galvanize-thickness);
transition: padding 200ms ease;
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
mask-composite: exclude;
}
[data-crucible="galvanize"] .galvanize-spin {
position: absolute;
top: 50%;
left: 50%;
width: 250%;
height: 250%;
transform: translate(-50%, -50%) rotate(0deg);
background: conic-gradient(
from 0deg,
var(--galvanize-c1),
var(--galvanize-c2),
var(--galvanize-c3),
var(--galvanize-c1)
);
animation: crucible-galvanize-spin var(--galvanize-duration) linear infinite;
animation-play-state: var(--galvanize-play);
}
[data-crucible="galvanize"]:hover .galvanize-mask {
padding: calc(var(--galvanize-thickness) + 1px);
}
[data-crucible="galvanize"]:hover .galvanize-spin {
animation-duration: calc(var(--galvanize-duration) * 0.35);
}
[data-crucible="galvanize"]:disabled .galvanize-spin {
animation-play-state: paused;
}
@media (prefers-reduced-motion: reduce) {
[data-crucible="galvanize"] .galvanize-spin {
animation: none;
transform: translate(-50%, -50%) rotate(35deg);
}
[data-crucible="galvanize"]:hover .galvanize-mask {
padding: var(--galvanize-thickness);
}
}
`}</style>
<span aria-hidden className="galvanize-mask pointer-events-none">
<span className="galvanize-spin" />
</span>
{/* Faint persistent rim over the gradient: keeps the silhouette readable
(and lifts the dim ember/violet phases) no matter the spin phase. */}
<span
aria-hidden
className="pointer-events-none absolute inset-0 rounded-[inherit] border border-white/15"
/>
<span className="relative z-10">{children}</span>
</button>
);
}
Installation
CLI
npx shadcn@latest add @crucible/galvanizeHonors prefers-reduced-motion with a designed static fallback, and passes className through.