Skip to content
← components

Galaxy Button

new

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

example.tsx
import { GalaxyButton } from "~/components/buttons/galaxy-button";
<GalaxyButton onClick={explore}>Explore</GalaxyButton>
// custom icon
import { 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.

galaxy-button.css
/* 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

proptypedefaultdescription
childrenReactNode"Explore"Button label.
iconReactNode<Sparkles />Leading icon. Pass null to omit.
…propsButtonHTMLAttributesAny native button prop (onClick, type, aria-*).

Original by jh3y.

The 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, …).

components/buttons/galaxy-button.tsx
import { Sparkles } from "lucide-react";
import { cn } from "~/lib/utils";
export interface GalaxyButtonProps
extends 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>
);
}
app/styles/app.css
/* ─── 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;
}
}