Bubbler
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>
</>
);
}Markup where the component goes, CSS in your stylesheet. No JavaScript.
<label class="toggle-bubbler">
<input class="toggle-bubbler__input" type="checkbox" role="switch"/>
<svg class="toggle-bubbler__art" viewBox="0 0 110 76" aria-hidden="true">
<rect class="toggle-bubbler__water" x="5" y="16" width="100" height="54"></rect>
<line class="toggle-bubbler__surface" x1="5" y1="16" x2="105" y2="16"></line>
<path class="toggle-bubbler__weed" d="M18 66C14 56 22 50 17 40C14 34 18 30 17 26"></path>
<path class="toggle-bubbler__weed" style="animation-delay:-1.2s" d="M26 66C29 58 24 52 27 46"></path>
<path class="toggle-bubbler__tube" d="M83 59V22Q83 6 96 4"></path>
<circle class="toggle-bubbler__bubble" cx="78" cy="56" r="1.6" style="--d:0s;--y:0px"></circle>
<circle class="toggle-bubbler__bubble" cx="78" cy="56" r="2.2" style="--d:0.3s;--y:-6px"></circle>
<circle class="toggle-bubbler__bubble" cx="78" cy="56" r="1.4" style="--d:0.55s;--y:-14px"></circle>
<circle class="toggle-bubbler__bubble" cx="78" cy="56" r="2" style="--d:0.85s;--y:-22px"></circle>
<circle class="toggle-bubbler__bubble" cx="78" cy="56" r="1.5" style="--d:1.1s;--y:-30px"></circle>
<circle class="toggle-bubbler__bubble" cx="78" cy="56" r="1.8" style="--d:1.3s;--y:-36px"></circle>
<g class="toggle-bubbler__fish">
<g class="toggle-bubbler__swim">
<path class="toggle-bubbler__body" d="M30 40Q37 33 45 40Q37 47 30 40ZM31 40L25 36V44Z"></path>
<circle class="toggle-bubbler__eye" cx="41" cy="39" r="1.1"></circle>
</g>
</g>
<path class="toggle-bubbler__sand" d="M5 70V64Q20 60 38 63T72 62T105 63V70Z"></path>
<rect class="toggle-bubbler__stone" x="73" y="58" width="11" height="5" rx="2"></rect>
<rect class="toggle-bubbler__tank" x="4" y="8" width="102" height="63" rx="4"></rect>
</svg>
<span>Air pump</span>
</label>@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; }
}Goal: Create a "Bubbler" toggle switch.
Visual behavior: 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.
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.
- Bubbles loop up the column with their own delays, and the weed sway uses animation-play-state so it stops where it is when the pump goes off.
- The fish swims over with a transition when the pump starts. 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 the bubbles stand still up the column.
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 "Bubbler" toggle switch.
Visual behavior: 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.
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.
- Bubbles loop up the column with their own delays, and the weed sway uses animation-play-state so it stops where it is when the pump goes off.
- The fish swims over with a transition when the pump starts. 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 the bubbles stand still up the column.
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
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.