Bubbler

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

const css = `
  @keyframes toggle-bubbler-rise {
    0% { opacity: 0; transform: translate(0, 0) scale(0.6); }
    10% { opacity: 1; }
    35% { transform: translate(1.6px, -16px) scale(0.9); }
    65% { transform: translate(-1.4px, -30px) scale(1); }
    92% { opacity: 1; }
    100% { opacity: 0; transform: translate(0.6px, -40px) scale(1.1); }
  }
  @keyframes toggle-bubbler-sway { 0%, 100% { transform: rotate(-5deg); } 50% { transform: rotate(6deg); } }
  @keyframes toggle-bubbler-fin { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-1.5px); } }
  .toggle-bubbler { display: inline-flex; align-items: center; gap: 14px; color: light-dark(#42454c, #e5e6e9); font-size: 15px; cursor: pointer; user-select: none; }
  .toggle-bubbler__input { position: absolute; width: 1px; height: 1px; margin: 0; opacity: 0; }
  .toggle-bubbler__art { display: block; width: 116px; height: 80px; overflow: visible; border-radius: 8px; }
  .toggle-bubbler__input:focus-visible + .toggle-bubbler__art { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 4px; }
  .toggle-bubbler__water { fill: light-dark(#ebedf0, #232529); }
  .toggle-bubbler__tank { fill: none; stroke: light-dark(#cbced3, #393c41); stroke-width: 2; }
  .toggle-bubbler__surface { stroke: light-dark(#cbced3, #393c41); stroke-width: 1; }
  .toggle-bubbler__sand { fill: light-dark(#dbdde1, #2e3035); }
  .toggle-bubbler__weed { fill: none; stroke: light-dark(#6d7179, #7d8087); stroke-width: 3; stroke-linecap: round; transform-box: fill-box; transform-origin: 50% 100%; animation: toggle-bubbler-sway 3.2s ease-in-out infinite paused; }
  .toggle-bubbler__tube { fill: none; stroke: light-dark(#cbced3, #393c41); stroke-width: 1.5; }
  .toggle-bubbler__stone { fill: light-dark(#6d7179, #7d8087); }
  .toggle-bubbler__bubble { fill: none; stroke: light-dark(#50535b, #c6c8cd); stroke-width: 1; opacity: 0; transform-box: fill-box; transform-origin: 50% 50%; }
  .toggle-bubbler__fish { transition: transform 1.4s cubic-bezier(0.45, 0, 0.3, 1); }
  .toggle-bubbler__body { fill: light-dark(#50535b, #c6c8cd); }
  .toggle-bubbler__eye { fill: light-dark(#f3f4f7, #1a1c1f); }
  .toggle-bubbler__input:checked + .toggle-bubbler__art .toggle-bubbler__bubble { animation: toggle-bubbler-rise 1.5s ease-in var(--d) infinite; }
  .toggle-bubbler__input:checked + .toggle-bubbler__art .toggle-bubbler__weed { animation-play-state: running; }
  /* The fish notices the bubbles and swims over to nose at them. */
  .toggle-bubbler__input:checked + .toggle-bubbler__art .toggle-bubbler__fish { transform: translate(22px, -6px); transition-delay: 0.4s; }
  .toggle-bubbler__input:checked + .toggle-bubbler__art .toggle-bubbler__swim { animation: toggle-bubbler-fin 0.9s ease-in-out infinite; }
  @media (prefers-reduced-motion: reduce) {
    .toggle-bubbler__weed, .toggle-bubbler__input:checked + .toggle-bubbler__art .toggle-bubbler__swim { animation: none; }
    .toggle-bubbler__input:checked + .toggle-bubbler__art .toggle-bubbler__bubble { animation: none; opacity: 1; transform: translateY(var(--y)); }
    .toggle-bubbler__fish { transition: none; }
  }
`;

const BUBBLES = [
  { r: 1.6, d: 0, y: 0 },
  { r: 2.2, d: 0.3, y: -6 },
  { r: 1.4, d: 0.55, y: -14 },
  { r: 2, d: 0.85, y: -22 },
  { r: 1.5, d: 1.1, y: -30 },
  { r: 1.8, d: 1.3, y: -36 },
];

export function BubblerToggle() {
  return (
    <>
      <style>
        {css}
      </style>
      <label className="toggle-bubbler">
        <input className="toggle-bubbler__input" type="checkbox" role="switch" />
        <svg className="toggle-bubbler__art" viewBox="0 0 110 76" aria-hidden="true">
          <rect className="toggle-bubbler__water" x="5" y="16" width="100" height="54" />
          <line className="toggle-bubbler__surface" x1="5" y1="16" x2="105" y2="16" />
          <path className="toggle-bubbler__weed" d="M18 66C14 56 22 50 17 40C14 34 18 30 17 26" />
          <path className="toggle-bubbler__weed" style={{ animationDelay: '-1.2s' }} d="M26 66C29 58 24 52 27 46" />
          <path className="toggle-bubbler__tube" d="M83 59V22Q83 6 96 4" />
          {BUBBLES.map((b) => (
            // Each starts from the stone; held still, they stand up the column at --y instead.
            <circle key={b.d} className="toggle-bubbler__bubble" cx="78" cy={56} r={b.r} style={{ '--d': `${b.d}s`, '--y': `${b.y}px` } as CSSProperties} />
          ))}
          <g className="toggle-bubbler__fish">
            <g className="toggle-bubbler__swim">
              <path className="toggle-bubbler__body" d="M30 40Q37 33 45 40Q37 47 30 40ZM31 40L25 36V44Z" />
              <circle className="toggle-bubbler__eye" cx="41" cy="39" r="1.1" />
            </g>
          </g>
          <path className="toggle-bubbler__sand" d="M5 70V64Q20 60 38 63T72 62T105 63V70Z" />
          <rect className="toggle-bubbler__stone" x="73" y="58" width="11" height="5" rx="2" />
          <rect className="toggle-bubbler__tank" x="4" y="8" width="102" height="63" rx="4" />
        </svg>
        <span>Air pump</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-bubbler
Source on GitHub

About this component

An air pump switch drawn as an aquarium: bubbles stream up from the air stone, the weed sways, and the fish notices and swims over to nose at them.

Dependencies
None
Difficulty
Intermediate
Triggers
click, state change, loop
Techniques
keyframes, css variables, 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 bubbles stand still up the column.

More toggles

See all
Burner
Chess clock
Drawbridge
Flytrap