Reactor
import { useRef, useState } from 'react';
const css = `
@keyframes btn-reactor-cw { to { transform: rotate(360deg); } }
@keyframes btn-reactor-ccw { to { transform: rotate(-360deg); } }
@keyframes btn-reactor-breathe {
0%, 100% { box-shadow: 0 0 14px color-mix(in oklab, light-dark(#373a40, #f1f2f3) 30%, transparent), inset 0 0 10px color-mix(in oklab, light-dark(#373a40, #f1f2f3) 30%, transparent); }
50% { box-shadow: 0 0 26px color-mix(in oklab, light-dark(#373a40, #f1f2f3) 50%, transparent), inset 0 0 14px color-mix(in oklab, light-dark(#373a40, #f1f2f3) 45%, transparent); }
}
@keyframes btn-reactor-ignite {
from { transform: scale(0.6); opacity: 0.9; }
to { transform: scale(1.9); opacity: 0; }
}
.btn-reactor {
position: relative;
display: grid;
place-items: center;
width: 136px;
height: 136px;
padding: 0;
border: none;
border-radius: 50%;
background: light-dark(#fbfcfd, #121417);
color: light-dark(#373a40, #f1f2f3);
font-family: inherit;
cursor: pointer;
}
.btn-reactor:focus-visible { outline: 2px solid light-dark(#42454c, #e5e6e9); outline-offset: 4px; }
.btn-reactor__ring {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
overflow: visible;
}
.btn-reactor__ring circle { fill: none; }
.btn-reactor__outer { animation: btn-reactor-cw 14s linear infinite; }
.btn-reactor__mid { animation: btn-reactor-ccw 9s linear infinite; }
.btn-reactor__inner { animation: btn-reactor-cw 5s linear infinite; }
.btn-reactor__core {
position: relative;
display: grid;
place-items: center;
width: 52px;
height: 52px;
border-radius: 50%;
border: 1px solid light-dark(#50535b, #c6c8cd);
background: radial-gradient(circle, color-mix(in oklab, light-dark(#373a40, #f1f2f3) 40%, transparent), light-dark(#f3f4f7, #1a1c1f) 70%);
font-size: 12px;
font-weight: 600;
letter-spacing: 0.16em;
animation: btn-reactor-breathe 2.4s ease-in-out infinite;
}
.btn-reactor[aria-pressed='true'] .btn-reactor__core {
background: radial-gradient(circle, light-dark(#373a40, #f1f2f3) 0 30%, color-mix(in oklab, light-dark(#373a40, #f1f2f3) 40%, transparent) 60%, light-dark(#f3f4f7, #1a1c1f));
color: light-dark(#fbfcfd, #121417);
}
.btn-reactor__flash {
position: absolute;
inset: 0;
border-radius: 50%;
border: 2px solid light-dark(#373a40, #f1f2f3);
animation: btn-reactor-ignite 0.7s cubic-bezier(0.22, 1, 0.36, 1) forwards;
pointer-events: none;
}
@media (prefers-reduced-motion: reduce) {
.btn-reactor__outer, .btn-reactor__mid, .btn-reactor__inner, .btn-reactor__core { animation: none; }
.btn-reactor__flash { display: none; }
}
`;
const C = 68; // centre of the 136px viewBox
export function ReactorButton() {
const root = useRef<HTMLButtonElement>(null);
const [on, setOn] = useState(false);
const [flash, setFlash] = useState(0);
// Spin up / down without a jump: the ring animations keep running and only their rate changes.
const rate = (r: number) =>
root.current?.querySelectorAll('svg').forEach((s) => s.getAnimations().forEach((a) => a.updatePlaybackRate(r)));
return (
<>
<style>
{css}
</style>
<button
ref={root}
type="button"
className="btn-reactor"
aria-pressed={on}
aria-label={on ? 'Core online' : 'Ignite core'}
onPointerEnter={() => rate(on ? 6 : 3)}
onPointerLeave={() => rate(on ? 2 : 1)}
onClick={() => {
setOn((v) => !v);
setFlash((n) => n + 1);
rate(on ? 3 : 6);
}}
>
<svg className="btn-reactor__ring btn-reactor__outer" viewBox="0 0 136 136" aria-hidden="true">
<circle cx={C} cy={C} r={64} stroke="light-dark(#cbced3, #393c41)" strokeWidth={1} />
<circle cx={C} cy={C} r={58} stroke="light-dark(#5f626a, #a7abb2)" strokeWidth={5} strokeDasharray="2 7.1" />
</svg>
<svg className="btn-reactor__ring btn-reactor__mid" viewBox="0 0 136 136" aria-hidden="true">
<circle cx={C} cy={C} r={46} stroke="light-dark(#50535b, #c6c8cd)" strokeWidth={3} strokeDasharray="34 14.2" />
</svg>
<svg className="btn-reactor__ring btn-reactor__inner" viewBox="0 0 136 136" aria-hidden="true">
<circle cx={C} cy={C} r={36} stroke="light-dark(#6d7179, #7d8087)" strokeWidth={1.5} strokeDasharray="1 5" />
<circle cx={C} cy={C} r={36} stroke="light-dark(#373a40, #f1f2f3)" strokeWidth={2} strokeDasharray="20 206.2" strokeLinecap="round" />
</svg>
<span className="btn-reactor__core">{on ? 'ON' : 'OFF'}</span>
{flash > 0 && <span key={flash} className="btn-reactor__flash" aria-hidden="true" />}
</button>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initBtnReactor(el) on the mounted element instead.
<button type="button" class="btn-reactor" aria-pressed="false" aria-label="Ignite core">
<svg class="btn-reactor__ring btn-reactor__outer" viewBox="0 0 136 136" aria-hidden="true">
<circle cx="68" cy="68" r="64" stroke="light-dark(#cbced3, #393c41)" stroke-width="1"></circle>
<circle cx="68" cy="68" r="58" stroke="light-dark(#5f626a, #a7abb2)" stroke-width="5" stroke-dasharray="2 7.1"></circle>
</svg>
<svg class="btn-reactor__ring btn-reactor__mid" viewBox="0 0 136 136" aria-hidden="true">
<circle cx="68" cy="68" r="46" stroke="light-dark(#50535b, #c6c8cd)" stroke-width="3" stroke-dasharray="34 14.2"></circle>
</svg>
<svg class="btn-reactor__ring btn-reactor__inner" viewBox="0 0 136 136" aria-hidden="true">
<circle cx="68" cy="68" r="36" stroke="light-dark(#6d7179, #7d8087)" stroke-width="1.5" stroke-dasharray="1 5"></circle>
<circle cx="68" cy="68" r="36" stroke="light-dark(#373a40, #f1f2f3)" stroke-width="2" stroke-dasharray="20 206.2" stroke-linecap="round"></circle>
</svg>
<span class="btn-reactor__core">OFF</span>
</button>@keyframes btn-reactor-cw { to { transform: rotate(360deg); } }
@keyframes btn-reactor-ccw { to { transform: rotate(-360deg); } }
@keyframes btn-reactor-breathe {
0%, 100% { box-shadow: 0 0 14px color-mix(in oklab, light-dark(#373a40, #f1f2f3) 30%, transparent), inset 0 0 10px color-mix(in oklab, light-dark(#373a40, #f1f2f3) 30%, transparent); }
50% { box-shadow: 0 0 26px color-mix(in oklab, light-dark(#373a40, #f1f2f3) 50%, transparent), inset 0 0 14px color-mix(in oklab, light-dark(#373a40, #f1f2f3) 45%, transparent); }
}
@keyframes btn-reactor-ignite {
from { transform: scale(0.6); opacity: 0.9; }
to { transform: scale(1.9); opacity: 0; }
}
.btn-reactor {
position: relative;
display: grid;
place-items: center;
width: 136px;
height: 136px;
padding: 0;
border: none;
border-radius: 50%;
background: light-dark(#fbfcfd, #121417);
color: light-dark(#373a40, #f1f2f3);
font-family: inherit;
cursor: pointer;
}
.btn-reactor:focus-visible { outline: 2px solid light-dark(#42454c, #e5e6e9); outline-offset: 4px; }
.btn-reactor__ring {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
overflow: visible;
}
.btn-reactor__ring circle { fill: none; }
.btn-reactor__outer { animation: btn-reactor-cw 14s linear infinite; }
.btn-reactor__mid { animation: btn-reactor-ccw 9s linear infinite; }
.btn-reactor__inner { animation: btn-reactor-cw 5s linear infinite; }
.btn-reactor__core {
position: relative;
display: grid;
place-items: center;
width: 52px;
height: 52px;
border-radius: 50%;
border: 1px solid light-dark(#50535b, #c6c8cd);
background: radial-gradient(circle, color-mix(in oklab, light-dark(#373a40, #f1f2f3) 40%, transparent), light-dark(#f3f4f7, #1a1c1f) 70%);
font-size: 12px;
font-weight: 600;
letter-spacing: 0.16em;
animation: btn-reactor-breathe 2.4s ease-in-out infinite;
}
.btn-reactor[aria-pressed='true'] .btn-reactor__core {
background: radial-gradient(circle, light-dark(#373a40, #f1f2f3) 0 30%, color-mix(in oklab, light-dark(#373a40, #f1f2f3) 40%, transparent) 60%, light-dark(#f3f4f7, #1a1c1f));
color: light-dark(#fbfcfd, #121417);
}
.btn-reactor__flash {
position: absolute;
inset: 0;
border-radius: 50%;
border: 2px solid light-dark(#373a40, #f1f2f3);
animation: btn-reactor-ignite 0.7s cubic-bezier(0.22, 1, 0.36, 1) forwards;
pointer-events: none;
}
@media (prefers-reduced-motion: reduce) {
.btn-reactor__outer, .btn-reactor__mid, .btn-reactor__inner, .btn-reactor__core { animation: none; }
.btn-reactor__flash { display: none; }
}function initBtnReactor(root) {
var core = root.querySelector('.btn-reactor__core');
if (!core) return;
var on = root.getAttribute('aria-pressed') === 'true';
// Spin up or down without a jump: the ring animations keep running and only their rate changes.
function rate(r) {
root.querySelectorAll('svg').forEach(function (s) {
s.getAnimations().forEach(function (a) { a.updatePlaybackRate(r); });
});
}
function enter() { rate(on ? 6 : 3); }
function leave() { rate(on ? 2 : 1); }
function click() {
rate(on ? 3 : 6);
on = !on;
root.setAttribute('aria-pressed', String(on));
root.setAttribute('aria-label', on ? 'Core online' : 'Ignite core');
core.textContent = on ? 'ON' : 'OFF';
if (matchMedia('(prefers-reduced-motion: reduce)').matches) return;
var flash = document.createElement('span');
flash.className = 'btn-reactor__flash';
flash.setAttribute('aria-hidden', 'true');
flash.addEventListener('animationend', function () { flash.remove(); });
root.appendChild(flash);
}
root.addEventListener('pointerenter', enter);
root.addEventListener('pointerleave', leave);
root.addEventListener('click', click);
return function () {
root.removeEventListener('pointerenter', enter);
root.removeEventListener('pointerleave', leave);
root.removeEventListener('click', click);
};
}
document.querySelectorAll('.btn-reactor').forEach(initBtnReactor);Goal: Create a "Reactor" button.
Visual behavior: A round reactor core as a toggle button: three segmented rings counter-rotate around a breathing core. Hover spins the rings up without a jump; a click ignites the core with a flash ring and keeps it running faster while it is on.
Technique:
- Three stacked SVG rings, each one circle or two with a stroke-dasharray that cuts it into segments, rotate on their own keyframes (14s, 9s and 5s, alternating direction).
- The core is a radial-gradient disc whose glow breathes on a 2.4s box-shadow keyframe; on, the gradient floods to a bright centre and the label flips from OFF to ON.
- Speed changes never restart the rings: getAnimations() on each SVG and updatePlaybackRate() (3x on hover, 6x when on and hovered, 2x on).
- A click toggles aria-pressed and mounts a one-shot ring that scales from 0.6 to 1.9 while it fades.
Accessibility: A real <button> with aria-pressed and a label that says what it will do ("Ignite core" / "Core online"). Focus outline. Under prefers-reduced-motion the rings, breathing and flash stop.
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 "Reactor" button.
Visual behavior: A round reactor core as a toggle button: three segmented rings counter-rotate around a breathing core. Hover spins the rings up without a jump; a click ignites the core with a flash ring and keeps it running faster while it is on.
Technique:
- Three stacked SVG rings, each one circle or two with a stroke-dasharray that cuts it into segments, rotate on their own keyframes (14s, 9s and 5s, alternating direction).
- The core is a radial-gradient disc whose glow breathes on a 2.4s box-shadow keyframe; on, the gradient floods to a bright centre and the label flips from OFF to ON.
- Speed changes never restart the rings: getAnimations() on each SVG and updatePlaybackRate() (3x on hover, 6x when on and hovered, 2x on).
- A click toggles aria-pressed and mounts a one-shot ring that scales from 0.6 to 1.9 while it fades.
Accessibility: A real <button> with aria-pressed and a label that says what it will do ("Ignite core" / "Core online"). Focus outline. Under prefers-reduced-motion the rings, breathing and flash stop.
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
A round reactor core as a toggle button: three segmented rings counter-rotate around a breathing core. Hover spins the rings up without a jump; a click ignites the core with a flash ring and keeps it running faster while it is on.
- Dependencies
- None
- Difficulty
- Advanced
- Triggers
- hover, click, state change, continuous, glow
- Techniques
- keyframes, svg, radial gradient, state machine
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
A real <button> with aria-pressed and a label that says what it will do ("Ignite core" / "Core online"). Focus outline. Under prefers-reduced-motion the rings, breathing and flash stop.