Hose

No dependencies
Code format
React + Tailwind
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>
    </>
  );
}

Free to use in any project, commercial included. MIT-0, no attribution required. Licence

Install with shadcn

npx shadcn@latest add @movin/slider-hose
Source on GitHub

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.

More sliders

See all
Nesting Dolls
Phase
99.1MHzClassic
Radio
Scissor Lift