Flytrap

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

const css = `
  @keyframes toggle-flytrap-shake { 0%, 100% { transform: rotate(0deg); } 25% { transform: rotate(-3deg); } 55% { transform: rotate(2deg); } 80% { transform: rotate(-1deg); } }
  @keyframes toggle-flytrap-escape { 0% { transform: translate(0, 0); opacity: 1; } 100% { transform: translate(22px, -30px); opacity: 0; } }
  @keyframes toggle-flytrap-land { 0% { transform: translate(-18px, -26px); opacity: 0; } 60% { opacity: 1; } 100% { transform: translate(0, 0); opacity: 1; } }
  @keyframes toggle-flytrap-buzz { 0%, 100% { transform: scaleY(1); } 50% { transform: scaleY(0.3); } }
  .toggle-flytrap { display: inline-flex; align-items: center; gap: 14px; color: light-dark(#42454c, #e5e6e9); font-size: 15px; cursor: pointer; user-select: none; }
  .toggle-flytrap__input { position: absolute; width: 1px; height: 1px; margin: 0; opacity: 0; }
  .toggle-flytrap__art { display: block; width: 88px; height: 92px; overflow: visible; border-radius: 8px; }
  .toggle-flytrap__input:focus-visible + .toggle-flytrap__art { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 4px; }
  .toggle-flytrap__pot { fill: light-dark(#cbced3, #393c41); }
  .toggle-flytrap__soil { fill: light-dark(#ebedf0, #232529); }
  .toggle-flytrap__stem { fill: none; stroke: light-dark(#6d7179, #7d8087); stroke-width: 3; stroke-linecap: round; }
  .toggle-flytrap__leaf { fill: light-dark(#cbced3, #393c41); }
  .toggle-flytrap__lobe { fill: light-dark(#5f626a, #a7abb2); }
  .toggle-flytrap__inner { fill: light-dark(#42454c, #e5e6e9); }
  .toggle-flytrap__tooth { stroke: light-dark(#5f626a, #a7abb2); stroke-width: 1.3; stroke-linecap: round; }
  .toggle-flytrap__fly { fill: light-dark(#42454c, #e5e6e9); }
  .toggle-flytrap__wing { fill: color-mix(in srgb, light-dark(#373a40, #f1f2f3) 55%, transparent); transform-box: fill-box; transform-origin: 50% 100%; }
  /* It shuts fast and opens slowly, like the real plant. */
  .toggle-flytrap__upper { transform-box: view-box; transform-origin: 40px 40px; transform: rotate(-36deg); transition: transform 1.2s cubic-bezier(0.3, 0, 0.3, 1); }
  .toggle-flytrap__lower { transform-box: view-box; transform-origin: 40px 40px; transform: rotate(16deg); transition: transform 1.2s cubic-bezier(0.3, 0, 0.3, 1); }
  .toggle-flytrap__head { transform-box: view-box; transform-origin: 40px 40px; }
  .toggle-flytrap__input:checked + .toggle-flytrap__art .toggle-flytrap__upper,
  .toggle-flytrap__input:checked + .toggle-flytrap__art .toggle-flytrap__lower { transform: none; transition: transform 0.13s cubic-bezier(0.7, 0, 1, 0.6) 0.12s; }
  .toggle-flytrap__input:checked + .toggle-flytrap__art .toggle-flytrap__head { animation: toggle-flytrap-shake 0.4s ease-out 0.25s; }
  /* The fly is the one thing that gets away: it takes off just before the jaws meet and comes back once they open. */
  .toggle-flytrap[data-armed] .toggle-flytrap__bug { animation: toggle-flytrap-land 0.7s ease-out 1.1s backwards; }
  .toggle-flytrap__input:checked + .toggle-flytrap__art .toggle-flytrap__bug { animation: toggle-flytrap-escape 0.4s ease-in forwards; }
  .toggle-flytrap__input:checked + .toggle-flytrap__art .toggle-flytrap__wing,
  .toggle-flytrap[data-armed] .toggle-flytrap__wing { animation: toggle-flytrap-buzz 0.05s linear 24; }
  @media (prefers-reduced-motion: reduce) {
    .toggle-flytrap__upper, .toggle-flytrap__lower,
    .toggle-flytrap__input:checked + .toggle-flytrap__art .toggle-flytrap__upper,
    .toggle-flytrap__input:checked + .toggle-flytrap__art .toggle-flytrap__lower { transition: none; }
    .toggle-flytrap__input:checked + .toggle-flytrap__art .toggle-flytrap__head,
    .toggle-flytrap[data-armed] .toggle-flytrap__bug, .toggle-flytrap__input:checked + .toggle-flytrap__art .toggle-flytrap__wing,
    .toggle-flytrap[data-armed] .toggle-flytrap__wing { animation: none; }
    .toggle-flytrap__input:checked + .toggle-flytrap__art .toggle-flytrap__bug { animation: none; opacity: 0; }
  }
`;

// Teeth along each lip, offset by half a gap between the two so they mesh when the trap shuts.
const teeth = (dir: 1 | -1, shift: number) =>
  Array.from({ length: 6 }, (_, i) => {
    const t = (i + shift) / 6.4;
    const x = 40 + 34 * t;
    const y = 40 - 4 * t;
    return `M${x.toFixed(1)} ${y.toFixed(1)}l${(1.5).toFixed(1)} ${(dir * -6).toFixed(1)}`;
  }).join('');

export function FlytrapToggle() {
  // The fly's landing only plays after the first snap, never on load.
  const [armed, setArmed] = useState(false);
  return (
    <>
      <style>
        {css}
      </style>
      <label className="toggle-flytrap" data-armed={armed || undefined}>
        <input className="toggle-flytrap__input" type="checkbox" role="switch" onChange={() => setArmed(true)} />
        <svg className="toggle-flytrap__art" viewBox="0 0 86 90" aria-hidden="true">
          <path className="toggle-flytrap__leaf" d="M43 70Q26 66 18 54Q32 56 43 66Z" />
          <path className="toggle-flytrap__stem" d="M43 74Q46 58 40 42" />
          <g className="toggle-flytrap__head">
            <g className="toggle-flytrap__lower">
              <path className="toggle-flytrap__lobe" d="M40 40Q58 54 76 36Q60 42 40 40Z" />
              <path className="toggle-flytrap__tooth" d={teeth(-1, 0.5)} />
            </g>
            <g className="toggle-flytrap__upper">
              <path className="toggle-flytrap__inner" d="M40 40Q58 38 76 36Q60 31 40 40Z" />
              <path className="toggle-flytrap__lobe" d="M40 40Q56 20 76 36Q60 32 40 40Z" />
              <path className="toggle-flytrap__tooth" d={teeth(1, 0)} />
              <g className="toggle-flytrap__bug">
                <ellipse className="toggle-flytrap__wing" cx="60" cy="31" rx="2" ry="3.2" />
                <ellipse className="toggle-flytrap__fly" cx="60" cy="34" rx="2.6" ry="1.8" />
              </g>
            </g>
          </g>
          <path className="toggle-flytrap__pot" d="M26 74H60L56 88H30Z" />
          <rect className="toggle-flytrap__pot" x="24" y="71" width="38" height="5" rx="1.5" />
          <ellipse className="toggle-flytrap__soil" cx="43" cy="72" rx="16" ry="1.6" />
        </svg>
        <span>Spam filter</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-flytrap
Source on GitHub

About this component

A spam filter switch drawn as a Venus flytrap: the jaws snap shut fast with their teeth meshing and the plant shakes, but the fly escapes just in time; off opens the jaws slowly and the fly lands again.

Dependencies
None
Difficulty
Intermediate
Triggers
click, state change, keyframe
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 the jaws move at once.

More toggles

See all
Gate
Handbrake
Jack
Kettle