Puffer
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>
</>
);
}Markup where the component goes, CSS in your stylesheet. No JavaScript.
<label class="toggle-puffer">
<input class="toggle-puffer__input" type="checkbox" role="switch"/>
<svg class="toggle-puffer__art" viewBox="0 0 96 80" aria-hidden="true">
<path class="toggle-puffer__fin toggle-puffer__tail" d="M28 40L16 31Q14 40 16 49Z"></path>
<g transform="translate(48.00 17.00) rotate(0)">
<path class="toggle-puffer__spike" style="--i:0" d="M-2 0L0-6L2 0Z"></path>
</g>
<g transform="translate(56.80 18.75) rotate(22.5)">
<path class="toggle-puffer__spike" style="--i:5" d="M-2 0L0-6L2 0Z"></path>
</g>
<g transform="translate(64.26 23.74) rotate(45)">
<path class="toggle-puffer__spike" style="--i:10" d="M-2 0L0-6L2 0Z"></path>
</g>
<g transform="translate(69.25 31.20) rotate(67.5)">
<path class="toggle-puffer__spike" style="--i:15" d="M-2 0L0-6L2 0Z"></path>
</g>
<g transform="translate(71.00 40.00) rotate(90)">
<path class="toggle-puffer__spike" style="--i:4" d="M-2 0L0-6L2 0Z"></path>
</g>
<g transform="translate(69.25 48.80) rotate(112.5)">
<path class="toggle-puffer__spike" style="--i:9" d="M-2 0L0-6L2 0Z"></path>
</g>
<g transform="translate(64.26 56.26) rotate(135)">
<path class="toggle-puffer__spike" style="--i:14" d="M-2 0L0-6L2 0Z"></path>
</g>
<g transform="translate(56.80 61.25) rotate(157.5)">
<path class="toggle-puffer__spike" style="--i:3" d="M-2 0L0-6L2 0Z"></path>
</g>
<g transform="translate(48.00 63.00) rotate(180)">
<path class="toggle-puffer__spike" style="--i:8" d="M-2 0L0-6L2 0Z"></path>
</g>
<g transform="translate(39.20 61.25) rotate(202.5)">
<path class="toggle-puffer__spike" style="--i:13" d="M-2 0L0-6L2 0Z"></path>
</g>
<g transform="translate(31.74 56.26) rotate(225)">
<path class="toggle-puffer__spike" style="--i:2" d="M-2 0L0-6L2 0Z"></path>
</g>
<g transform="translate(26.75 48.80) rotate(247.5)">
<path class="toggle-puffer__spike" style="--i:7" d="M-2 0L0-6L2 0Z"></path>
</g>
<g transform="translate(25.00 40.00) rotate(270)">
<path class="toggle-puffer__spike" style="--i:12" d="M-2 0L0-6L2 0Z"></path>
</g>
<g transform="translate(26.75 31.20) rotate(292.5)">
<path class="toggle-puffer__spike" style="--i:1" d="M-2 0L0-6L2 0Z"></path>
</g>
<g transform="translate(31.74 23.74) rotate(315)">
<path class="toggle-puffer__spike" style="--i:6" d="M-2 0L0-6L2 0Z"></path>
</g>
<g transform="translate(39.20 18.75) rotate(337.5)">
<path class="toggle-puffer__spike" style="--i:11" d="M-2 0L0-6L2 0Z"></path>
</g>
<g class="toggle-puffer__swell">
<ellipse class="toggle-puffer__skin" cx="48" cy="40" rx="21" ry="13"></ellipse>
<path class="toggle-puffer__belly" d="M30 44Q48 56 68 44Q60 53 48 53Q36 53 30 44Z"></path>
<circle class="toggle-puffer__spot" cx="40" cy="34" r="1.6"></circle>
<circle class="toggle-puffer__spot" cx="47" cy="31" r="1.3"></circle>
<circle class="toggle-puffer__spot" cx="36" cy="40" r="1.2"></circle>
</g>
<path class="toggle-puffer__fin toggle-puffer__pec" d="M47 42Q52 38 54 44Q51 47 47 42Z"></path>
<g class="toggle-puffer__face">
<circle class="toggle-puffer__white" cx="60" cy="35" r="4"></circle>
<circle class="toggle-puffer__pupil" cx="61.2" cy="35.2" r="2"></circle>
<path class="toggle-puffer__mouth" d="M67 42Q69 43 68 45"></path>
</g>
</svg>
<span>Shield</span>
</label>@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; }
}Goal: Create a "Puffer" toggle switch.
Visual behavior: 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.
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.
- The body scales up about its centre on an overshooting curve. Sixteen spikes sit on the swollen outline and grow out of it with staggered delays.
- The face moves with the swelling and a fin flutters on a loop. Pure CSS.
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 it swells 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 "Puffer" toggle switch.
Visual behavior: 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.
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.
- The body scales up about its centre on an overshooting curve. Sixteen spikes sit on the swollen outline and grow out of it with staggered delays.
- The face moves with the swelling and a fin flutters on a loop. Pure CSS.
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 it swells 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 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.