Galaxy Button
A glowy CTA with orbiting stars, a conic spark sweep, and a 3D star ring built on transform-style: preserve-3d. Lights up on hover. Ported from jh3y.
hover or focus to light it up
Usage
import { GalaxyButton } from "~/components/buttons/galaxy-button";<GalaxyButton onClick={explore}>Explore</GalaxyButton>// custom iconimport { Rocket } from "lucide-react";<GalaxyButton icon={<Rocket />}>Launch</GalaxyButton>
How it works
This one's all jh3y. The clever bit, and the reason it feels like more than a glow, is the star ring. The button sets transform-style: preserve-3d and a perspective, then a flat circular element gets tipped back with a few rotateX/rotateY transforms so it reads as a disc receding into space. The stars are absolutely-positioned dots pushed out along that disc and spun with a linear orbit animation, so they trace an ellipse instead of a flat circle.
Everything else hangs off a single --active variable that flips from 0 to 1 on hover or focus. The glow, the scale, the star opacity, the conic spark sweep, all of it interpolates from that one number, so the whole thing brightens together. I scoped it to the button (the original toggled the page background via :has), kept a real <button> for focus, and parked the animations behind prefers-reduced-motion.
/* One --active variable drives everything: glow, scale, star opacity. */.galaxy-btn {--active: 0;transform-style: preserve-3d; /* the 3D ring lives in here */perspective: 100vmin;scale: calc(1 + var(--active) * 0.1);box-shadow: 0 0 calc(var(--active) * 6em) calc(var(--active) * 3em)hsl(var(--hue) 97% 61% / .5);}.galaxy-btn:is(:hover, :focus-visible) { --active: 1; }/* The ring is a flat circle tipped into 3D; stars orbit around it. */.galaxy__ring {transform: rotateX(-24deg) rotateY(-30deg) rotateX(90deg);transform-style: preserve-3d;}.star {transform: translate(-50%, -50%) rotate(10deg)translateY(calc(var(--distance) * 1px));animation: galaxy-orbit calc(var(--duration) * 1s) infinite linear;}
Props
| prop | type | default | description |
|---|---|---|---|
| children | ReactNode | "Explore" | Button label. |
| icon | ReactNode | <Sparkles /> | Leading icon. Pass null to omit. |
| …props | ButtonHTMLAttributes | — | Any native button prop (onClick, type, aria-*). |
Original by jh3y.
Source
download .zipThe full implementation, across 2 files. Copy it in or grab the zip. It leans on a cn() class helper and the theme tokens (--primary, --border, …).
import { Sparkles } from "lucide-react";import { cn } from "~/lib/utils";export interface GalaxyButtonPropsextends React.ButtonHTMLAttributes<HTMLButtonElement> {/** Leading icon. Defaults to a sparkle. Pass null to omit. */icon?: React.ReactNode;}type Star = {angle: number;duration: number;delay: number;alpha: number;size: number;distance: number;};// Deterministic so server and client render identically (no hydration drift).const STATIC_STARS: Star[] = [{ angle: 292, duration: 16, delay: 1, alpha: 0.48, size: 4, distance: 48 },{ angle: 79, duration: 20, delay: 6, alpha: 0.4, size: 5, distance: 62 },{ angle: 147, duration: 13, delay: 3, alpha: 0.9, size: 3, distance: 95 },{ angle: 57, duration: 17, delay: 4, alpha: 0.53, size: 6, distance: 65 },{ angle: 12, duration: 15, delay: 2, alpha: 0.6, size: 4, distance: 40 },];const RING_STARS: Star[] = [{ angle: 130, duration: 17, delay: 7, alpha: 0.46, size: 6, distance: 109 },{ angle: 348, duration: 19, delay: 4, alpha: 0.62, size: 6, distance: 149 },{ angle: 13, duration: 13, delay: 8, alpha: 0.79, size: 4, distance: 73 },{ angle: 250, duration: 10, delay: 9, alpha: 0.86, size: 3, distance: 43 },{ angle: 189, duration: 16, delay: 8, alpha: 0.86, size: 4, distance: 158 },{ angle: 59, duration: 20, delay: 4, alpha: 0.49, size: 4, distance: 125 },{ angle: 302, duration: 14, delay: 2, alpha: 0.7, size: 5, distance: 96 },{ angle: 95, duration: 18, delay: 6, alpha: 0.55, size: 3, distance: 134 },{ angle: 211, duration: 12, delay: 1, alpha: 0.8, size: 4, distance: 60 },{ angle: 333, duration: 21, delay: 5, alpha: 0.5, size: 5, distance: 170 },{ angle: 167, duration: 15, delay: 3, alpha: 0.72, size: 3, distance: 88 },{ angle: 24, duration: 19, delay: 7, alpha: 0.6, size: 6, distance: 116 },];function starStyle(s: Star): React.CSSProperties {return {"--angle": s.angle,"--duration": s.duration,"--delay": s.delay,"--alpha": s.alpha,"--size": s.size,"--distance": s.distance,} as React.CSSProperties;}/*** The "galaxy button" — a glowy CTA with orbiting stars, a conic spark sweep,* and a 3D star ring built with `transform-style: preserve-3d`. It lights up on* hover / focus. Ported (and scoped) from jh3y's CodePen. Styling lives in* `.galaxy-btn` (app.css).*/export function GalaxyButton({icon,children = "Explore",className,type = "button",...props}: GalaxyButtonProps) {return (<button type={type} className={cn("galaxy-btn", className)} {...props}><span className="spark" aria-hidden="true" /><span className="backdrop" aria-hidden="true" /><span className="galaxy__container" aria-hidden="true">{STATIC_STARS.map((s, i) => (<span key={i} className="star star--static" style={starStyle(s)} />))}</span><span className="galaxy" aria-hidden="true"><span className="galaxy__ring">{RING_STARS.map((s, i) => (<span key={i} className="star" style={starStyle(s)} />))}</span></span><span className="galaxy-btn__label">{icon === undefined ? <Sparkles /> : icon}{children}</span></button>);}
/* ─── Galaxy button (orbiting stars + 3D ring), ported from jh3y ────── */.galaxy-btn {--transition: 0.25s;--spark: 1.8s;--hue: 245;--cut: 0.1em;--active: 0;--bg:radial-gradient(120% 120% at 126% 126%, hsl(var(--hue) calc(var(--active) * 97%) 98% / calc(var(--active) * 0.9)) 40%, transparent 50%) calc(100px - (var(--active) * 100px)) 0 / 100% 100% no-repeat,radial-gradient(120% 120% at 120% 120%, hsl(var(--hue) calc(var(--active) * 97%) 70% / calc(var(--active) * 1)) 30%, transparent 70%) calc(100px - (var(--active) * 100px)) 0 / 100% 100% no-repeat,hsl(var(--hue) calc(var(--active) * 100%) calc(12% - (var(--active) * 8%)));appearance: none;cursor: pointer;border: 0;position: relative;display: inline-flex;align-items: center;gap: 0.4em;white-space: nowrap;padding: 0.9em 1.3em;border-radius: 2rem;font-family: var(--font-sans);font-size: 1.4rem;font-weight: 500;color: hsl(var(--hue) calc(var(--active) * 90%) 92%);background: var(--bg);box-shadow:0 0 calc(var(--active) * 6em) calc(var(--active) * 3em) hsl(var(--hue) 97% 61% / 0.5),0 0.05em 0 0 hsl(var(--hue) calc(var(--active) * 97%) calc((var(--active) * 50%) + 30%)) inset,0 -0.05em 0 0 hsl(var(--hue) calc(var(--active) * 97%) calc(var(--active) * 10%)) inset;transition:box-shadow var(--transition),scale var(--transition),background var(--transition),color var(--transition);scale: calc(1 + (var(--active) * 0.1));transform-style: preserve-3d;perspective: 100vmin;overflow: hidden;}.galaxy-btn:is(:hover, :focus-visible) {--active: 1;outline: none;}.galaxy-btn:active {scale: 1;}.galaxy-btn__label {position: relative;z-index: 10;display: inline-flex;align-items: center;gap: 0.4em;}.galaxy-btn__label svg {width: 0.9em;height: 0.9em;}.galaxy-btn .star {height: calc(var(--size) * 1px);aspect-ratio: 1;background: white;border-radius: 50%;position: absolute;opacity: var(--alpha);top: 50%;left: 50%;transform: translate(-50%, -50%) rotate(10deg) rotate(0deg)translateY(calc(var(--distance) * 1px));animation: galaxy-orbit calc(var(--duration) * 1s) calc(var(--delay) * -1s)infinite linear;}@keyframes galaxy-orbit {to {transform: translate(-50%, -50%) rotate(10deg) rotate(360deg)translateY(calc(var(--distance) * 1px));}}.galaxy-btn .galaxy {position: absolute;width: 100%;aspect-ratio: 1;top: 50%;left: 50%;translate: -50% -50%;overflow: hidden;opacity: var(--active);transition: opacity var(--transition);}.galaxy-btn .galaxy__ring {height: 200%;width: 200%;position: absolute;top: 50%;left: 50%;border-radius: 50%;transform: translate(-28%, -40%) rotateX(-24deg) rotateY(-30deg) rotateX(90deg);transform-style: preserve-3d;}.galaxy-btn .galaxy__container {position: absolute;inset: 0;opacity: var(--active);transition: opacity var(--transition);-webkit-mask: radial-gradient(white, transparent);mask: radial-gradient(white, transparent);}.galaxy-btn .star--static {top: 50%;left: 50%;max-height: 4px;filter: brightness(4);opacity: 0.9;animation:galaxy-move-x calc(var(--duration) * 0.1s) calc(var(--delay) * -0.1s) infinite linear,galaxy-move-y calc(var(--duration) * 0.2s) calc(var(--delay) * -0.2s) infinite linear;}.galaxy-btn:hover .star--static {animation-play-state: paused;}@keyframes galaxy-move-x {0% { translate: -100px 0; }100% { translate: 100px 0; }}@keyframes galaxy-move-y {0% { transform: translate(0, -50px); }100% { transform: translate(0, 50px); }}.galaxy-btn .spark {position: absolute;inset: 0;border-radius: 2rem;rotate: 0deg;overflow: hidden;-webkit-mask: linear-gradient(white, transparent 50%);mask: linear-gradient(white, transparent 50%);animation: galaxy-flip calc(var(--spark) * 2) infinite steps(2, end);}@keyframes galaxy-flip {to { rotate: 360deg; }}.galaxy-btn .spark:before {content: "";position: absolute;width: 200%;aspect-ratio: 1;top: 0%;left: 50%;z-index: -1;translate: -50% -15%;transform: rotate(-90deg);opacity: calc(var(--active) + 0.4);background: conic-gradient(from 0deg, transparent 0 340deg, white 360deg);transition: opacity var(--transition);animation: galaxy-rotate var(--spark) linear infinite both;}.galaxy-btn .spark:after {content: "";position: absolute;inset: var(--cut);border-radius: 2rem;}.galaxy-btn .backdrop {position: absolute;inset: var(--cut);background: var(--bg);border-radius: 2rem;transition: background var(--transition);}@keyframes galaxy-rotate {to { transform: rotate(90deg); }}@media (prefers-reduced-motion: reduce) {.galaxy-btn .star,.galaxy-btn .spark,.galaxy-btn .spark:before {animation: none;}}