Aurora Button
Soft colour clouds drift over a dark field, crossing and mixing like emergent light.
The fill is several radial clouds, each drifting on its own path at its own tempo. Because they cross and overlap, the colour visibly moves and mixes instead of reading as a flat wash. The field is blurred so the clouds melt together, a fractal-noise tile is blended over for real film grain, and all motion stops on reduce.
Installation
Complete the shared Setup first, then copy the component into
components/ui/aurora-button.tsx.
'use client';import * as React from 'react';import { cn } from '@/lib/cn';const FOCUS = 'focus-visible:outline-none focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:[outline-color:var(--aurora-focus)]';export type AuroraTone = 'aurora' | 'azure' | 'sunset' | 'indigo' | 'magenta' | 'ember';type Blob = { color: string; size: number; x: number; y: number; path: 1 | 2 | 3 | 4;};type ToneSpec = { base: string; blobs: Blob[]; accent: string;};const TONES: Record<AuroraTone, ToneSpec> = { aurora: { base: '#06131f', accent: '#7fe3c0', blobs: [ { color: '#7fe6c4', size: 95, x: 46, y: 10, path: 1 }, { color: '#f4a53f', size: 105, x: 54, y: 96, path: 2 }, { color: '#f6e7b6', size: 70, x: 50, y: 58, path: 3 }, { color: '#3aa0ff', size: 80, x: 12, y: 44, path: 4 }, ], }, azure: { base: '#1745e6', accent: '#6ea8ff', blobs: [ { color: '#eef3ff', size: 100, x: 12, y: 14, path: 1 }, { color: '#74acff', size: 110, x: 44, y: 52, path: 2 }, { color: '#0e2fc4', size: 105, x: 92, y: 64, path: 3 }, ], }, sunset: { base: '#0b1130', accent: '#2b4bff', blobs: [ { color: '#2b4bff', size: 95, x: 34, y: 50, path: 1 }, { color: '#ff3b2f', size: 100, x: 74, y: 50, path: 2 }, { color: '#ff9a3c', size: 85, x: 98, y: 46, path: 3 }, { color: '#131f66', size: 90, x: 6, y: 50, path: 4 }, ], }, indigo: { base: '#140d55', accent: '#7d3ff5', blobs: [ { color: '#7d3ff5', size: 100, x: 12, y: 44, path: 1 }, { color: '#4b32d6', size: 105, x: 92, y: 52, path: 2 }, { color: '#b98bff', size: 65, x: 52, y: 22, path: 3 }, ], }, magenta: { base: '#2c0b63', accent: '#ff36c8', blobs: [ { color: '#ff36c8', size: 105, x: 50, y: 100, path: 1 }, { color: '#7b2ff0', size: 90, x: 22, y: 16, path: 2 }, { color: '#3f16b0', size: 95, x: 92, y: 60, path: 3 }, ], }, ember: { base: '#140811', accent: '#ff9a3c', blobs: [ { color: '#ff9a3c', size: 100, x: 14, y: 86, path: 1 }, { color: '#c23a2f', size: 105, x: 56, y: 56, path: 2 }, { color: '#7a1f2b', size: 90, x: 90, y: 34, path: 3 }, ], },};const PATH_TEMPO: Record<Blob['path'], number> = { 1: 1, 2: 1.32, 3: 0.86, 4: 1.14 };export type AuroraButtonProps = { tone?: AuroraTone; grain?: boolean; animate?: boolean; speed?: number; accent?: string; icon?: React.ReactNode;} & React.ButtonHTMLAttributes<HTMLButtonElement>;export function AuroraButton({ tone = 'aurora', grain = true, animate = true, speed = 9, accent, icon, children = 'Start Free Trial', className, style, ...props}: AuroraButtonProps) { const spec = TONES[tone]; const vars = { '--aurora-speed': `${speed}s`, '--aurora-focus': accent ?? spec.accent, } as React.CSSProperties; return ( <button type="button" style={{ ...vars, ...style }} className={cn( 'group relative isolate inline-flex cursor-pointer select-none overflow-hidden rounded-[18px]', 'shadow-[0_2px_4px_-1px_rgba(0,0,0,0.18),0_18px_40px_-14px_rgba(0,0,0,0.4)]', 'dark:shadow-[0_2px_6px_-1px_rgba(0,0,0,0.55),0_26px_54px_-18px_rgba(0,0,0,0.75)]', 'transition-transform duration-100 ease-out active:scale-[0.98] motion-reduce:active:scale-100', FOCUS, className, )} {...props} > <span aria-hidden className="aurora-field pointer-events-none absolute inset-0 rounded-[inherit]" style={{ backgroundColor: spec.base }} > {spec.blobs.map((b, i) => ( <span key={i} data-animate={animate ? '' : undefined} className="aurora-blob" style={{ width: `${b.size}%`, height: `${b.size}%`, left: `${b.x}%`, top: `${b.y}%`, background: `radial-gradient(circle at center, ${b.color} 0%, ${b.color} 12%, transparent 66%)`, animationName: `aurora-b${b.path}`, animationDuration: `calc(var(--aurora-speed, 9s) * ${PATH_TEMPO[b.path]})`, }} /> ))} </span> {grain && ( <span aria-hidden className="aurora-grain pointer-events-none absolute inset-0 rounded-[inherit] opacity-[0.22] [mix-blend-mode:overlay]" /> )} <span aria-hidden className="pointer-events-none absolute inset-0 z-10 rounded-[inherit] shadow-[inset_0_1px_0_0_rgba(255,255,255,0.22),inset_0_0_0_1px_rgba(0,0,0,0.28),inset_0_-14px_30px_-10px_rgba(0,0,0,0.5)]" /> <span className="relative z-20 flex items-center justify-center gap-2 px-7 py-3.5 text-[16px] font-semibold tracking-[-0.01em] text-white [text-shadow:0_1px_2px_rgba(0,0,0,0.35)]"> {icon != null && ( <span className="flex shrink-0 items-center justify-center [&_svg]:size-[18px]"> {icon} </span> )} {children} </span> </button> );}Add the animations to your global.css.
.aurora-field {
overflow: hidden;
filter: blur(16px) saturate(1.05);
}
.aurora-blob {
position: absolute;
border-radius: 9999px;
transform: translate(-50%, -50%);
mix-blend-mode: screen;
will-change: transform;
}
.aurora-blob[data-animate] {
animation-timing-function: ease-in-out;
animation-iteration-count: infinite;
}
@keyframes aurora-b1 {
0%,
100% {
transform: translate(-50%, -50%) translate(-26%, -20%) scale(1);
}
50% {
transform: translate(-50%, -50%) translate(24%, 22%) scale(1.18);
}
}
@keyframes aurora-b2 {
0%,
100% {
transform: translate(-50%, -50%) translate(30%, -14%) scale(1.12);
}
50% {
transform: translate(-50%, -50%) translate(-22%, 20%) scale(0.94);
}
}
@keyframes aurora-b3 {
0%,
100% {
transform: translate(-50%, -50%) translate(-14%, 26%) scale(1.06);
}
50% {
transform: translate(-50%, -50%) translate(18%, -24%) scale(1.22);
}
}
@keyframes aurora-b4 {
0%,
100% {
transform: translate(-50%, -50%) translate(12%, 12%) scale(1.2);
}
50% {
transform: translate(-50%, -50%) translate(-20%, -10%) scale(1);
}
}
.aurora-grain {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
background-size: 140px 140px;
}
@media (prefers-reduced-motion: reduce) {
.aurora-blob[data-animate] {
animation: none;
}
}Usage
import { Sparkles } from 'lucide-react';
import { AuroraButton } from '@/components/ui/aurora-button';
export default function Example() {
return <AuroraButton icon={<Sparkles strokeWidth={2} />}>Start Free Trial</AuroraButton>;
}Examples
Six colour tones, each with its own drifting clouds.
Turn off drift and grain for a static, flat-lit field.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
tone | AuroraTone | "aurora" | Colour theme of the grainy mesh. |
grain | boolean | true | Overlay the film-grain texture. |
animate | boolean | true | Drift the colour clouds like shifting light. |
speed | number | 9 | Seconds for the base drift cycle. |
accent | string | tone's hue | Focus-outline colour. |
icon | React.ReactNode | - | Optional leading icon. |
className | string | - | Forwarded to the button. |
tone is one of aurora, azure, sunset, indigo, magenta, or ember. The
button also accepts every native <button> prop.