Hose
import { useState, type CSSProperties } from 'react';
const GROUND = 104;
const TIP = { x: 40, y: 66 }; // where the water leaves the nozzle
const POT = 178;
const M = 34; // pixels to the metre along the lawn
// Where the stream lands for a pressure, and the arc it takes to get there.
const reach = (v: number) => 50 + v * 166;
function arc(v: number) {
const x = reach(v);
const run = x - TIP.x;
return `M${TIP.x} ${TIP.y} Q${(TIP.x + run * 0.42).toFixed(1)} ${(TIP.y - 8 - run * 0.3).toFixed(1)} ${x.toFixed(1)} ${GROUND}`;
}
const css = `
.slider-hose { --water: light-dark(#5f626a, #c6c8cd); display: grid; gap: 10px; width: 230px; }
.slider-hose__scene { display: block; width: 230px; height: 120px; overflow: visible; }
.slider-hose__lawn { fill: none; stroke: light-dark(#cbced3, #393c41); stroke-width: 2; stroke-linecap: round; }
.slider-hose__mark { fill: light-dark(#6d7179, #7d8087); font-size: 8px; font-weight: 600; text-anchor: middle; }
.slider-hose__tick { stroke: light-dark(#cbced3, #393c41); stroke-width: 1.5; }
.slider-hose__hose { fill: none; stroke: light-dark(#6d7179, #7d8087); stroke-width: 5; stroke-linecap: round; }
.slider-hose__nozzle { fill: light-dark(#50535b, #c6c8cd); }
/* The stream: a soft body with dashes running along it, so it reads as flowing. */
.slider-hose__jet, .slider-hose__flow { fill: none; stroke-linecap: round; transition: opacity 0.2s; }
.slider-hose__jet { stroke: color-mix(in oklab, var(--water) 55%, transparent); stroke-width: calc(1.5px + var(--slider-hose-v) * 3px); }
.slider-hose__flow { stroke: var(--water); stroke-width: calc(1px + var(--slider-hose-v) * 1.6px); stroke-dasharray: 7 5; animation: slider-hose-flow 0.3s linear infinite; }
.slider-hose[data-off] .slider-hose__jet, .slider-hose[data-off] .slider-hose__flow, .slider-hose[data-off] .slider-hose__splash { opacity: 0; }
.slider-hose__drip { fill: var(--water); opacity: 0; }
.slider-hose[data-off] .slider-hose__drip { opacity: 1; animation: slider-hose-drip 1.4s ease-in infinite; }
.slider-hose__splash { transform: translateX(var(--slider-hose-x)); }
.slider-hose__splash circle { fill: var(--water); animation: slider-hose-splash 0.55s ease-out infinite; animation-delay: var(--d); }
.slider-hose__puddle { fill: color-mix(in oklab, var(--water) 45%, transparent); }
/* The pot: the flower droops until the water reaches it. */
.slider-hose__pot { fill: light-dark(#6d7179, #7d8087); }
.slider-hose__plant { fill: none; stroke: light-dark(#5f626a, #a7abb2); stroke-width: 2; stroke-linecap: round; transform-box: view-box; transform-origin: ${POT}px 88px; rotate: 38deg; transition: rotate 0.7s cubic-bezier(0.3, 1.5, 0.5, 1); }
.slider-hose__petal { fill: light-dark(#42454c, #e5e6e9); stroke: none; }
.slider-hose[data-watered] .slider-hose__plant { rotate: 0deg; }
.slider-hose__read { display: flex; justify-content: space-between; color: light-dark(#5f626a, #a7abb2); font-size: 11px; font-variant-numeric: tabular-nums; }
.slider-hose__read b { color: light-dark(#373a40, #f1f2f3); font-weight: 600; }
.slider-hose__track { position: relative; height: 22px; }
.slider-hose__rail { position: absolute; left: 0; right: 0; top: 9px; height: 4px; border-radius: 2px; background: light-dark(#dbdde1, #2e3035); }
.slider-hose__fill { position: absolute; left: 0; top: 9px; height: 4px; border-radius: 2px; width: calc(11px + var(--slider-hose-v) * (100% - 22px)); background: var(--water); }
.slider-hose__thumb { position: absolute; top: 0; left: calc(var(--slider-hose-v) * (100% - 22px)); width: 22px; height: 22px; border-radius: 50%; background: light-dark(#373a40, #f1f2f3); box-shadow: 0 2px 6px light-dark(rgba(0, 0, 0, 0.14), rgba(0, 0, 0, 0.45)); }
.slider-hose__thumb::after { content: ''; position: absolute; left: 7px; top: 6px; width: 8px; height: 8px; border-radius: 50% 0 50% 50%; rotate: -45deg; background: light-dark(#ebedf0, #232529); }
.slider-hose:has(.slider-hose__input:focus-visible) .slider-hose__thumb { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 3px; }
.slider-hose__input { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; opacity: 0; cursor: pointer; -webkit-appearance: none; appearance: none; background: none; }
.slider-hose__input::-webkit-slider-thumb { -webkit-appearance: none; width: 22px; height: 22px; }
.slider-hose__input::-moz-range-thumb { width: 22px; height: 22px; border: 0; }
@keyframes slider-hose-flow { to { stroke-dashoffset: -12; } }
@keyframes slider-hose-drip { 0%, 30% { transform: translateY(0) scale(0.4); opacity: 0; } 45% { transform: translateY(0) scale(1); opacity: 1; } 100% { transform: translateY(34px) scale(1, 1.3); opacity: 0; } }
@keyframes slider-hose-splash { from { transform: translate(0, 0) scale(1); opacity: 1; } to { transform: translate(var(--sx), -12px) scale(0.4); opacity: 0; } }
@media (prefers-reduced-motion: reduce) {
.slider-hose * { animation: none !important; transition: none !important; }
}
`;
export function Preview() {
const [p, setP] = useState(55);
const v = p / 100;
const x = reach(v);
const off = p === 0;
const metres = ((x - TIP.x) / M).toFixed(1);
return (
<>
<style href="kit-sliders-hose" precedence="kit">
{css}
</style>
<div
className="slider-hose"
data-off={off ? '' : undefined}
data-watered={!off && Math.abs(x - POT) < 13 ? '' : undefined}
style={{ '--slider-hose-v': v, '--slider-hose-x': `${x.toFixed(1)}px` } as CSSProperties}
>
<svg className="slider-hose__scene" viewBox="0 0 230 120" aria-hidden="true">
<path className="slider-hose__lawn" d={`M6 ${GROUND} H224`} />
{[1, 2, 3, 4, 5].map((m) => (
<g key={m}>
<path className="slider-hose__tick" d={`M${TIP.x + m * M} ${GROUND} v4`} />
<text className="slider-hose__mark" x={TIP.x + m * M} y={GROUND + 13}>
{m} m
</text>
</g>
))}
<path className="slider-hose__hose" d={`M4 ${GROUND - 2} C18 ${GROUND - 2} 6 ${TIP.y + 16} 24 ${TIP.y + 8}`} />
<path className="slider-hose__nozzle" d={`M22 ${TIP.y + 5} L${TIP.x} ${TIP.y - 4} L${TIP.x + 2} ${TIP.y + 1} L26 ${TIP.y + 12} Z`} />
<ellipse className="slider-hose__drip" cx={TIP.x + 1} cy={TIP.y + 2} rx="1.8" ry="2.4" />
<path className="slider-hose__jet" d={arc(v)} />
<path className="slider-hose__flow" d={arc(v)} />
<g className="slider-hose__splash">
<ellipse className="slider-hose__puddle" cx="0" cy={GROUND} rx={6 + v * 6} ry="2" />
{[-8, -3, 3, 8].map((sx, i) => (
<circle key={sx} cx="0" cy={GROUND - 1} r="1.6" style={{ '--sx': `${sx}px`, '--d': `${-i * 0.14}s` } as CSSProperties} />
))}
</g>
<g className="slider-hose__plant">
<path d={`M${POT} 88 C${POT - 2} 78 ${POT + 2} 70 ${POT} 62`} />
<path d={`M${POT} 76 q-7 -2 -9 -8 q7 0 9 8`} />
<circle className="slider-hose__petal" cx={POT} cy="58" r="5" />
<circle className="slider-hose__petal" cx={POT - 5} cy="61" r="3.4" />
<circle className="slider-hose__petal" cx={POT + 5} cy="61" r="3.4" />
</g>
<path className="slider-hose__pot" d={`M${POT - 11} 88 H${POT + 11} L${POT + 8} ${GROUND} H${POT - 8} Z`} />
</svg>
<div className="slider-hose__read" aria-hidden="true">
<span>
Pressure <b>{p}%</b>
</span>
<span>
Reach <b>{off ? '0.0' : metres} m</b>
</span>
</div>
<div className="slider-hose__track">
<span className="slider-hose__rail" aria-hidden="true" />
<span className="slider-hose__fill" aria-hidden="true" />
<span className="slider-hose__thumb" aria-hidden="true" />
<input className="slider-hose__input" type="range" min={0} max={100} value={p} onChange={(e) => setP(+e.target.value)} aria-label="Water pressure" aria-valuetext={`${p} percent, reaches ${off ? 0 : metres} metres`} />
</div>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initSliderHose(el) on the mounted element instead.
<div class="slider-hose" style="--slider-hose-v:0.55;--slider-hose-x:141.3px">
<svg class="slider-hose__scene" viewBox="0 0 230 120" aria-hidden="true">
<path class="slider-hose__lawn" d="M6 104 H224"></path>
<g>
<path class="slider-hose__tick" d="M74 104 v4"></path>
<text class="slider-hose__mark" x="74" y="117">1 m</text>
</g>
<g>
<path class="slider-hose__tick" d="M108 104 v4"></path>
<text class="slider-hose__mark" x="108" y="117">2 m</text>
</g>
<g>
<path class="slider-hose__tick" d="M142 104 v4"></path>
<text class="slider-hose__mark" x="142" y="117">3 m</text>
</g>
<g>
<path class="slider-hose__tick" d="M176 104 v4"></path>
<text class="slider-hose__mark" x="176" y="117">4 m</text>
</g>
<g>
<path class="slider-hose__tick" d="M210 104 v4"></path>
<text class="slider-hose__mark" x="210" y="117">5 m</text>
</g>
<path class="slider-hose__hose" d="M4 102 C18 102 6 82 24 74"></path>
<path class="slider-hose__nozzle" d="M22 71 L40 62 L42 67 L26 78 Z"></path>
<ellipse class="slider-hose__drip" cx="41" cy="68" rx="1.8" ry="2.4"></ellipse>
<path class="slider-hose__jet" d="M40 66 Q82.5 27.6 141.3 104"></path>
<path class="slider-hose__flow" d="M40 66 Q82.5 27.6 141.3 104"></path>
<g class="slider-hose__splash">
<ellipse class="slider-hose__puddle" cx="0" cy="104" rx="9.3" ry="2"></ellipse>
<circle cx="0" cy="103" r="1.6" style="--sx:-8px;--d:0s"></circle>
<circle cx="0" cy="103" r="1.6" style="--sx:-3px;--d:-0.14s"></circle>
<circle cx="0" cy="103" r="1.6" style="--sx:3px;--d:-0.28s"></circle>
<circle cx="0" cy="103" r="1.6" style="--sx:8px;--d:-0.42000000000000004s"></circle>
</g>
<g class="slider-hose__plant">
<path d="M178 88 C176 78 180 70 178 62"></path>
<path d="M178 76 q-7 -2 -9 -8 q7 0 9 8"></path>
<circle class="slider-hose__petal" cx="178" cy="58" r="5"></circle>
<circle class="slider-hose__petal" cx="173" cy="61" r="3.4"></circle>
<circle class="slider-hose__petal" cx="183" cy="61" r="3.4"></circle>
</g>
<path class="slider-hose__pot" d="M167 88 H189 L186 104 H170 Z"></path>
</svg>
<div class="slider-hose__read" aria-hidden="true">
<span>
Pressure
<b>55%</b>
</span>
<span>
Reach
<b>3.0 m</b>
</span>
</div>
<div class="slider-hose__track">
<span class="slider-hose__rail" aria-hidden="true"></span>
<span class="slider-hose__fill" aria-hidden="true"></span>
<span class="slider-hose__thumb" aria-hidden="true"></span>
<input class="slider-hose__input" type="range" min="0" max="100" aria-label="Water pressure" aria-valuetext="55 percent, reaches 3.0 metres" value="55"/>
</div>
</div>.slider-hose { --water: light-dark(#5f626a, #c6c8cd); display: grid; gap: 10px; width: 230px; }
.slider-hose__scene { display: block; width: 230px; height: 120px; overflow: visible; }
.slider-hose__lawn { fill: none; stroke: light-dark(#cbced3, #393c41); stroke-width: 2; stroke-linecap: round; }
.slider-hose__mark { fill: light-dark(#6d7179, #7d8087); font-size: 8px; font-weight: 600; text-anchor: middle; }
.slider-hose__tick { stroke: light-dark(#cbced3, #393c41); stroke-width: 1.5; }
.slider-hose__hose { fill: none; stroke: light-dark(#6d7179, #7d8087); stroke-width: 5; stroke-linecap: round; }
.slider-hose__nozzle { fill: light-dark(#50535b, #c6c8cd); }
/* The stream: a soft body with dashes running along it, so it reads as flowing. */
.slider-hose__jet, .slider-hose__flow { fill: none; stroke-linecap: round; transition: opacity 0.2s; }
.slider-hose__jet { stroke: color-mix(in oklab, var(--water) 55%, transparent); stroke-width: calc(1.5px + var(--slider-hose-v) * 3px); }
.slider-hose__flow { stroke: var(--water); stroke-width: calc(1px + var(--slider-hose-v) * 1.6px); stroke-dasharray: 7 5; animation: slider-hose-flow 0.3s linear infinite; }
.slider-hose[data-off] .slider-hose__jet, .slider-hose[data-off] .slider-hose__flow, .slider-hose[data-off] .slider-hose__splash { opacity: 0; }
.slider-hose__drip { fill: var(--water); opacity: 0; }
.slider-hose[data-off] .slider-hose__drip { opacity: 1; animation: slider-hose-drip 1.4s ease-in infinite; }
.slider-hose__splash { transform: translateX(var(--slider-hose-x)); }
.slider-hose__splash circle { fill: var(--water); animation: slider-hose-splash 0.55s ease-out infinite; animation-delay: var(--d); }
.slider-hose__puddle { fill: color-mix(in oklab, var(--water) 45%, transparent); }
/* The pot: the flower droops until the water reaches it. */
.slider-hose__pot { fill: light-dark(#6d7179, #7d8087); }
.slider-hose__plant { fill: none; stroke: light-dark(#5f626a, #a7abb2); stroke-width: 2; stroke-linecap: round; transform-box: view-box; transform-origin: 178px 88px; rotate: 38deg; transition: rotate 0.7s cubic-bezier(0.3, 1.5, 0.5, 1); }
.slider-hose__petal { fill: light-dark(#42454c, #e5e6e9); stroke: none; }
.slider-hose[data-watered] .slider-hose__plant { rotate: 0deg; }
.slider-hose__read { display: flex; justify-content: space-between; color: light-dark(#5f626a, #a7abb2); font-size: 11px; font-variant-numeric: tabular-nums; }
.slider-hose__read b { color: light-dark(#373a40, #f1f2f3); font-weight: 600; }
.slider-hose__track { position: relative; height: 22px; }
.slider-hose__rail { position: absolute; left: 0; right: 0; top: 9px; height: 4px; border-radius: 2px; background: light-dark(#dbdde1, #2e3035); }
.slider-hose__fill { position: absolute; left: 0; top: 9px; height: 4px; border-radius: 2px; width: calc(11px + var(--slider-hose-v) * (100% - 22px)); background: var(--water); }
.slider-hose__thumb { position: absolute; top: 0; left: calc(var(--slider-hose-v) * (100% - 22px)); width: 22px; height: 22px; border-radius: 50%; background: light-dark(#373a40, #f1f2f3); box-shadow: 0 2px 6px light-dark(rgba(0, 0, 0, 0.14), rgba(0, 0, 0, 0.45)); }
.slider-hose__thumb::after { content: ''; position: absolute; left: 7px; top: 6px; width: 8px; height: 8px; border-radius: 50% 0 50% 50%; rotate: -45deg; background: light-dark(#ebedf0, #232529); }
.slider-hose:has(.slider-hose__input:focus-visible) .slider-hose__thumb { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 3px; }
.slider-hose__input { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; opacity: 0; cursor: pointer; -webkit-appearance: none; appearance: none; background: none; }
.slider-hose__input::-webkit-slider-thumb { -webkit-appearance: none; width: 22px; height: 22px; }
.slider-hose__input::-moz-range-thumb { width: 22px; height: 22px; border: 0; }
@keyframes slider-hose-flow { to { stroke-dashoffset: -12; } }
@keyframes slider-hose-drip { 0%, 30% { transform: translateY(0) scale(0.4); opacity: 0; } 45% { transform: translateY(0) scale(1); opacity: 1; } 100% { transform: translateY(34px) scale(1, 1.3); opacity: 0; } }
@keyframes slider-hose-splash { from { transform: translate(0, 0) scale(1); opacity: 1; } to { transform: translate(var(--sx), -12px) scale(0.4); opacity: 0; } }
@media (prefers-reduced-motion: reduce) {
.slider-hose * { animation: none !important; transition: none !important; }
}function initSliderHose(root) {
var input = root.querySelector('.slider-hose__input');
var streams = root.querySelectorAll('.slider-hose__jet, .slider-hose__flow');
var puddle = root.querySelector('.slider-hose__puddle');
var reads = root.querySelectorAll('.slider-hose__read b');
var ground = 104;
var tip = { x: 40, y: 66 };
var pot = 178;
var metre = 34;
// Where the stream lands for a pressure, and the arc it takes to get there.
function arc(x) {
var run = x - tip.x;
return 'M' + tip.x + ' ' + tip.y + ' Q' + (tip.x + run * 0.42).toFixed(1) + ' ' + (tip.y - 8 - run * 0.3).toFixed(1) + ' ' + x.toFixed(1) + ' ' + ground;
}
function update() {
var p = +input.value;
var v = p / 100;
var x = 50 + v * 166;
var off = p === 0;
var reach = off ? '0.0' : ((x - tip.x) / metre).toFixed(1);
root.toggleAttribute('data-off', off);
// Land the stream on the pot and the flower perks up.
root.toggleAttribute('data-watered', !off && Math.abs(x - pot) < 13);
root.style.setProperty('--slider-hose-v', v);
root.style.setProperty('--slider-hose-x', x.toFixed(1) + 'px');
streams.forEach(function (path) { path.setAttribute('d', arc(x)); });
puddle.setAttribute('rx', 6 + v * 6);
reads[0].textContent = p + '%';
reads[1].textContent = reach + ' m';
input.setAttribute('aria-valuetext', p + ' percent, reaches ' + (off ? 0 : reach) + ' metres');
}
input.addEventListener('input', update);
update();
return function () { input.removeEventListener('input', update); };
}
document.querySelectorAll('.slider-hose').forEach(initSliderHose);Goal: Create a "Hose" slider.
Visual behavior: Water pressure: the stream arcs further from the nozzle and splashes where it lands; reach the pot and the flower perks up.
Technique:
- One real <input type="range"> lies transparent over the track (its thumb sized to the drawn handle, so the pointer lines up), and everything drawn is set from its value.
- The stream is a quadratic curve from the nozzle to the landing point, which moves out with pressure; the stream also thickens.
- Dashes run along a softer body of water, so it reads as flowing. A splash plays where it lands and a puddle spreads there.
- At zero the stream stops and the nozzle drips. Land the water on the pot and the flower swings upright on a spring.
Accessibility: A native range input named "Water pressure" with an aria-valuetext in words, so a screen reader announces the reading rather than a bare number; keyboard focus outlines the drawn handle. The drawing is aria-hidden. Under prefers-reduced-motion the stream and splash hold 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 "Hose" slider.
Visual behavior: Water pressure: the stream arcs further from the nozzle and splashes where it lands; reach the pot and the flower perks up.
Technique:
- One real <input type="range"> lies transparent over the track (its thumb sized to the drawn handle, so the pointer lines up), and everything drawn is set from its value.
- The stream is a quadratic curve from the nozzle to the landing point, which moves out with pressure; the stream also thickens.
- Dashes run along a softer body of water, so it reads as flowing. A splash plays where it lands and a puddle spreads there.
- At zero the stream stops and the nozzle drips. Land the water on the pot and the flower swings upright on a spring.
Accessibility: A native range input named "Water pressure" with an aria-valuetext in words, so a screen reader announces the reading rather than a bare number; keyboard focus outlines the drawn handle. The drawing is aria-hidden. Under prefers-reduced-motion the stream and splash hold 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
Water pressure: the stream arcs further from the nozzle and splashes where it lands; reach the pot and the flower perks up.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- state change, continuous, spring
- Techniques
- svg, stroke dashoffset, keyframes, css variables
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
A native range input named "Water pressure" with an aria-valuetext in words, so a screen reader announces the reading rather than a bare number; keyboard focus outlines the drawn handle. The drawing is aria-hidden. Under prefers-reduced-motion the stream and splash hold still.