Tap
import { useId } from 'react';
const OPTIONS = [
{ value: 'drip', label: 'Drip', note: 'A drop every second or so' },
{ value: 'trickle', label: 'Trickle', note: 'A thin, steady thread' },
{ value: 'full', label: 'Full', note: 'Wide open, with splashes' },
];
const at = (n: number) => `.radio-tap:has(.radio-tap__opt:nth-child(${n}) input:checked)`;
const css = `
.radio-tap { display: grid; justify-items: center; gap: 14px; width: 260px; }
.radio-tap__sink { display: block; width: 200px; height: 124px; overflow: visible; }
.radio-tap__metal { fill: light-dark(#6d7179, #7d8087); }
.radio-tap__wall { fill: light-dark(#dbdde1, #2e3035); }
.radio-tap__basin { fill: light-dark(#ebedf0, #232529); }
.radio-tap__water { fill: light-dark(#50535b, #c6c8cd); stroke: light-dark(#50535b, #c6c8cd); }
.radio-tap__shine { fill: none; stroke: light-dark(#373a40, #f1f2f3); }
.radio-tap__ring { fill: none; stroke: light-dark(#5f626a, #a7abb2); stroke-width: 1.2; transform-box: fill-box; transform-origin: center; opacity: 0; }
/* The handle turns further for each setting. */
.radio-tap__lever { transform-box: view-box; transform-origin: 56px 9px; transition: transform 0.5s cubic-bezier(0.3, 1.4, 0.5, 1); }
${at(2)} .radio-tap__lever { transform: rotate(-28deg); }
${at(3)} .radio-tap__lever { transform: rotate(-60deg); }
.radio-tap__flow { opacity: 0; transition: opacity 0.3s; }
${at(1)} .radio-tap__flow--drip, ${at(2)} .radio-tap__flow--trickle, ${at(3)} .radio-tap__flow--full { opacity: 1; }
/* Drip: a drop swells at the spout, lets go, falls, and rings the water where it lands. */
.radio-tap__drop { transform-box: fill-box; transform-origin: 50% 0; animation: radio-tap-drop 1.4s infinite; }
@keyframes radio-tap-drop {
0% { transform: translateY(0) scale(0.2); animation-timing-function: ease-out; }
40% { transform: translateY(0) scale(1); animation-timing-function: cubic-bezier(0.5, 0, 1, 0.6); }
72% { transform: translateY(58px) scale(0.9, 1.15); opacity: 1; }
73%, 100% { transform: translateY(58px); opacity: 0; }
}
.radio-tap__flow--drip .radio-tap__ring { animation: radio-tap-ring 1.4s ease-out infinite; }
@keyframes radio-tap-ring {
0%, 72% { opacity: 0; transform: scale(0.2); }
74% { opacity: 1; }
100% { opacity: 0; transform: scale(1.4); }
}
/* Trickle and full: the stream's dashes run downward, and the surface keeps rippling. */
.radio-tap__stream { fill: none; stroke-linecap: round; animation: radio-tap-run 0.35s linear infinite; }
@keyframes radio-tap-run {
to { stroke-dashoffset: -18; }
}
.radio-tap__flow--trickle .radio-tap__ring { animation: radio-tap-ripple 0.9s ease-out infinite; }
.radio-tap__flow--full .radio-tap__ring { animation: radio-tap-ripple 0.6s ease-out infinite; }
.radio-tap__flow--full .radio-tap__ring + .radio-tap__ring { animation-delay: 0.3s; }
@keyframes radio-tap-ripple {
from { opacity: 0.9; transform: scale(0.3); }
to { opacity: 0; transform: scale(1.3); }
}
.radio-tap__splash { fill: light-dark(#50535b, #c6c8cd); animation: radio-tap-splash 0.5s ease-out infinite; }
.radio-tap__splash:nth-of-type(2) { --dx: 12px; animation-delay: 0.12s; }
.radio-tap__splash:nth-of-type(3) { --dx: -10px; animation-delay: 0.24s; }
.radio-tap__splash:nth-of-type(4) { --dx: 16px; animation-delay: 0.36s; }
@keyframes radio-tap-splash {
from { transform: translate(0, 0); opacity: 1; }
50% { transform: translate(calc(var(--dx, -14px) * 0.6), -10px); }
to { transform: translate(var(--dx, -14px), 2px); opacity: 0; }
}
.radio-tap__read { display: grid; color: light-dark(#50535b, #c6c8cd); font-size: 12px; }
.radio-tap__read > * { grid-area: 1 / 1; text-align: center; opacity: 0; transform: translateY(4px); transition: opacity 0.25s, transform 0.3s; }
${at(1)} .radio-tap__read > :nth-child(1), ${at(2)} .radio-tap__read > :nth-child(2), ${at(3)} .radio-tap__read > :nth-child(3) { opacity: 1; transform: none; }
.radio-tap__opts { display: flex; gap: 3px; padding: 4px; border-radius: 12px; background: light-dark(#f3f4f7, #1a1c1f); box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035); }
.radio-tap__opt { position: relative; flex: 1; padding: 7px 12px; border-radius: 8px; color: light-dark(#5f626a, #a7abb2); font-size: 12.5px; font-weight: 500; text-align: center; white-space: nowrap; cursor: pointer; transition: background-color 0.25s, color 0.25s; }
.radio-tap__opt:hover { color: light-dark(#42454c, #e5e6e9); }
.radio-tap__opt:has(input:checked) { background: light-dark(#373a40, #f1f2f3); color: light-dark(#fbfcfd, #121417); }
.radio-tap__opt:has(input:focus-visible) { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 2px; }
.radio-tap__input { position: absolute; width: 1px; height: 1px; margin: 0; opacity: 0; pointer-events: none; }
@media (prefers-reduced-motion: reduce) {
.radio-tap * { animation: none !important; transition: none !important; }
.radio-tap__ring { opacity: 0.6; }
}
`;
export function TapRadio() {
const name = useId();
return (
<>
<style>
{css}
</style>
<div className="radio-tap">
<svg className="radio-tap__sink" viewBox="0 0 200 124" aria-hidden="true">
<rect className="radio-tap__wall" x={8} y={10} width={14} height={30} rx={3} />
<ellipse className="radio-tap__basin" cx={99} cy={112} rx={64} ry={7} />
<g className="radio-tap__flow radio-tap__flow--drip">
<path className="radio-tap__drop radio-tap__water" d="M99 42C102.5 47 104 50 104 52.5A5 5 0 0 1 94 52.5C94 50 95.5 47 99 42Z" />
<ellipse className="radio-tap__ring" cx={99} cy={111} rx={14} ry={2.5} />
</g>
<g className="radio-tap__flow radio-tap__flow--trickle">
<path className="radio-tap__stream radio-tap__water" d="M99 42C100 62 98 84 99 109" strokeWidth={2.2} strokeDasharray="12 6" />
<ellipse className="radio-tap__ring" cx={99} cy={111} rx={16} ry={3} />
</g>
<g className="radio-tap__flow radio-tap__flow--full">
<path className="radio-tap__water" d="M94 42H104C105 64 106 88 107 109H91C92 88 93 64 94 42Z" />
<path className="radio-tap__stream radio-tap__shine" d="M97 46C97.5 66 97 88 96.5 106" strokeWidth={1.5} strokeDasharray="9 9" />
<circle className="radio-tap__splash" cx={99} cy={108} r={2} />
<circle className="radio-tap__splash" cx={99} cy={108} r={1.6} />
<circle className="radio-tap__splash" cx={99} cy={108} r={1.8} />
<circle className="radio-tap__splash" cx={99} cy={108} r={1.4} />
<ellipse className="radio-tap__ring" cx={99} cy={111} rx={22} ry={4} />
<ellipse className="radio-tap__ring" cx={99} cy={111} rx={22} ry={4} />
</g>
<path className="radio-tap__metal" d="M22 17H92Q104 17 104 29V42H94V31Q94 28 91 28H22Z" />
<rect className="radio-tap__metal" x={54} y={9} width={4} height={9} />
<rect className="radio-tap__metal radio-tap__lever" x={40} y={5} width={32} height={5} rx={2.5} />
</svg>
<span className="radio-tap__read" aria-hidden="true">
{OPTIONS.map((o) => (
<span key={o.value}>{o.note}</span>
))}
</span>
<div className="radio-tap__opts" role="radiogroup" aria-label="Water flow">
{OPTIONS.map((o, i) => (
<label key={o.value} className="radio-tap__opt">
<input className="radio-tap__input" type="radio" name={name} value={o.value} defaultChecked={i === 0} />
{o.label}
</label>
))}
</div>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet. No JavaScript.
<div class="radio-tap">
<svg class="radio-tap__sink" viewBox="0 0 200 124" aria-hidden="true">
<rect class="radio-tap__wall" x="8" y="10" width="14" height="30" rx="3"></rect>
<ellipse class="radio-tap__basin" cx="99" cy="112" rx="64" ry="7"></ellipse>
<g class="radio-tap__flow radio-tap__flow--drip">
<path class="radio-tap__drop radio-tap__water" d="M99 42C102.5 47 104 50 104 52.5A5 5 0 0 1 94 52.5C94 50 95.5 47 99 42Z"></path>
<ellipse class="radio-tap__ring" cx="99" cy="111" rx="14" ry="2.5"></ellipse>
</g>
<g class="radio-tap__flow radio-tap__flow--trickle">
<path class="radio-tap__stream radio-tap__water" d="M99 42C100 62 98 84 99 109" stroke-width="2.2" stroke-dasharray="12 6"></path>
<ellipse class="radio-tap__ring" cx="99" cy="111" rx="16" ry="3"></ellipse>
</g>
<g class="radio-tap__flow radio-tap__flow--full">
<path class="radio-tap__water" d="M94 42H104C105 64 106 88 107 109H91C92 88 93 64 94 42Z"></path>
<path class="radio-tap__stream radio-tap__shine" d="M97 46C97.5 66 97 88 96.5 106" stroke-width="1.5" stroke-dasharray="9 9"></path>
<circle class="radio-tap__splash" cx="99" cy="108" r="2"></circle>
<circle class="radio-tap__splash" cx="99" cy="108" r="1.6"></circle>
<circle class="radio-tap__splash" cx="99" cy="108" r="1.8"></circle>
<circle class="radio-tap__splash" cx="99" cy="108" r="1.4"></circle>
<ellipse class="radio-tap__ring" cx="99" cy="111" rx="22" ry="4"></ellipse>
<ellipse class="radio-tap__ring" cx="99" cy="111" rx="22" ry="4"></ellipse>
</g>
<path class="radio-tap__metal" d="M22 17H92Q104 17 104 29V42H94V31Q94 28 91 28H22Z"></path>
<rect class="radio-tap__metal" x="54" y="9" width="4" height="9"></rect>
<rect class="radio-tap__metal radio-tap__lever" x="40" y="5" width="32" height="5" rx="2.5"></rect>
</svg>
<span class="radio-tap__read" aria-hidden="true">
<span>A drop every second or so</span>
<span>A thin, steady thread</span>
<span>Wide open, with splashes</span>
</span>
<div class="radio-tap__opts" role="radiogroup" aria-label="Water flow">
<label class="radio-tap__opt">
<input class="radio-tap__input" type="radio" name="radio-tap" checked="" value="drip"/>
Drip
</label>
<label class="radio-tap__opt">
<input class="radio-tap__input" type="radio" name="radio-tap" value="trickle"/>
Trickle
</label>
<label class="radio-tap__opt">
<input class="radio-tap__input" type="radio" name="radio-tap" value="full"/>
Full
</label>
</div>
</div>.radio-tap { display: grid; justify-items: center; gap: 14px; width: 260px; }
.radio-tap__sink { display: block; width: 200px; height: 124px; overflow: visible; }
.radio-tap__metal { fill: light-dark(#6d7179, #7d8087); }
.radio-tap__wall { fill: light-dark(#dbdde1, #2e3035); }
.radio-tap__basin { fill: light-dark(#ebedf0, #232529); }
.radio-tap__water { fill: light-dark(#50535b, #c6c8cd); stroke: light-dark(#50535b, #c6c8cd); }
.radio-tap__shine { fill: none; stroke: light-dark(#373a40, #f1f2f3); }
.radio-tap__ring { fill: none; stroke: light-dark(#5f626a, #a7abb2); stroke-width: 1.2; transform-box: fill-box; transform-origin: center; opacity: 0; }
/* The handle turns further for each setting. */
.radio-tap__lever { transform-box: view-box; transform-origin: 56px 9px; transition: transform 0.5s cubic-bezier(0.3, 1.4, 0.5, 1); }
.radio-tap:has(.radio-tap__opt:nth-child(2) input:checked) .radio-tap__lever { transform: rotate(-28deg); }
.radio-tap:has(.radio-tap__opt:nth-child(3) input:checked) .radio-tap__lever { transform: rotate(-60deg); }
.radio-tap__flow { opacity: 0; transition: opacity 0.3s; }
.radio-tap:has(.radio-tap__opt:nth-child(1) input:checked) .radio-tap__flow--drip, .radio-tap:has(.radio-tap__opt:nth-child(2) input:checked) .radio-tap__flow--trickle, .radio-tap:has(.radio-tap__opt:nth-child(3) input:checked) .radio-tap__flow--full { opacity: 1; }
/* Drip: a drop swells at the spout, lets go, falls, and rings the water where it lands. */
.radio-tap__drop { transform-box: fill-box; transform-origin: 50% 0; animation: radio-tap-drop 1.4s infinite; }
@keyframes radio-tap-drop {
0% { transform: translateY(0) scale(0.2); animation-timing-function: ease-out; }
40% { transform: translateY(0) scale(1); animation-timing-function: cubic-bezier(0.5, 0, 1, 0.6); }
72% { transform: translateY(58px) scale(0.9, 1.15); opacity: 1; }
73%, 100% { transform: translateY(58px); opacity: 0; }
}
.radio-tap__flow--drip .radio-tap__ring { animation: radio-tap-ring 1.4s ease-out infinite; }
@keyframes radio-tap-ring {
0%, 72% { opacity: 0; transform: scale(0.2); }
74% { opacity: 1; }
100% { opacity: 0; transform: scale(1.4); }
}
/* Trickle and full: the stream's dashes run downward, and the surface keeps rippling. */
.radio-tap__stream { fill: none; stroke-linecap: round; animation: radio-tap-run 0.35s linear infinite; }
@keyframes radio-tap-run {
to { stroke-dashoffset: -18; }
}
.radio-tap__flow--trickle .radio-tap__ring { animation: radio-tap-ripple 0.9s ease-out infinite; }
.radio-tap__flow--full .radio-tap__ring { animation: radio-tap-ripple 0.6s ease-out infinite; }
.radio-tap__flow--full .radio-tap__ring + .radio-tap__ring { animation-delay: 0.3s; }
@keyframes radio-tap-ripple {
from { opacity: 0.9; transform: scale(0.3); }
to { opacity: 0; transform: scale(1.3); }
}
.radio-tap__splash { fill: light-dark(#50535b, #c6c8cd); animation: radio-tap-splash 0.5s ease-out infinite; }
.radio-tap__splash:nth-of-type(2) { --dx: 12px; animation-delay: 0.12s; }
.radio-tap__splash:nth-of-type(3) { --dx: -10px; animation-delay: 0.24s; }
.radio-tap__splash:nth-of-type(4) { --dx: 16px; animation-delay: 0.36s; }
@keyframes radio-tap-splash {
from { transform: translate(0, 0); opacity: 1; }
50% { transform: translate(calc(var(--dx, -14px) * 0.6), -10px); }
to { transform: translate(var(--dx, -14px), 2px); opacity: 0; }
}
.radio-tap__read { display: grid; color: light-dark(#50535b, #c6c8cd); font-size: 12px; }
.radio-tap__read > * { grid-area: 1 / 1; text-align: center; opacity: 0; transform: translateY(4px); transition: opacity 0.25s, transform 0.3s; }
.radio-tap:has(.radio-tap__opt:nth-child(1) input:checked) .radio-tap__read > :nth-child(1), .radio-tap:has(.radio-tap__opt:nth-child(2) input:checked) .radio-tap__read > :nth-child(2), .radio-tap:has(.radio-tap__opt:nth-child(3) input:checked) .radio-tap__read > :nth-child(3) { opacity: 1; transform: none; }
.radio-tap__opts { display: flex; gap: 3px; padding: 4px; border-radius: 12px; background: light-dark(#f3f4f7, #1a1c1f); box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035); }
.radio-tap__opt { position: relative; flex: 1; padding: 7px 12px; border-radius: 8px; color: light-dark(#5f626a, #a7abb2); font-size: 12.5px; font-weight: 500; text-align: center; white-space: nowrap; cursor: pointer; transition: background-color 0.25s, color 0.25s; }
.radio-tap__opt:hover { color: light-dark(#42454c, #e5e6e9); }
.radio-tap__opt:has(input:checked) { background: light-dark(#373a40, #f1f2f3); color: light-dark(#fbfcfd, #121417); }
.radio-tap__opt:has(input:focus-visible) { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 2px; }
.radio-tap__input { position: absolute; width: 1px; height: 1px; margin: 0; opacity: 0; pointer-events: none; }
@media (prefers-reduced-motion: reduce) {
.radio-tap * { animation: none !important; transition: none !important; }
.radio-tap__ring { opacity: 0.6; }
}Goal: Create a "Tap" radio.
Visual behavior: A water flow picker: the handle turns further each step, from a drop that swells, falls and rings the water, to a thin running thread, to a full stream with splashes.
Technique:
- Native radio inputs sharing one name inside a labelled role="radiogroup", visually hidden but focusable; each option is a <label> wrapping its input, so the arrow keys and clicks both work.
- The scene reacts with :has() on the root, one selector per option (such as :has(.opt:nth-child(2) input:checked)), so no script is needed.
- The lever rotates further for each step. Drip loops a drop that swells, falls and sends a ring across the water; trickle runs a thin moving thread; full runs a wide stream with ripples and splashes.
Accessibility: A role="radiogroup" with an aria-label around native radio inputs sharing one name, so the arrow keys move the choice and Tab enters the group once; :focus-visible on the input outlines its option. The drawing is aria-hidden. Under prefers-reduced-motion the flow is drawn still.
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 "Tap" radio.
Visual behavior: A water flow picker: the handle turns further each step, from a drop that swells, falls and rings the water, to a thin running thread, to a full stream with splashes.
Technique:
- Native radio inputs sharing one name inside a labelled role="radiogroup", visually hidden but focusable; each option is a <label> wrapping its input, so the arrow keys and clicks both work.
- The scene reacts with :has() on the root, one selector per option (such as :has(.opt:nth-child(2) input:checked)), so no script is needed.
- The lever rotates further for each step. Drip loops a drop that swells, falls and sends a ring across the water; trickle runs a thin moving thread; full runs a wide stream with ripples and splashes.
Accessibility: A role="radiogroup" with an aria-label around native radio inputs sharing one name, so the arrow keys move the choice and Tab enters the group once; :focus-visible on the input outlines its option. The drawing is aria-hidden. Under prefers-reduced-motion the flow is drawn still.
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 water flow picker: the handle turns further each step, from a drop that swells, falls and rings the water, to a thin running thread, to a full stream with splashes.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- click, state change, loop, keyframe
- Techniques
- keyframes, css transition, svg
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
A role="radiogroup" with an aria-label around native radio inputs sharing one name, so the arrow keys move the choice and Tab enters the group once; :focus-visible on the input outlines its option. The drawing is aria-hidden. Under prefers-reduced-motion the flow is drawn still.