Launch
import { useEffect, useRef, useState } from 'react';
const css = `
@keyframes btn-launch-rumble {
0% { translate: 0 0; } 25% { translate: -1px 1px; } 50% { translate: 1px 0; } 75% { translate: 0 -1px; } 100% { translate: 1px 1px; }
}
@keyframes btn-launch-flicker { 0%, 100% { scale: 1 1; } 50% { scale: 0.85 1.15; } }
@keyframes btn-launch-pop { from { transform: scale(1.6); opacity: 0; } to { transform: scale(1); opacity: 1; } }
@keyframes btn-launch-puff {
from { transform: translate(-50%, -50%) scale(0.3); opacity: 0.8; }
to { transform: translate(calc(-50% + var(--x)), calc(-50% + var(--y))) scale(1.6); opacity: 0; }
}
.btn-launch {
position: relative;
display: grid;
place-items: center;
width: 220px;
height: 150px;
}
.btn-launch__btn {
position: relative;
z-index: 1;
width: 132px;
height: 46px;
border: 1px solid light-dark(#cbced3, #393c41);
border-radius: 999px;
background: light-dark(#f3f4f7, #1a1c1f);
color: light-dark(#373a40, #f1f2f3);
font-family: inherit;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: transform 0.9s cubic-bezier(0.6, 0, 0.9, 0.45);
}
.btn-launch__btn:focus-visible { outline: 2px solid light-dark(#42454c, #e5e6e9); outline-offset: 3px; }
.btn-launch__count { display: inline-block; animation: btn-launch-pop 0.35s cubic-bezier(0.22, 1, 0.36, 1); font-variant-numeric: tabular-nums; }
.btn-launch__flame {
position: absolute;
left: 50%;
top: 100%;
width: 16px;
height: 44px;
margin-left: -8px;
border-radius: 50% 50% 50% 50% / 25% 25% 75% 75%;
background: linear-gradient(light-dark(#373a40, #f1f2f3), color-mix(in oklab, light-dark(#373a40, #f1f2f3) 40%, transparent) 45%, transparent);
filter: blur(1px);
transform-origin: top;
transform: scaleY(0);
transition: transform 0.25s ease;
pointer-events: none;
}
.btn-launch[data-phase='count'] .btn-launch__btn { animation: btn-launch-rumble 0.09s linear infinite; }
.btn-launch[data-phase='count'] .btn-launch__flame { transform: scaleY(0.3); animation: btn-launch-flicker 0.12s linear infinite; }
.btn-launch[data-phase='lift'] .btn-launch__btn { transform: translateY(-280px); }
.btn-launch[data-phase='lift'] .btn-launch__flame { transform: scaleY(1.5); animation: btn-launch-flicker 0.1s linear infinite; }
.btn-launch[data-phase='gone'] .btn-launch__btn { transform: translateY(-280px); transition: none; }
.btn-launch[data-phase='land'] .btn-launch__btn { transform: none; transition: transform 0.75s cubic-bezier(0.22, 1.25, 0.36, 1); }
.btn-launch__puff {
position: absolute;
left: 50%;
top: calc(50% + 30px);
width: 26px;
height: 26px;
border-radius: 50%;
background: radial-gradient(circle, color-mix(in oklab, light-dark(#50535b, #c6c8cd) 70%, transparent), transparent 70%);
animation: btn-launch-puff 1.1s cubic-bezier(0.22, 1, 0.36, 1) var(--delay) forwards;
opacity: 0;
pointer-events: none;
}
@media (prefers-reduced-motion: reduce) {
.btn-launch__btn, .btn-launch__flame, .btn-launch__puff, .btn-launch__count { animation: none !important; transition: none !important; }
.btn-launch__puff { display: none; }
}
`;
type Phase = 'idle' | 'count' | 'lift' | 'gone' | 'land' | 'done';
const PUFFS = Array.from({ length: 9 }, (_, i) => {
const a = Math.PI * (0.05 + (0.9 * i) / 8); // fanned along the ground, both sides
return { '--x': `${(Math.cos(a) * 70).toFixed(0)}px`, '--y': `${(-Math.sin(a) * 18 + 6).toFixed(0)}px`, '--delay': `${(i % 3) * 0.05}s` };
});
export function LaunchButton() {
const [phase, setPhase] = useState<Phase>('idle');
const [n, setN] = useState(3);
const timers = useRef<number[]>([]);
useEffect(() => () => timers.current.forEach(clearTimeout), []);
const at = (ms: number, fn: () => void) => timers.current.push(window.setTimeout(fn, ms));
const go = () => {
if (phase !== 'idle' && phase !== 'done') return;
setPhase('count');
setN(3);
at(600, () => setN(2));
at(1200, () => setN(1));
at(1800, () => setPhase('lift'));
at(2700, () => setPhase('gone'));
at(3300, () => setPhase('land'));
at(4100, () => setPhase('done'));
at(5600, () => setPhase('idle'));
};
const label = phase === 'count' ? null : phase === 'done' || phase === 'land' ? 'Launched' : 'Launch';
return (
<>
<style>
{css}
</style>
<div className="btn-launch" data-phase={phase}>
{phase === 'lift' &&
PUFFS.map((p, i) => <span key={i} className="btn-launch__puff" style={p as React.CSSProperties} aria-hidden="true" />)}
<button type="button" className="btn-launch__btn" onClick={go} aria-live="polite">
{label ?? (
<span key={n} className="btn-launch__count">
T-{n}
</span>
)}
<span className="btn-launch__flame" aria-hidden="true" />
</button>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initBtnLaunch(el) on the mounted element instead.
<div class="btn-launch" data-phase="idle">
<button type="button" class="btn-launch__btn" aria-live="polite">Launch<span class="btn-launch__flame" aria-hidden="true"></span></button>
</div>@keyframes btn-launch-rumble {
0% { translate: 0 0; } 25% { translate: -1px 1px; } 50% { translate: 1px 0; } 75% { translate: 0 -1px; } 100% { translate: 1px 1px; }
}
@keyframes btn-launch-flicker { 0%, 100% { scale: 1 1; } 50% { scale: 0.85 1.15; } }
@keyframes btn-launch-pop { from { transform: scale(1.6); opacity: 0; } to { transform: scale(1); opacity: 1; } }
@keyframes btn-launch-puff {
from { transform: translate(-50%, -50%) scale(0.3); opacity: 0.8; }
to { transform: translate(calc(-50% + var(--x)), calc(-50% + var(--y))) scale(1.6); opacity: 0; }
}
.btn-launch {
position: relative;
display: grid;
place-items: center;
width: 220px;
height: 150px;
}
.btn-launch__btn {
position: relative;
z-index: 1;
width: 132px;
height: 46px;
border: 1px solid light-dark(#cbced3, #393c41);
border-radius: 999px;
background: light-dark(#f3f4f7, #1a1c1f);
color: light-dark(#373a40, #f1f2f3);
font-family: inherit;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: transform 0.9s cubic-bezier(0.6, 0, 0.9, 0.45);
}
.btn-launch__btn:focus-visible { outline: 2px solid light-dark(#42454c, #e5e6e9); outline-offset: 3px; }
.btn-launch__count { display: inline-block; animation: btn-launch-pop 0.35s cubic-bezier(0.22, 1, 0.36, 1); font-variant-numeric: tabular-nums; }
.btn-launch__flame {
position: absolute;
left: 50%;
top: 100%;
width: 16px;
height: 44px;
margin-left: -8px;
border-radius: 50% 50% 50% 50% / 25% 25% 75% 75%;
background: linear-gradient(light-dark(#373a40, #f1f2f3), color-mix(in oklab, light-dark(#373a40, #f1f2f3) 40%, transparent) 45%, transparent);
filter: blur(1px);
transform-origin: top;
transform: scaleY(0);
transition: transform 0.25s ease;
pointer-events: none;
}
.btn-launch[data-phase='count'] .btn-launch__btn { animation: btn-launch-rumble 0.09s linear infinite; }
.btn-launch[data-phase='count'] .btn-launch__flame { transform: scaleY(0.3); animation: btn-launch-flicker 0.12s linear infinite; }
.btn-launch[data-phase='lift'] .btn-launch__btn { transform: translateY(-280px); }
.btn-launch[data-phase='lift'] .btn-launch__flame { transform: scaleY(1.5); animation: btn-launch-flicker 0.1s linear infinite; }
.btn-launch[data-phase='gone'] .btn-launch__btn { transform: translateY(-280px); transition: none; }
.btn-launch[data-phase='land'] .btn-launch__btn { transform: none; transition: transform 0.75s cubic-bezier(0.22, 1.25, 0.36, 1); }
.btn-launch__puff {
position: absolute;
left: 50%;
top: calc(50% + 30px);
width: 26px;
height: 26px;
border-radius: 50%;
background: radial-gradient(circle, color-mix(in oklab, light-dark(#50535b, #c6c8cd) 70%, transparent), transparent 70%);
animation: btn-launch-puff 1.1s cubic-bezier(0.22, 1, 0.36, 1) var(--delay) forwards;
opacity: 0;
pointer-events: none;
}
@media (prefers-reduced-motion: reduce) {
.btn-launch__btn, .btn-launch__flame, .btn-launch__puff, .btn-launch__count { animation: none !important; transition: none !important; }
.btn-launch__puff { display: none; }
}function initBtnLaunch(root) {
var btn = root.querySelector('.btn-launch__btn');
var flame = root.querySelector('.btn-launch__flame');
if (!btn || !flame) return;
var phase = 'idle';
var timers = [];
function at(ms, fn) { timers.push(setTimeout(fn, ms)); }
function setPhase(p) {
phase = p;
root.setAttribute('data-phase', p);
root.querySelectorAll('.btn-launch__puff').forEach(function (el) { el.remove(); });
if (p === 'lift') puffs();
if (p !== 'count') label(p === 'done' || p === 'land' ? 'Launched' : 'Launch');
}
function label(text) {
btn.textContent = text;
btn.appendChild(flame);
}
function count(n) {
var s = document.createElement('span');
s.className = 'btn-launch__count';
s.textContent = 'T-' + n;
btn.textContent = '';
btn.appendChild(s);
btn.appendChild(flame);
}
// Smoke fanned along the ground on both sides.
function puffs() {
for (var i = 0; i < 9; i++) {
var a = Math.PI * (0.05 + (0.9 * i) / 8);
var p = document.createElement('span');
p.className = 'btn-launch__puff';
p.setAttribute('aria-hidden', 'true');
p.style.setProperty('--x', (Math.cos(a) * 70).toFixed(0) + 'px');
p.style.setProperty('--y', (-Math.sin(a) * 18 + 6).toFixed(0) + 'px');
p.style.setProperty('--delay', (i % 3) * 0.05 + 's');
root.insertBefore(p, btn);
}
}
function go() {
if (phase !== 'idle' && phase !== 'done') return;
setPhase('count');
count(3);
at(600, function () { count(2); });
at(1200, function () { count(1); });
at(1800, function () { setPhase('lift'); });
at(2700, function () { setPhase('gone'); });
at(3300, function () { setPhase('land'); });
at(4100, function () { setPhase('done'); });
at(5600, function () { setPhase('idle'); });
}
btn.addEventListener('click', go);
return function () {
timers.forEach(clearTimeout);
btn.removeEventListener('click', go);
};
}
document.querySelectorAll('.btn-launch').forEach(initBtnLaunch);Goal: Create a "Launch" button.
Visual behavior: Click to launch: the button counts down T-3, T-2, T-1 while it rumbles and its engine flickers, then lifts off with a full flame and smoke puffs fanning along the ground, drops back in and lands with a bounce.
Technique:
- A phase attribute on the wrapper (idle, count, lift, gone, land, done) drives every style; a timeline of timeouts steps through it.
- Count: the button shakes on a tiny translate keyframe and a small flame under it flickers; each number pops in with a scale keyframe.
- Lift: the button transitions 280px up on an ease-in curve with the flame at full length, and nine puffs (positions in CSS variables) spread out along the ground and fade.
- Gone snaps it back below without a transition; land eases it into place on an overshooting curve, then the label reads Launched.
Accessibility: A real <button> whose label is an aria-live region, so the countdown and Launched are announced. Focus outline. Under prefers-reduced-motion nothing moves; the labels still change.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: neutral only. Black, white and grays, no hues. Use the color values above as written, or my project's closest neutral tokens.
Return a single self-contained component. No dependencies.Goal: Create a "Launch" button.
Visual behavior: Click to launch: the button counts down T-3, T-2, T-1 while it rumbles and its engine flickers, then lifts off with a full flame and smoke puffs fanning along the ground, drops back in and lands with a bounce.
Technique:
- A phase attribute on the wrapper (idle, count, lift, gone, land, done) drives every style; a timeline of timeouts steps through it.
- Count: the button shakes on a tiny translate keyframe and a small flame under it flickers; each number pops in with a scale keyframe.
- Lift: the button transitions 280px up on an ease-in curve with the flame at full length, and nine puffs (positions in CSS variables) spread out along the ground and fade.
- Gone snaps it back below without a transition; land eases it into place on an overshooting curve, then the label reads Launched.
Accessibility: A real <button> whose label is an aria-live region, so the countdown and Launched are announced. Focus outline. Under prefers-reduced-motion nothing moves; the labels still change.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: use my project's existing color tokens. The color values above are grayscale placeholders. Map each one by its role (surface, text, border, accent, glow) to my theme, and keep their relative lightness so contrast and the effect still read.
Return a single self-contained component. No dependencies.Paste into Claude, ChatGPT or Cursor after filling in {{USER_STACK}}, {{USER_STYLING}} and {{USER_CONSTRAINTS}}. Neutral keeps the greys you see here; Match my project asks the model to map them onto your own colour tokens.
Free to use in any project, commercial included. MIT-0, no attribution required. Licence
About this component
Click to launch: the button counts down T-3, T-2, T-1 while it rumbles and its engine flickers, then lifts off with a full flame and smoke puffs fanning along the ground, drops back in and lands with a bounce.
- Dependencies
- None
- Difficulty
- Advanced
- Triggers
- click, state change, spring
- Techniques
- state machine, keyframes, css transition, css variables
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
A real <button> whose label is an aria-live region, so the countdown and Launched are announced. Focus outline. Under prefers-reduced-motion nothing moves; the labels still change.