Flytrap
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>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initToggleFlytrap(el) on the mounted element instead.
<label class="toggle-flytrap">
<input class="toggle-flytrap__input" type="checkbox" role="switch"/>
<svg class="toggle-flytrap__art" viewBox="0 0 86 90" aria-hidden="true">
<path class="toggle-flytrap__leaf" d="M43 70Q26 66 18 54Q32 56 43 66Z"></path>
<path class="toggle-flytrap__stem" d="M43 74Q46 58 40 42"></path>
<g class="toggle-flytrap__head">
<g class="toggle-flytrap__lower">
<path class="toggle-flytrap__lobe" d="M40 40Q58 54 76 36Q60 42 40 40Z"></path>
<path class="toggle-flytrap__tooth" d="M42.7 39.7l1.5 6.0M48.0 39.1l1.5 6.0M53.3 38.4l1.5 6.0M58.6 37.8l1.5 6.0M63.9 37.2l1.5 6.0M69.2 36.6l1.5 6.0"></path>
</g>
<g class="toggle-flytrap__upper">
<path class="toggle-flytrap__inner" d="M40 40Q58 38 76 36Q60 31 40 40Z"></path>
<path class="toggle-flytrap__lobe" d="M40 40Q56 20 76 36Q60 32 40 40Z"></path>
<path class="toggle-flytrap__tooth" d="M40.0 40.0l1.5 -6.0M45.3 39.4l1.5 -6.0M50.6 38.8l1.5 -6.0M55.9 38.1l1.5 -6.0M61.3 37.5l1.5 -6.0M66.6 36.9l1.5 -6.0"></path>
<g class="toggle-flytrap__bug">
<ellipse class="toggle-flytrap__wing" cx="60" cy="31" rx="2" ry="3.2"></ellipse>
<ellipse class="toggle-flytrap__fly" cx="60" cy="34" rx="2.6" ry="1.8"></ellipse>
</g>
</g>
</g>
<path class="toggle-flytrap__pot" d="M26 74H60L56 88H30Z"></path>
<rect class="toggle-flytrap__pot" x="24" y="71" width="38" height="5" rx="1.5"></rect>
<ellipse class="toggle-flytrap__soil" cx="43" cy="72" rx="16" ry="1.6"></ellipse>
</svg>
<span>Spam filter</span>
</label>@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; }
}function initToggleFlytrap(root) {
// The motion keyframes only run once the control has been used, never on page load.
function onChange() {
root.setAttribute('data-armed', '');
}
root.addEventListener('change', onChange);
return function () {
root.removeEventListener('change', onChange);
};
}
document.querySelectorAll('.toggle-flytrap').forEach(initToggleFlytrap);Goal: Create a "Flytrap" toggle switch.
Visual behavior: 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.
Technique:
- A native checkbox with role="switch", visually hidden but focusable, sits inside a <label> before an aria-hidden drawing and the visible text. Every on state is styled from the input's :checked state through the adjacent sibling combinator, so Space and clicks both work.
- Each jaw rotates about the hinge, closing on a short ease-in and opening on a long ease-out, like the real plant. The teeth on the two lips are offset by half a gap so they mesh.
- Keyframes that must not play on page load hang off a data-armed attribute set on the first change. The fly takes off just before the jaws meet and lands back once they open.
Accessibility: 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.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: neutral only. Black, white and grays, no hues. Use the color values above as written, or my project's closest neutral tokens.
Return a single self-contained component. No dependencies.Goal: Create a "Flytrap" toggle switch.
Visual behavior: 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.
Technique:
- A native checkbox with role="switch", visually hidden but focusable, sits inside a <label> before an aria-hidden drawing and the visible text. Every on state is styled from the input's :checked state through the adjacent sibling combinator, so Space and clicks both work.
- Each jaw rotates about the hinge, closing on a short ease-in and opening on a long ease-out, like the real plant. The teeth on the two lips are offset by half a gap so they mesh.
- Keyframes that must not play on page load hang off a data-armed attribute set on the first change. The fly takes off just before the jaws meet and lands back once they open.
Accessibility: 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.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: use my project's existing color tokens. The color values above are grayscale placeholders. Map each one by its role (surface, text, border, accent, glow) to my theme, and keep their relative lightness so contrast and the effect still read.
Return a single self-contained component. No dependencies.Paste into Claude, ChatGPT or Cursor after filling in {{USER_STACK}}, {{USER_STYLING}} and {{USER_CONSTRAINTS}}. Neutral keeps the greys you see here; Match my project asks the model to map them onto your own colour tokens.
Free to use in any project, commercial included. MIT-0, no attribution required. Licence
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.