Kettle

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

const css = `
  @keyframes toggle-kettle-rise {
    0% { opacity: 0; transform: translate(0, 0); }
    15% { opacity: 1; }
    100% { opacity: 0; transform: translate(var(--x), -15px); }
  }
  @keyframes toggle-kettle-steam {
    0% { opacity: 0; transform: translate(0, 0) scale(0.6); }
    30% { opacity: 0.8; }
    100% { opacity: 0; transform: translate(-3px, -14px) scale(1.25); }
  }
  @keyframes toggle-kettle-boil { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-0.8px); } }
  .toggle-kettle { display: inline-flex; align-items: center; gap: 14px; color: light-dark(#42454c, #e5e6e9); font-size: 15px; cursor: pointer; user-select: none; }
  .toggle-kettle__input { position: absolute; width: 1px; height: 1px; margin: 0; opacity: 0; }
  .toggle-kettle__art { display: block; width: 92px; height: 90px; overflow: visible; border-radius: 8px; }
  .toggle-kettle__input:focus-visible + .toggle-kettle__art { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 4px; }
  .toggle-kettle__base { fill: light-dark(#dbdde1, #2e3035); }
  .toggle-kettle__body { fill: light-dark(#dbdde1, #2e3035); }
  .toggle-kettle__shine { fill: light-dark(#cbced3, #393c41); }
  .toggle-kettle__lid { fill: light-dark(#cbced3, #393c41); }
  .toggle-kettle__handle { fill: none; stroke: light-dark(#cbced3, #393c41); stroke-width: 5; stroke-linecap: round; }
  .toggle-kettle__window { fill: light-dark(#f3f4f7, #1a1c1f); }
  .toggle-kettle__water { fill: light-dark(#6d7179, #7d8087); }
  .toggle-kettle__bubble { fill: light-dark(#42454c, #e5e6e9); opacity: 0; transform-box: fill-box; }
  .toggle-kettle__steam { fill: none; stroke: light-dark(#5f626a, #a7abb2); stroke-width: 2; stroke-linecap: round; opacity: 0; transform-box: fill-box; transform-origin: 50% 100%; }
  .toggle-kettle__lever { fill: light-dark(#5f626a, #a7abb2); transform-box: fill-box; transform-origin: 0 50%; transition: transform 0.25s cubic-bezier(0.3, 1.6, 0.5, 1); }
  .toggle-kettle__led { fill: light-dark(#cbced3, #393c41); transition: fill 0.2s, filter 0.2s; }
  .toggle-kettle__input:checked + .toggle-kettle__art .toggle-kettle__lever { transform: rotate(-30deg); }
  .toggle-kettle__input:checked + .toggle-kettle__art .toggle-kettle__led { fill: light-dark(#373a40, #f1f2f3); filter: drop-shadow(0 0 2.5px light-dark(#373a40, #f1f2f3)); }
  /* The water heats for a moment before it bubbles, and steams a little after that. */
  .toggle-kettle__input:checked + .toggle-kettle__art .toggle-kettle__bubble { animation: toggle-kettle-rise 1.1s ease-in calc(0.5s + var(--d)) infinite; }
  .toggle-kettle__input:checked + .toggle-kettle__art .toggle-kettle__surface { animation: toggle-kettle-boil 0.3s steps(2) 0.9s infinite; }
  .toggle-kettle__input:checked + .toggle-kettle__art .toggle-kettle__steam { animation: toggle-kettle-steam 1.8s ease-out calc(1.3s + var(--d)) infinite; }
  @media (prefers-reduced-motion: reduce) {
    .toggle-kettle__lever { transition: none; }
    .toggle-kettle__input:checked + .toggle-kettle__art .toggle-kettle__bubble,
    .toggle-kettle__input:checked + .toggle-kettle__art .toggle-kettle__surface { animation: none; }
    .toggle-kettle__input:checked + .toggle-kettle__art .toggle-kettle__steam { animation: none; opacity: 0.6; }
  }
`;

const BUBBLES = [
  { x: 33.5, r: 1.2, d: 0, dx: 0.6 },
  { x: 36, r: 1.6, d: 0.35, dx: -0.5 },
  { x: 38.5, r: 1.1, d: 0.7, dx: 0.4 },
  { x: 35, r: 1, d: 0.95, dx: 0.8 },
  { x: 37.5, r: 1.3, d: 0.2, dx: -0.7 },
];

export function KettleToggle() {
  return (
    <>
      <style>
        {css}
      </style>
      <label className="toggle-kettle">
        <input className="toggle-kettle__input" type="checkbox" role="switch" />
        <svg className="toggle-kettle__art" viewBox="0 0 76 74" aria-hidden="true">
          {[0, 0.6, 1.2].map((d, i) => (
            <path key={d} className="toggle-kettle__steam" style={{ '--d': `${d}s` } as CSSProperties} d={`M${7 + i * 3} 22c-3-3 3-5 0-8`} />
          ))}
          <path className="toggle-kettle__handle" d="M50 24C62 23 64 30 64 40C64 50 60 56 53 57" />
          <path className="toggle-kettle__body" d="M20 40L8 27Q6 25 9 24H12L22 33Z" />
          <path className="toggle-kettle__body" d="M18 62L21 24Q22 18 28 18H44Q50 18 51 24L54 62Z" />
          <path className="toggle-kettle__shine" d="M24 24Q25 21 28 21L26 56H22Z" />
          <path className="toggle-kettle__lid" d="M25 18Q36 10 47 18Z" />
          <rect className="toggle-kettle__lid" x="33" y="9" width="6" height="3" rx="1.5" />
          <rect className="toggle-kettle__window" x="31" y="27" width="10" height="29" rx="3" />
          <g className="toggle-kettle__surface">
            <path className="toggle-kettle__water" d="M31 39H41V53A3 3 0 0 1 38 56H34A3 3 0 0 1 31 53Z" />
          </g>
          {BUBBLES.map((b) => (
            <circle key={b.x} className="toggle-kettle__bubble" cx={b.x} cy="54" r={b.r} style={{ '--d': `${b.d}s`, '--x': `${b.dx}px` } as CSSProperties} />
          ))}
          <rect className="toggle-kettle__lever" x="55" y="54" width="8" height="3.5" rx="1.5" />
          <rect className="toggle-kettle__base" x="12" y="62" width="48" height="8" rx="3" />
          <circle className="toggle-kettle__led" cx="51" cy="66" r="1.8" />
        </svg>
        <span>Kettle</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-kettle
Source on GitHub

About this component

A kettle switch: the lever flips, the base light comes on, bubbles start rising in the water window and steam curls from the spout. Pure CSS.

Dependencies
None
Difficulty
Intermediate
Triggers
click, state change, loop
Techniques
keyframes, css transition, 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 it switches without the bubbles and steam moving.

More toggles

See all
Lights
Periscope
Puffer
Sail