Match
import { useState } from 'react';
const css = `
.toggle-match { position: relative; display: inline-flex; align-items: center; gap: 14px; color: light-dark(#42454c, #e5e6e9); font-size: 14px; cursor: pointer; user-select: none; }
.toggle-match__input { position: absolute; width: 1px; height: 1px; margin: 0; opacity: 0; }
.toggle-match__scene { position: relative; flex: none; }
.toggle-match__input:focus-visible + .toggle-match__scene { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 4px; border-radius: 8px; }
@keyframes toggle-match-strike { 0% { transform: translateX(0) rotate(-35deg); } 40% { transform: translateX(26px) rotate(-35deg); } 100% { transform: translateX(26px) rotate(-12deg); } }
@keyframes toggle-match-catch { 0%, 38% { transform: scale(0); opacity: 0; } 50% { transform: scale(1.5, 1.2); opacity: 1; } 100% { transform: none; opacity: 1; } }
@keyframes toggle-match-flicker { 0%, 100% { transform: scale(1) rotate(-3deg); } 50% { transform: scale(0.92, 1.08) rotate(3deg); } }
@keyframes toggle-match-spark { 0% { transform: translate(0, 0); opacity: 1; } 100% { transform: translate(var(--x), var(--y)); opacity: 0; } }
@keyframes toggle-match-smoke { 0% { stroke-dashoffset: 1; opacity: 0.8; } 100% { stroke-dashoffset: 0; opacity: 0; transform: translateY(-8px); } }
.toggle-match__scene { width: 76px; height: 52px; }
.toggle-match__strip { position: absolute; left: 4px; bottom: 6px; width: 46px; height: 8px; border-radius: 2px; background: repeating-linear-gradient(90deg, light-dark(#cbced3, #393c41) 0 2px, light-dark(#6d7179, #7d8087) 2px 3px); }
.toggle-match__stick { position: absolute; left: 6px; bottom: 16px; width: 40px; height: 4px; border-radius: 2px; background: light-dark(#50535b, #c6c8cd); transform-origin: 100% 50%; transform: rotate(-12deg) translateX(26px); }
.toggle-match__stick::before { content: ''; position: absolute; left: -5px; top: -2.5px; width: 9px; height: 9px; border-radius: 50%; background: light-dark(#6d7179, #7d8087); transition: background-color 0.3s 0.3s; }
.toggle-match__flame { position: absolute; left: -8px; top: -20px; width: 12px; height: 20px; transform-origin: 50% 100%; opacity: 0; }
.toggle-match__flame span { position: absolute; inset: 0; border-radius: 50% 50% 50% 50% / 62% 62% 38% 38%; background: radial-gradient(ellipse at 50% 74%, light-dark(#fbfcfd, #121417) 0 18%, light-dark(#373a40, #f1f2f3) 45%, light-dark(#50535b, #c6c8cd)); animation: toggle-match-flicker 0.7s ease-in-out infinite; }
.toggle-match__spark { position: absolute; left: -2px; top: 0; width: 2px; height: 2px; border-radius: 50%; background: light-dark(#373a40, #f1f2f3); opacity: 0; }
.toggle-match__smoke { position: absolute; left: -12px; top: -30px; width: 16px; height: 28px; overflow: visible; }
.toggle-match__smoke path { fill: none; stroke: light-dark(#6d7179, #7d8087); stroke-width: 1.2; stroke-linecap: round; stroke-dasharray: 1; stroke-dashoffset: 1; opacity: 0; }
.toggle-match__input:checked + .toggle-match__scene .toggle-match__flame { opacity: 1; }
.toggle-match__input:checked + .toggle-match__scene .toggle-match__stick::before { background: light-dark(#dbdde1, #2e3035); }
.toggle-match[data-armed] .toggle-match__input:checked + .toggle-match__scene .toggle-match__stick { animation: toggle-match-strike 0.5s cubic-bezier(0.5, 0, 0.3, 1) both; }
.toggle-match[data-armed] .toggle-match__input:checked + .toggle-match__scene .toggle-match__flame { animation: toggle-match-catch 0.6s ease-out both; }
.toggle-match[data-armed] .toggle-match__input:checked + .toggle-match__scene .toggle-match__spark { animation: toggle-match-spark 0.4s ease-out 0.18s; }
.toggle-match[data-armed] .toggle-match__input:not(:checked) + .toggle-match__scene .toggle-match__smoke path { animation: toggle-match-smoke 1.1s ease-out; }
@media (prefers-reduced-motion: reduce) {
.toggle-match * { animation: none !important; transition: none !important; }
}
`;
export function MatchToggle() {
const [armed, setArmed] = useState(false);
return (
<>
<style>
{css}
</style>
<label className="toggle-match" data-armed={armed || undefined}>
<input className="toggle-match__input" type="checkbox" role="switch" onChange={() => setArmed(true)} />
<span className="toggle-match__scene" aria-hidden="true">
<span className="toggle-match__strip" />
<span className="toggle-match__stick">
<svg className="toggle-match__smoke" viewBox="0 0 16 28">
<path d="M8 26C5 21 11 18 8 13S11 6 8 2" pathLength={1} />
</svg>
<span className="toggle-match__flame">
<span />
</span>
{[
[-10, -8],
[-4, -14],
[-14, 2],
].map(([x, y]) => (
<span key={x} className="toggle-match__spark" style={{ '--x': `${x}px`, '--y': `${y}px` } as React.CSSProperties} />
))}
</span>
</span>
Fire it up
</label>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initToggleMatch(el) on the mounted element instead.
<label class="toggle-match"><input class="toggle-match__input" type="checkbox" role="switch"/><span class="toggle-match__scene" aria-hidden="true"><span class="toggle-match__strip"></span><span class="toggle-match__stick"><svg class="toggle-match__smoke" viewBox="0 0 16 28"><path d="M8 26C5 21 11 18 8 13S11 6 8 2" pathLength="1"></path></svg><span class="toggle-match__flame"><span></span></span><span class="toggle-match__spark" style="--x:-10px;--y:-8px"></span><span class="toggle-match__spark" style="--x:-4px;--y:-14px"></span><span class="toggle-match__spark" style="--x:-14px;--y:2px"></span></span></span>Fire it up</label>.toggle-match { position: relative; display: inline-flex; align-items: center; gap: 14px; color: light-dark(#42454c, #e5e6e9); font-size: 14px; cursor: pointer; user-select: none; }
.toggle-match__input { position: absolute; width: 1px; height: 1px; margin: 0; opacity: 0; }
.toggle-match__scene { position: relative; flex: none; }
.toggle-match__input:focus-visible + .toggle-match__scene { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 4px; border-radius: 8px; }
@keyframes toggle-match-strike { 0% { transform: translateX(0) rotate(-35deg); } 40% { transform: translateX(26px) rotate(-35deg); } 100% { transform: translateX(26px) rotate(-12deg); } }
@keyframes toggle-match-catch { 0%, 38% { transform: scale(0); opacity: 0; } 50% { transform: scale(1.5, 1.2); opacity: 1; } 100% { transform: none; opacity: 1; } }
@keyframes toggle-match-flicker { 0%, 100% { transform: scale(1) rotate(-3deg); } 50% { transform: scale(0.92, 1.08) rotate(3deg); } }
@keyframes toggle-match-spark { 0% { transform: translate(0, 0); opacity: 1; } 100% { transform: translate(var(--x), var(--y)); opacity: 0; } }
@keyframes toggle-match-smoke { 0% { stroke-dashoffset: 1; opacity: 0.8; } 100% { stroke-dashoffset: 0; opacity: 0; transform: translateY(-8px); } }
.toggle-match__scene { width: 76px; height: 52px; }
.toggle-match__strip { position: absolute; left: 4px; bottom: 6px; width: 46px; height: 8px; border-radius: 2px; background: repeating-linear-gradient(90deg, light-dark(#cbced3, #393c41) 0 2px, light-dark(#6d7179, #7d8087) 2px 3px); }
.toggle-match__stick { position: absolute; left: 6px; bottom: 16px; width: 40px; height: 4px; border-radius: 2px; background: light-dark(#50535b, #c6c8cd); transform-origin: 100% 50%; transform: rotate(-12deg) translateX(26px); }
.toggle-match__stick::before { content: ''; position: absolute; left: -5px; top: -2.5px; width: 9px; height: 9px; border-radius: 50%; background: light-dark(#6d7179, #7d8087); transition: background-color 0.3s 0.3s; }
.toggle-match__flame { position: absolute; left: -8px; top: -20px; width: 12px; height: 20px; transform-origin: 50% 100%; opacity: 0; }
.toggle-match__flame span { position: absolute; inset: 0; border-radius: 50% 50% 50% 50% / 62% 62% 38% 38%; background: radial-gradient(ellipse at 50% 74%, light-dark(#fbfcfd, #121417) 0 18%, light-dark(#373a40, #f1f2f3) 45%, light-dark(#50535b, #c6c8cd)); animation: toggle-match-flicker 0.7s ease-in-out infinite; }
.toggle-match__spark { position: absolute; left: -2px; top: 0; width: 2px; height: 2px; border-radius: 50%; background: light-dark(#373a40, #f1f2f3); opacity: 0; }
.toggle-match__smoke { position: absolute; left: -12px; top: -30px; width: 16px; height: 28px; overflow: visible; }
.toggle-match__smoke path { fill: none; stroke: light-dark(#6d7179, #7d8087); stroke-width: 1.2; stroke-linecap: round; stroke-dasharray: 1; stroke-dashoffset: 1; opacity: 0; }
.toggle-match__input:checked + .toggle-match__scene .toggle-match__flame { opacity: 1; }
.toggle-match__input:checked + .toggle-match__scene .toggle-match__stick::before { background: light-dark(#dbdde1, #2e3035); }
.toggle-match[data-armed] .toggle-match__input:checked + .toggle-match__scene .toggle-match__stick { animation: toggle-match-strike 0.5s cubic-bezier(0.5, 0, 0.3, 1) both; }
.toggle-match[data-armed] .toggle-match__input:checked + .toggle-match__scene .toggle-match__flame { animation: toggle-match-catch 0.6s ease-out both; }
.toggle-match[data-armed] .toggle-match__input:checked + .toggle-match__scene .toggle-match__spark { animation: toggle-match-spark 0.4s ease-out 0.18s; }
.toggle-match[data-armed] .toggle-match__input:not(:checked) + .toggle-match__scene .toggle-match__smoke path { animation: toggle-match-smoke 1.1s ease-out; }
@media (prefers-reduced-motion: reduce) {
.toggle-match * { animation: none !important; transition: none !important; }
}function initToggleMatch(root) {
var input = root.querySelector('input');
if (!input) return;
// Animate only after the first change, so nothing plays on page load.
function arm() { root.setAttribute('data-armed', ''); }
input.addEventListener('change', arm);
return function () { input.removeEventListener('change', arm); };
}
document.querySelectorAll('.toggle-match').forEach(initToggleMatch);Goal: Create a "Match" toggle.
Visual behavior: A switch drawn as a match: it strikes along the box, sparks and catches into a flickering flame; switch it off and the flame goes out in a thread of smoke.
Technique:
- A real native checkbox with role="switch" (visually hidden: absolute, 1x1, opacity 0) inside a <label>; every visual is aria-hidden and styled from the input with :checked + sibling selectors.
- Switching on, the stick slides along the striker strip and tilts up; the flame scales in with an overshoot as it catches and three sparks fly out to their own --x and --y offsets.
- The flame is a rounded radial gradient that flickers on a loop; switching off, a smoke path draws itself upwards with a dash-offset animation and fades.
- The keyframes only apply once a data-armed flag is set on the first change, so nothing plays on page load; on and off use differently named keyframes, so each change restarts the right one.
Accessibility: A native checkbox with role="switch" inside a <label>, so Space toggles it and screen readers announce a switch; the visible label names it and :focus-visible on the input outlines the visual. Under prefers-reduced-motion the flame simply appears and disappears.
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 "Match" toggle.
Visual behavior: A switch drawn as a match: it strikes along the box, sparks and catches into a flickering flame; switch it off and the flame goes out in a thread of smoke.
Technique:
- A real native checkbox with role="switch" (visually hidden: absolute, 1x1, opacity 0) inside a <label>; every visual is aria-hidden and styled from the input with :checked + sibling selectors.
- Switching on, the stick slides along the striker strip and tilts up; the flame scales in with an overshoot as it catches and three sparks fly out to their own --x and --y offsets.
- The flame is a rounded radial gradient that flickers on a loop; switching off, a smoke path draws itself upwards with a dash-offset animation and fades.
- The keyframes only apply once a data-armed flag is set on the first change, so nothing plays on page load; on and off use differently named keyframes, so each change restarts the right one.
Accessibility: A native checkbox with role="switch" inside a <label>, so Space toggles it and screen readers announce a switch; the visible label names it and :focus-visible on the input outlines the visual. Under prefers-reduced-motion the flame simply appears and disappears.
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 switch drawn as a match: it strikes along the box, sparks and catches into a flickering flame; switch it off and the flame goes out in a thread of smoke.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- click, state change, glow, draw
- Techniques
- keyframes, svg, stroke dashoffset, radial gradient
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
A native checkbox with role="switch" inside a <label>, so Space toggles it and screen readers announce a switch; the visible label names it and :focus-visible on the input outlines the visual. Under prefers-reduced-motion the flame simply appears and disappears.