Black Hole
import { useLayoutEffect, useRef } from 'react';
const css = `
@keyframes btn-black-hole-disk {
from { transform: rotateX(72deg) rotate(0deg); }
to { transform: rotateX(72deg) rotate(360deg); }
}
/* The singularity flares as the last letters fall in, then gives up the new word. */
@keyframes btn-black-hole-core {
0% { transform: scale(0); }
35%, 70% { transform: scale(1); }
100% { transform: scale(0); }
}
.btn-black-hole {
position: relative;
display: grid;
place-items: center;
width: 196px;
height: 56px;
padding: 0;
border: 1px solid light-dark(#dbdde1, #2e3035);
border-radius: 999px;
background: light-dark(#f3f4f7, #1a1c1f);
color: light-dark(#373a40, #f1f2f3);
font-family: inherit;
font-size: 15px;
font-weight: 600;
letter-spacing: 0.06em;
cursor: pointer;
perspective: 400px;
transition: border-color 0.4s ease, background-color 0.6s ease;
}
.btn-black-hole:focus-visible { outline: 2px solid light-dark(#42454c, #e5e6e9); outline-offset: 3px; }
.btn-black-hole__word { display: flex; }
.btn-black-hole__ch {
display: inline-block;
/* Orbit the singularity, not the letter's own centre: scaling about that point pulls it in. */
transform-origin: calc(var(--dx, 0px) + 50%) 50%;
transition:
transform 0.75s cubic-bezier(0.55, 0, 0.9, 0.4) var(--d, 0s),
opacity 0.3s ease calc(var(--d, 0s) + 0.45s);
}
.btn-black-hole__disk {
position: absolute;
left: 50%;
top: 50%;
width: 86px;
height: 86px;
margin: -43px 0 0 -43px;
border-radius: 50%;
background: conic-gradient(from 0deg, transparent, color-mix(in oklab, light-dark(#373a40, #f1f2f3) 70%, transparent), transparent 40%, color-mix(in oklab, light-dark(#373a40, #f1f2f3) 45%, transparent) 70%, transparent);
mask-image: radial-gradient(circle, transparent 0 34%, #000 36% 62%, transparent 70%);
transform: rotateX(72deg);
scale: 0;
opacity: 0;
transition: opacity 0.4s ease, scale 0.6s cubic-bezier(0.22, 1, 0.36, 1);
pointer-events: none;
}
.btn-black-hole__core {
position: absolute;
left: 50%;
top: 50%;
width: 16px;
height: 16px;
margin: -8px 0 0 -8px;
border-radius: 50%;
background: light-dark(#fbfcfd, #121417);
box-shadow: 0 0 0 1px light-dark(#50535b, #c6c8cd), 0 0 14px 3px color-mix(in oklab, light-dark(#373a40, #f1f2f3) 35%, transparent);
transform: scale(0);
pointer-events: none;
}
.btn-black-hole__next {
position: absolute;
opacity: 0;
transform: scale(0.2);
letter-spacing: 0.3em;
transition: opacity 0.4s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1), letter-spacing 0.8s ease;
}
.btn-black-hole:hover, .btn-black-hole:focus-visible { border-color: light-dark(#6d7179, #7d8087); background: light-dark(#fbfcfd, #121417); }
.btn-black-hole:hover .btn-black-hole__ch,
.btn-black-hole:focus-visible .btn-black-hole__ch { transform: rotate(560deg) scale(0); opacity: 0; }
.btn-black-hole:hover .btn-black-hole__disk,
.btn-black-hole:focus-visible .btn-black-hole__disk { opacity: 0.55; scale: 1; animation: btn-black-hole-disk 1.6s linear infinite; }
.btn-black-hole:hover .btn-black-hole__core,
.btn-black-hole:focus-visible .btn-black-hole__core { animation: btn-black-hole-core 1.25s cubic-bezier(0.34, 1.56, 0.64, 1) both; }
.btn-black-hole:hover .btn-black-hole__next,
.btn-black-hole:focus-visible .btn-black-hole__next {
opacity: 1;
transform: none;
letter-spacing: 0.12em;
transition-delay: 0.95s;
}
@media (prefers-reduced-motion: reduce) {
.btn-black-hole__ch, .btn-black-hole__disk, .btn-black-hole__core, .btn-black-hole__next { transition: opacity 0.2s ease; }
.btn-black-hole:hover .btn-black-hole__ch { transform: none; }
.btn-black-hole:hover .btn-black-hole__disk, .btn-black-hole:hover .btn-black-hole__core { animation: none; }
}
`;
const WORD = 'Gravity';
export function BlackHoleButton() {
const word = useRef<HTMLSpanElement>(null);
// Each letter's distance to the button's centre: its pivot, and how long it lags.
useLayoutEffect(() => {
const el = word.current!;
const mid = el.offsetWidth / 2;
el.querySelectorAll<HTMLElement>('.btn-black-hole__ch').forEach((ch) => {
const dx = mid - (ch.offsetLeft + ch.offsetWidth / 2);
ch.style.setProperty('--dx', `${dx.toFixed(1)}px`);
ch.style.setProperty('--d', `${(Math.abs(dx) / 260).toFixed(3)}s`);
});
}, []);
return (
<>
<style>
{css}
</style>
<button type="button" className="btn-black-hole" aria-label="Enter">
<span className="btn-black-hole__disk" aria-hidden="true" />
<span className="btn-black-hole__core" aria-hidden="true" />
<span ref={word} className="btn-black-hole__word" aria-hidden="true">
{[...WORD].map((c, i) => (
<span key={i} className="btn-black-hole__ch">
{c}
</span>
))}
</span>
<span className="btn-black-hole__next" aria-hidden="true">
Enter
</span>
</button>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initBtnBlackHole(el) on the mounted element instead.
<button type="button" class="btn-black-hole" aria-label="Enter">
<span class="btn-black-hole__disk" aria-hidden="true"></span>
<span class="btn-black-hole__core" aria-hidden="true"></span>
<span class="btn-black-hole__word" aria-hidden="true">
<span class="btn-black-hole__ch">G</span>
<span class="btn-black-hole__ch">r</span>
<span class="btn-black-hole__ch">a</span>
<span class="btn-black-hole__ch">v</span>
<span class="btn-black-hole__ch">i</span>
<span class="btn-black-hole__ch">t</span>
<span class="btn-black-hole__ch">y</span>
</span>
<span class="btn-black-hole__next" aria-hidden="true">Enter</span>
</button>@keyframes btn-black-hole-disk {
from { transform: rotateX(72deg) rotate(0deg); }
to { transform: rotateX(72deg) rotate(360deg); }
}
/* The singularity flares as the last letters fall in, then gives up the new word. */
@keyframes btn-black-hole-core {
0% { transform: scale(0); }
35%, 70% { transform: scale(1); }
100% { transform: scale(0); }
}
.btn-black-hole {
position: relative;
display: grid;
place-items: center;
width: 196px;
height: 56px;
padding: 0;
border: 1px solid light-dark(#dbdde1, #2e3035);
border-radius: 999px;
background: light-dark(#f3f4f7, #1a1c1f);
color: light-dark(#373a40, #f1f2f3);
font-family: inherit;
font-size: 15px;
font-weight: 600;
letter-spacing: 0.06em;
cursor: pointer;
perspective: 400px;
transition: border-color 0.4s ease, background-color 0.6s ease;
}
.btn-black-hole:focus-visible { outline: 2px solid light-dark(#42454c, #e5e6e9); outline-offset: 3px; }
.btn-black-hole__word { display: flex; }
.btn-black-hole__ch {
display: inline-block;
/* Orbit the singularity, not the letter's own centre: scaling about that point pulls it in. */
transform-origin: calc(var(--dx, 0px) + 50%) 50%;
transition:
transform 0.75s cubic-bezier(0.55, 0, 0.9, 0.4) var(--d, 0s),
opacity 0.3s ease calc(var(--d, 0s) + 0.45s);
}
.btn-black-hole__disk {
position: absolute;
left: 50%;
top: 50%;
width: 86px;
height: 86px;
margin: -43px 0 0 -43px;
border-radius: 50%;
background: conic-gradient(from 0deg, transparent, color-mix(in oklab, light-dark(#373a40, #f1f2f3) 70%, transparent), transparent 40%, color-mix(in oklab, light-dark(#373a40, #f1f2f3) 45%, transparent) 70%, transparent);
mask-image: radial-gradient(circle, transparent 0 34%, #000 36% 62%, transparent 70%);
transform: rotateX(72deg);
scale: 0;
opacity: 0;
transition: opacity 0.4s ease, scale 0.6s cubic-bezier(0.22, 1, 0.36, 1);
pointer-events: none;
}
.btn-black-hole__core {
position: absolute;
left: 50%;
top: 50%;
width: 16px;
height: 16px;
margin: -8px 0 0 -8px;
border-radius: 50%;
background: light-dark(#fbfcfd, #121417);
box-shadow: 0 0 0 1px light-dark(#50535b, #c6c8cd), 0 0 14px 3px color-mix(in oklab, light-dark(#373a40, #f1f2f3) 35%, transparent);
transform: scale(0);
pointer-events: none;
}
.btn-black-hole__next {
position: absolute;
opacity: 0;
transform: scale(0.2);
letter-spacing: 0.3em;
transition: opacity 0.4s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1), letter-spacing 0.8s ease;
}
.btn-black-hole:hover, .btn-black-hole:focus-visible { border-color: light-dark(#6d7179, #7d8087); background: light-dark(#fbfcfd, #121417); }
.btn-black-hole:hover .btn-black-hole__ch,
.btn-black-hole:focus-visible .btn-black-hole__ch { transform: rotate(560deg) scale(0); opacity: 0; }
.btn-black-hole:hover .btn-black-hole__disk,
.btn-black-hole:focus-visible .btn-black-hole__disk { opacity: 0.55; scale: 1; animation: btn-black-hole-disk 1.6s linear infinite; }
.btn-black-hole:hover .btn-black-hole__core,
.btn-black-hole:focus-visible .btn-black-hole__core { animation: btn-black-hole-core 1.25s cubic-bezier(0.34, 1.56, 0.64, 1) both; }
.btn-black-hole:hover .btn-black-hole__next,
.btn-black-hole:focus-visible .btn-black-hole__next {
opacity: 1;
transform: none;
letter-spacing: 0.12em;
transition-delay: 0.95s;
}
@media (prefers-reduced-motion: reduce) {
.btn-black-hole__ch, .btn-black-hole__disk, .btn-black-hole__core, .btn-black-hole__next { transition: opacity 0.2s ease; }
.btn-black-hole:hover .btn-black-hole__ch { transform: none; }
.btn-black-hole:hover .btn-black-hole__disk, .btn-black-hole:hover .btn-black-hole__core { animation: none; }
}function initBtnBlackHole(root) {
var word = root.querySelector('.btn-black-hole__word');
if (!word) return;
// Each letter's distance to the centre: its pivot, and how long it lags before falling in.
var mid = word.offsetWidth / 2;
word.querySelectorAll('.btn-black-hole__ch').forEach(function (ch) {
var dx = mid - (ch.offsetLeft + ch.offsetWidth / 2);
ch.style.setProperty('--dx', dx.toFixed(1) + 'px');
ch.style.setProperty('--d', (Math.abs(dx) / 260).toFixed(3) + 's');
});
}
document.querySelectorAll('.btn-black-hole').forEach(initBtnBlackHole);Goal: Create a "Black Hole" button.
Visual behavior: Hover and the label spirals into a singularity: each letter orbits into the centre, the far ones last, while an accretion disc spins up in 3D. The core flares and a new word emerges from it.
Technique:
- Each letter is its own span whose transform-origin is the button centre (an offset --dx measured after layout), so rotate(560deg) scale(0) pulls it round and into the middle.
- Each letter also gets a delay proportional to its distance from the centre, so the outer letters fall in last.
- The disc is a conic-gradient ring (radial mask) tilted rotateX(72deg) that scales in and spins; the core flares on a one-shot keyframe.
- The replacement word fades and scales in after a 0.95s delay, its letter-spacing settling as it arrives.
Accessibility: A real <button> named by aria-label; the animated letters are aria-hidden. Focus plays it like hover. Under prefers-reduced-motion it simply crossfades the words.
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 "Black Hole" button.
Visual behavior: Hover and the label spirals into a singularity: each letter orbits into the centre, the far ones last, while an accretion disc spins up in 3D. The core flares and a new word emerges from it.
Technique:
- Each letter is its own span whose transform-origin is the button centre (an offset --dx measured after layout), so rotate(560deg) scale(0) pulls it round and into the middle.
- Each letter also gets a delay proportional to its distance from the centre, so the outer letters fall in last.
- The disc is a conic-gradient ring (radial mask) tilted rotateX(72deg) that scales in and spins; the core flares on a one-shot keyframe.
- The replacement word fades and scales in after a 0.95s delay, its letter-spacing settling as it arrives.
Accessibility: A real <button> named by aria-label; the animated letters are aria-hidden. Focus plays it like hover. Under prefers-reduced-motion it simply crossfades the words.
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
Hover and the label spirals into a singularity: each letter orbits into the centre, the far ones last, while an accretion disc spins up in 3D. The core flares and a new word emerges from it.
- Dependencies
- None
- Difficulty
- Advanced
- Triggers
- hover, 3d, morph, delay stagger
- Techniques
- css variables, conic gradient, keyframes, transition delay stagger, perspective
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
A real <button> named by aria-label; the animated letters are aria-hidden. Focus plays it like hover. Under prefers-reduced-motion it simply crossfades the words.