Dive

No dependencies
Code format
React + Tailwind
import { useState, type CSSProperties, type KeyboardEvent } from 'react';

const MAX = 40; // metres
const TORCH = 25;
const TOP = 12; // where 0 m sits on the gauge
const SPAN = 172; // gauge length, 0 to 40 m

const css = `
  .slider-dive { --deep: light-dark(#373a40, #121417); display: grid; grid-template-columns: 150px 1fr; gap: 14px; width: 230px; height: 196px; }
  .slider-dive__sea { position: relative; overflow: hidden; border-radius: 12px; box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035); }
  /* Everything in the water dims with depth; the torch beam is drawn over it, undimmed. */
  .slider-dive__scene { position: absolute; inset: 0; background: linear-gradient(light-dark(#dbdde1, #393c41), light-dark(#5f626a, #1a1c1f) 70%, var(--deep)); filter: brightness(calc(1 - var(--slider-dive-v) * 0.6)); transition: filter 0.3s; }
  .slider-dive__surface { position: absolute; left: -20px; right: -20px; top: 4px; height: 6px; background: radial-gradient(10px 4px at 10px 0, transparent 9px, color-mix(in oklab, light-dark(#373a40, #f1f2f3) 35%, transparent) 9.5px 10.5px, transparent 11px) 0 0 / 20px 6px repeat-x; animation: slider-dive-swell 2.4s linear infinite; }
  .slider-dive__ray { position: absolute; top: -10px; width: 22px; height: 150px; transform-origin: 50% 0; background: linear-gradient(color-mix(in oklab, light-dark(#373a40, #f1f2f3) 30%, transparent), transparent); opacity: calc(0.75 - var(--slider-dive-v) * 0.7); animation: slider-dive-ray 5s ease-in-out infinite alternate; }
  .slider-dive__fish { position: absolute; left: 0; top: 74px; width: 46px; height: 18px; animation: slider-dive-school 7s ease-in-out infinite alternate; }
  .slider-dive__fish i { position: absolute; width: 9px; height: 4px; border-radius: 50%; background: light-dark(#50535b, #c6c8cd); }
  .slider-dive__fish i::after { content: ''; position: absolute; left: -3px; top: 0; border: 2px solid transparent; border-right: 3px solid light-dark(#50535b, #c6c8cd); }
  .slider-dive__bed { position: absolute; left: -10px; right: -10px; bottom: -14px; height: 34px; border-radius: 50% 50% 0 0 / 70% 70% 0 0; background: light-dark(#6d7179, #2e3035); }
  .slider-dive__weed { position: absolute; bottom: 10px; width: 3px; height: 26px; border-radius: 2px; transform-origin: 50% 100%; background: light-dark(#5f626a, #393c41); animation: slider-dive-weed 3s ease-in-out infinite alternate; }
  /* The diver, head down, kicking. */
  .slider-dive__diver { position: absolute; left: 38px; top: 0; width: 64px; height: 30px; translate: 0 calc(10px + var(--slider-dive-v) * 118px); transition: translate 0.45s cubic-bezier(0.3, 1.1, 0.5, 1); }
  .slider-dive__diver svg { width: 100%; height: 100%; overflow: visible; rotate: 28deg; }
  .slider-dive__body { fill: light-dark(#42454c, #e5e6e9); }
  .slider-dive__tank { fill: light-dark(#5f626a, #a7abb2); }
  .slider-dive__limb { fill: light-dark(#50535b, #c6c8cd); }
  .slider-dive__arm { stroke: light-dark(#42454c, #e5e6e9); stroke-width: 3.5; stroke-linecap: round; }
  .slider-dive__torch { fill: light-dark(#5f626a, #a7abb2); }
  .slider-dive__mask { fill: light-dark(#fbfcfd, #121417); stroke: light-dark(#5f626a, #a7abb2); stroke-width: 1; }
  .slider-dive__reg { fill: light-dark(#6d7179, #7d8087); }
  .slider-dive__fin { fill: light-dark(#50535b, #c6c8cd); transform-box: fill-box; transform-origin: 100% 50%; animation: slider-dive-kick 0.7s ease-in-out infinite alternate; }
  .slider-dive__fin--b { animation-delay: -0.35s; }
  .slider-dive__bubble { position: absolute; left: 50px; top: 14px; width: 4px; height: 4px; border-radius: 50%; box-shadow: inset 0 0 0 1px color-mix(in oklab, light-dark(#373a40, #f1f2f3) 70%, transparent); animation: slider-dive-bubble 1.8s ease-in infinite; }
  .slider-dive__beam { position: absolute; left: 92px; top: 0; width: 70px; height: 60px; translate: 0 calc(38px + var(--slider-dive-v) * 118px); transform-origin: 0 0; rotate: 34deg; clip-path: polygon(0 0, 100% -40%, 100% 140%); background: linear-gradient(90deg, color-mix(in oklab, light-dark(#373a40, #f1f2f3) 55%, transparent), transparent); opacity: 0; transition: opacity 0.35s, translate 0.45s cubic-bezier(0.3, 1.1, 0.5, 1); }
  .slider-dive[data-torch] .slider-dive__beam { opacity: 1; animation: slider-dive-torch 0.4s steps(1); }
  /* The gauge: depth runs down, the handle is a tag with the reading. */
  .slider-dive__gauge { position: relative; }
  .slider-dive__line { position: absolute; left: 6px; top: ${TOP}px; height: ${SPAN}px; width: 2px; border-radius: 1px; background: light-dark(#dbdde1, #2e3035); }
  .slider-dive__line::after { content: ''; position: absolute; left: 0; top: 0; width: 100%; height: calc(var(--slider-dive-v) * 100%); border-radius: inherit; background: light-dark(#50535b, #c6c8cd); }
  .slider-dive__tick { position: absolute; left: 3px; width: 8px; height: 1px; background: light-dark(#6d7179, #7d8087); }
  .slider-dive__tick b { position: absolute; left: 13px; top: -6px; color: light-dark(#6d7179, #7d8087); font-size: 9px; font-weight: 600; font-variant-numeric: tabular-nums; }
  .slider-dive__tag { position: absolute; left: 15px; top: calc(${TOP}px + var(--slider-dive-v) * ${SPAN}px); translate: 0 -50%; padding: 3px 6px; border-radius: 5px; background: light-dark(#373a40, #f1f2f3); color: light-dark(#f3f4f7, #1a1c1f); font-size: 11px; font-weight: 700; white-space: nowrap; font-variant-numeric: tabular-nums; box-shadow: 0 2px 6px light-dark(rgba(0, 0, 0, 0.14), rgba(0, 0, 0, 0.5)); }
  .slider-dive__tag::before { content: ''; position: absolute; left: -5px; top: 50%; translate: 0 -50%; border: 5px solid transparent; border-left: 0; border-right-color: light-dark(#373a40, #f1f2f3); }
  .slider-dive:has(.slider-dive__input:focus-visible) .slider-dive__tag { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 2px; }
  /* A horizontal range turned a quarter: 0 m at the top, deeper downward. */
  .slider-dive__input { position: absolute; left: 50%; top: ${TOP + SPAN / 2}px; width: ${SPAN + 24}px; height: 64px; margin: 0; translate: -50% -50%; rotate: 90deg; opacity: 0; cursor: ns-resize; -webkit-appearance: none; appearance: none; background: none; }
  .slider-dive__input::-webkit-slider-thumb { -webkit-appearance: none; width: 24px; height: 64px; }
  .slider-dive__input::-moz-range-thumb { width: 24px; height: 64px; border: 0; }
  @keyframes slider-dive-swell { to { transform: translateX(20px); } }
  @keyframes slider-dive-ray { from { transform: rotate(14deg); } to { transform: rotate(22deg); } }
  @keyframes slider-dive-school { from { transform: translateX(10px); } to { transform: translateX(84px); } }
  @keyframes slider-dive-weed { from { transform: rotate(-9deg); } to { transform: rotate(9deg); } }
  @keyframes slider-dive-kick { from { transform: rotate(-16deg); } to { transform: rotate(16deg); } }
  @keyframes slider-dive-bubble { from { transform: translate(0, 0) scale(0.6); opacity: 0.9; } to { transform: translate(-6px, -38px) scale(1.2); opacity: 0; } }
  @keyframes slider-dive-torch { 0% { opacity: 0.4; } 30% { opacity: 0; } 60% { opacity: 1; } }
  @media (prefers-reduced-motion: reduce) {
    .slider-dive *, .slider-dive *::after { animation: none !important; transition: none !important; }
  }
`;

export function Preview() {
  const [d, setD] = useState(14);
  // Depth grows downward, so the down arrow should take you deeper.
  const onKey = (e: KeyboardEvent<HTMLInputElement>) => {
    if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return;
    e.preventDefault();
    setD((x) => Math.min(MAX, Math.max(0, x + (e.key === 'ArrowDown' ? 1 : -1))));
  };
  return (
    <>
      <style href="kit-sliders-dive" precedence="kit">
        {css}
      </style>
      <div className="slider-dive" data-torch={d >= TORCH ? '' : undefined} style={{ '--slider-dive-v': (d / MAX).toFixed(3) } as CSSProperties}>
        <div className="slider-dive__sea" aria-hidden="true">
          <span className="slider-dive__scene">
            <span className="slider-dive__ray" style={{ left: 22 }} />
            <span className="slider-dive__ray" style={{ left: 70, animationDelay: '-2s' }} />
            <span className="slider-dive__ray" style={{ left: 112, animationDelay: '-3.5s' }} />
            <span className="slider-dive__surface" />
            <span className="slider-dive__fish">
              {[
                [0, 2],
                [14, 0],
                [10, 9],
                [26, 6],
              ].map(([x, y]) => (
                <i key={`${x}-${y}`} style={{ left: x + 6, top: y }} />
              ))}
            </span>
            <span className="slider-dive__bed" />
            <span className="slider-dive__weed" style={{ left: 18 }} />
            <span className="slider-dive__weed" style={{ left: 26, height: 18, animationDelay: '-1.4s' }} />
            <span className="slider-dive__weed" style={{ left: 126, height: 22, animationDelay: '-0.7s' }} />
            <span className="slider-dive__diver">
              <svg viewBox="0 0 64 30">
                <path className="slider-dive__fin" d="M7 10.5 L-7 5 L-5 13.5 Z" />
                <path className="slider-dive__fin slider-dive__fin--b" d="M7 18.5 L-5 15.5 L-7 24 Z" />
                <rect className="slider-dive__limb" x="5" y="9.5" width="19" height="4" rx="2" />
                <rect className="slider-dive__limb" x="5" y="16.5" width="19" height="4" rx="2" />
                <rect className="slider-dive__tank" x="22" y="2.5" width="19" height="7" rx="3.5" />
                <rect className="slider-dive__body" x="20" y="8" width="25" height="14" rx="7" />
                <path className="slider-dive__arm" d="M41 18 L56 24" />
                <rect className="slider-dive__torch" x="55" y="21.5" width="7" height="4" rx="1.5" transform="rotate(22 58 23.5)" />
                <circle className="slider-dive__body" cx="50" cy="13.5" r="6.5" />
                <rect className="slider-dive__mask" x="51" y="9.5" width="6.5" height="5.5" rx="2" />
                <circle className="slider-dive__reg" cx="54.5" cy="18" r="1.8" />
              </svg>
              <i className="slider-dive__bubble" />
              <i className="slider-dive__bubble" style={{ animationDelay: '-0.6s', left: 46, top: 16 }} />
              <i className="slider-dive__bubble" style={{ animationDelay: '-1.2s', left: 53, top: 13 }} />
            </span>
          </span>
          <span className="slider-dive__beam" />
        </div>
        <div className="slider-dive__gauge">
          <span className="slider-dive__line" aria-hidden="true" />
          {[0, 5, 10, 15, 20, 25, 30, 35, 40].map((m) => (
            <span key={m} className="slider-dive__tick" style={{ top: TOP + (m / MAX) * SPAN, width: m % 10 ? 5 : 8 }} aria-hidden="true">
              {m % 10 === 0 && <b>{m}</b>}
            </span>
          ))}
          <span className="slider-dive__tag" aria-hidden="true">
            {d} m
          </span>
          <input className="slider-dive__input" type="range" min={0} max={MAX} value={d} onChange={(e) => setD(+e.target.value)} onKeyDown={onKey} aria-label="Depth" aria-valuetext={`${d} metres`} aria-orientation="vertical" />
        </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-dive
Source on GitHub

About this component

A depth gauge beside the sea: take the diver down and the water darkens, the light rays fade, and past 25 metres the torch comes on.

Dependencies
None
Difficulty
Advanced
Triggers
state change, continuous, ambient
Techniques
css variables, filter, keyframes, svg
Reduced motion
Respected
Theme
Light and dark (follows your page's color-scheme)

A native range input named "Depth" 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 loops stop and the diver moves without easing.

More sliders

See all
Egg
Flipbook
Grind
Hose