Puffer

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

const css = `
  @keyframes toggle-puffer-fin { 0%, 100% { transform: rotate(0deg); } 50% { transform: rotate(-24deg); } }
  .toggle-puffer { display: inline-flex; align-items: center; gap: 14px; color: light-dark(#42454c, #e5e6e9); font-size: 15px; cursor: pointer; user-select: none; }
  .toggle-puffer__input { position: absolute; width: 1px; height: 1px; margin: 0; opacity: 0; }
  .toggle-puffer__art { display: block; width: 100px; height: 80px; overflow: visible; border-radius: 8px; }
  .toggle-puffer__input:focus-visible + .toggle-puffer__art { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 4px; }
  .toggle-puffer__skin { fill: light-dark(#5f626a, #a7abb2); }
  .toggle-puffer__belly { fill: light-dark(#50535b, #c6c8cd); }
  .toggle-puffer__spot { fill: light-dark(#6d7179, #7d8087); }
  .toggle-puffer__fin { fill: light-dark(#6d7179, #7d8087); }
  .toggle-puffer__white { fill: light-dark(#373a40, #f1f2f3); }
  .toggle-puffer__pupil { fill: light-dark(#f3f4f7, #1a1c1f); }
  .toggle-puffer__mouth { fill: none; stroke: light-dark(#dbdde1, #2e3035); stroke-width: 1.4; stroke-linecap: round; }
  .toggle-puffer__spike { fill: light-dark(#50535b, #c6c8cd); transform-box: fill-box; transform-origin: 50% 100%; transform: scale(0.3, 0); transition: transform 0.2s ease-in; }
  /* It swallows water and swells round on an overshooting spring; the spikes stand up one after another. */
  .toggle-puffer__swell { transform-box: view-box; transform-origin: 48px 40px; transition: transform 0.45s cubic-bezier(0.4, 0, 0.4, 1) 0.1s; }
  .toggle-puffer__face { transition: transform 0.45s cubic-bezier(0.4, 0, 0.4, 1) 0.1s; }
  .toggle-puffer__tail { transform-box: fill-box; transform-origin: 100% 50%; transition: transform 0.45s; }
  .toggle-puffer__pec { transform-box: fill-box; transform-origin: 0 50%; }
  .toggle-puffer__input:checked + .toggle-puffer__art .toggle-puffer__swell { transform: scale(1.12, 1.78); transition: transform 0.55s cubic-bezier(0.3, 1.6, 0.5, 1); }
  .toggle-puffer__input:checked + .toggle-puffer__art .toggle-puffer__face { transform: translate(3px, -5px); transition: transform 0.55s cubic-bezier(0.3, 1.6, 0.5, 1); }
  .toggle-puffer__input:checked + .toggle-puffer__art .toggle-puffer__tail { transform: translateX(-2px) scale(0.7); }
  .toggle-puffer__input:checked + .toggle-puffer__art .toggle-puffer__spike { transform: none; transition: transform 0.25s cubic-bezier(0.3, 1.8, 0.5, 1) calc(0.3s + var(--i) * 25ms); }
  .toggle-puffer__input:checked + .toggle-puffer__art .toggle-puffer__pec { animation: toggle-puffer-fin 0.25s ease-in-out infinite; }
  @media (prefers-reduced-motion: reduce) {
    .toggle-puffer__swell, .toggle-puffer__face, .toggle-puffer__tail, .toggle-puffer__spike,
    .toggle-puffer__input:checked + .toggle-puffer__art .toggle-puffer__swell,
    .toggle-puffer__input:checked + .toggle-puffer__art .toggle-puffer__face,
    .toggle-puffer__input:checked + .toggle-puffer__art .toggle-puffer__spike { transition: none; }
    .toggle-puffer__input:checked + .toggle-puffer__art .toggle-puffer__pec { animation: none; }
  }
`;

// Spikes sit on the swollen outline (a circle of radius 23 round the body's centre), pointing out.
const SPIKES = Array.from({ length: 16 }, (_, i) => {
  const a = (i / 16) * 360;
  return { i, a, x: 48 + 23 * Math.sin((a * Math.PI) / 180), y: 40 - 23 * Math.cos((a * Math.PI) / 180) };
});

export function PufferToggle() {
  return (
    <>
      <style>
        {css}
      </style>
      <label className="toggle-puffer">
        <input className="toggle-puffer__input" type="checkbox" role="switch" />
        <svg className="toggle-puffer__art" viewBox="0 0 96 80" aria-hidden="true">
          <path className="toggle-puffer__fin toggle-puffer__tail" d="M28 40L16 31Q14 40 16 49Z" />
          {SPIKES.map((s) => (
            <g key={s.i} transform={`translate(${s.x.toFixed(2)} ${s.y.toFixed(2)}) rotate(${s.a})`}>
              <path className="toggle-puffer__spike" style={{ '--i': (s.i * 5) % 16 } as CSSProperties} d="M-2 0L0-6L2 0Z" />
            </g>
          ))}
          <g className="toggle-puffer__swell">
            <ellipse className="toggle-puffer__skin" cx="48" cy="40" rx="21" ry="13" />
            <path className="toggle-puffer__belly" d="M30 44Q48 56 68 44Q60 53 48 53Q36 53 30 44Z" />
            <circle className="toggle-puffer__spot" cx="40" cy="34" r="1.6" />
            <circle className="toggle-puffer__spot" cx="47" cy="31" r="1.3" />
            <circle className="toggle-puffer__spot" cx="36" cy="40" r="1.2" />
          </g>
          <path className="toggle-puffer__fin toggle-puffer__pec" d="M47 42Q52 38 54 44Q51 47 47 42Z" />
          <g className="toggle-puffer__face">
            <circle className="toggle-puffer__white" cx="60" cy="35" r="4" />
            <circle className="toggle-puffer__pupil" cx="61.2" cy="35.2" r="2" />
            <path className="toggle-puffer__mouth" d="M67 42Q69 43 68 45" />
          </g>
        </svg>
        <span>Shield</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-puffer
Source on GitHub

About this component

A shield switch drawn as a pufferfish: on, it gulps water and swells round on a springy overshoot as its spikes stand up one after another; off, it deflates.

Dependencies
None
Difficulty
Intermediate
Triggers
click, state change, spring, delay stagger
Techniques
css transition, 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 it swells at once.

More toggles

See all
Sail
Shades
Shell
Sign