Burner

No dependencies
Code format
React + Tailwind
import type { CSSProperties } from 'react';

const css = `
  @keyframes toggle-burner-flicker { 0%, 100% { transform: scale(1, 1); } 30% { transform: scale(0.92, 1.12); } 60% { transform: scale(1.05, 0.9); } }
  @keyframes toggle-burner-spark { 0%, 20%, 45%, 100% { opacity: 0; transform: scale(0.4); } 8%, 30% { opacity: 1; transform: scale(1); } }
  .toggle-burner { display: inline-flex; align-items: center; gap: 14px; color: light-dark(#42454c, #e5e6e9); font-size: 15px; cursor: pointer; user-select: none; }
  .toggle-burner__input { position: absolute; width: 1px; height: 1px; margin: 0; opacity: 0; }
  .toggle-burner__art { display: block; width: 124px; height: 90px; overflow: visible; border-radius: 8px; }
  .toggle-burner__input:focus-visible + .toggle-burner__art { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 4px; }
  .toggle-burner__top { fill: light-dark(#dbdde1, #2e3035); }
  .toggle-burner__front { fill: light-dark(#ebedf0, #232529); }
  .toggle-burner__base { fill: light-dark(#cbced3, #393c41); }
  .toggle-burner__cap { fill: light-dark(#6d7179, #7d8087); }
  .toggle-burner__glow { fill: light-dark(#373a40, #f1f2f3); opacity: 0; filter: blur(6px); transition: opacity 0.4s; }
  .toggle-burner__knob { fill: light-dark(#6d7179, #7d8087); }
  .toggle-burner__mark { stroke: light-dark(#373a40, #f1f2f3); stroke-width: 1.6; stroke-linecap: round; }
  .toggle-burner__dial { transform-box: view-box; transform-origin: 80px 62px; transition: transform 0.3s cubic-bezier(0.3, 1.3, 0.5, 1); }
  .toggle-burner__spark { fill: light-dark(#373a40, #f1f2f3); opacity: 0; transform-box: fill-box; transform-origin: 50% 50%; }
  /* Each flame grows from its port. They catch one after another round the ring once the spark has fired. */
  .toggle-burner__flame { transform-box: fill-box; transform-origin: 50% 100%; transform: scale(0.2, 0); transition: transform 0.18s ease-in; }
  .toggle-burner__tongue { fill: light-dark(#42454c, #e5e6e9); transform-box: fill-box; transform-origin: 50% 100%; animation: toggle-burner-flicker var(--t) ease-in-out infinite; }
  .toggle-burner__tongue--back { fill: light-dark(#5f626a, #a7abb2); }
  .toggle-burner__input:checked + .toggle-burner__art .toggle-burner__dial { transform: rotate(-90deg); }
  .toggle-burner__input:checked + .toggle-burner__art .toggle-burner__spark { animation: toggle-burner-spark 0.5s 0.1s; }
  .toggle-burner__input:checked + .toggle-burner__art .toggle-burner__flame { transform: none; transition: transform 0.3s cubic-bezier(0.3, 1.5, 0.5, 1) calc(0.4s + var(--i) * 30ms); }
  .toggle-burner__input:checked + .toggle-burner__art .toggle-burner__glow { opacity: 0.18; transition-delay: 0.5s; }
  @media (prefers-reduced-motion: reduce) {
    .toggle-burner__tongue { animation: none; }
    .toggle-burner__dial, .toggle-burner__flame, .toggle-burner__input:checked + .toggle-burner__art .toggle-burner__flame { transition: none; }
    .toggle-burner__input:checked + .toggle-burner__art .toggle-burner__spark { animation: none; }
  }
`;

// Twelve ports round the burner, seen from the front: the back half sits behind the cap and reads smaller.
const PORTS = Array.from({ length: 12 }, (_, i) => {
  const a = (i / 12) * Math.PI * 2 + Math.PI / 2;
  return { i, x: 40 + 21 * Math.cos(a), y: 45 + 5.5 * Math.sin(a), back: Math.sin(a) < 0, t: 0.5 + ((i * 7) % 5) * 0.08 };
});
const order = (i: number) => (i <= 6 ? i : 12 - i);

function Flame({ p }: { p: (typeof PORTS)[number] }) {
  const s = p.back ? 0.72 : 1;
  return (
    <g transform={`translate(${p.x.toFixed(2)} ${p.y.toFixed(2)}) scale(${s})`}>
      <g className="toggle-burner__flame" style={{ '--i': order(p.i) } as CSSProperties}>
        <path
          className={`toggle-burner__tongue${p.back ? ' toggle-burner__tongue--back' : ''}`}
          style={{ '--t': `${p.t}s` } as CSSProperties}
          d="M0 0C-2.6-2-2.4-6 0-13C2.4-6 2.6-2 0 0Z"
        />
      </g>
    </g>
  );
}

export function BurnerToggle() {
  return (
    <>
      <style>
        {css}
      </style>
      <label className="toggle-burner">
        <input className="toggle-burner__input" type="checkbox" role="switch" />
        <svg className="toggle-burner__art" viewBox="0 0 96 70" aria-hidden="true">
          <ellipse className="toggle-burner__glow" cx="40" cy="38" rx="28" ry="12" />
          <rect className="toggle-burner__top" x="2" y="50" width="92" height="5" rx="2" />
          <rect className="toggle-burner__front" x="4" y="55" width="88" height="14" rx="2" />
          <ellipse className="toggle-burner__base" cx="40" cy="48" rx="25" ry="5" />
          {PORTS.filter((p) => p.back).map((p) => (
            <Flame key={p.i} p={p} />
          ))}
          <ellipse className="toggle-burner__cap" cx="40" cy="44" rx="17" ry="4.5" />
          {PORTS.filter((p) => !p.back).map((p) => (
            <Flame key={p.i} p={p} />
          ))}
          <path className="toggle-burner__spark" d="M64 40L66 43L69 42L67 45L69 48L66 47L64 50L63 47L60 47L62 45L60 42L63 43Z" />
          <g className="toggle-burner__dial">
            <circle className="toggle-burner__knob" cx="80" cy="62" r="5.5" />
            <line className="toggle-burner__mark" x1="80" y1="62" x2="80" y2="57.5" />
          </g>
        </svg>
        <span>Heat</span>
      </label>
    </>
  );
}

Free to use in any project, commercial included. MIT-0, no attribution required. Licence

Install with shadcn

npx shadcn@latest add @movin/toggle-burner
Source on GitHub

About this component

A heat switch drawn as a gas hob: the knob turns, the igniter sparks and a ring of flames catches round the burner one after another, then flickers.

Dependencies
None
Difficulty
Intermediate
Triggers
click, state change, delay stagger, loop
Techniques
keyframes, stagger, svg
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 the visible text names it; :focus-visible on the input outlines the drawing, which is aria-hidden. Under prefers-reduced-motion the flames light without catching round or flickering.

More toggles

See all
Chess clock
Drawbridge
Flytrap
Gate