{
	"$schema": "https://ui.shadcn.com/schema/registry-item.json",
	"name": "SloshGauge-JS-CSS",
	"title": "SloshGauge",
	"description": "Tank gauge whose liquid chases the value with mass, tilts with its own speed and splashes against the top when it slams full; optionally a vertical slider.",
	"type": "registry:component",
	"files": [
		{
			"type": "registry:file",
			"path": "SloshGauge.css",
			"target": "@components/SloshGauge.css",
			"content": ".slosh-gauge {\n  --sg-w: 88px;\n  --sg-h: 180px;\n  --sg-r: 20px;\n  --sg-glass: #27272a;\n  --sg-liquid: #f5f5f5;\n  --sg-on-liquid: #111111;\n  --sg-ticks: 4;\n  --sg-font: 14px;\n\n  position: relative;\n  display: inline-block;\n  box-sizing: border-box;\n  width: var(--sg-w);\n  height: var(--sg-h);\n  border-radius: var(--sg-r);\n  overflow: clip;\n  isolation: isolate;\n  background: var(--sg-glass);\n  font-size: var(--sg-font);\n  font-weight: 600;\n  font-variant-numeric: tabular-nums;\n  line-height: 1;\n  outline: none;\n  user-select: none;\n  -webkit-user-select: none;\n  -webkit-tap-highlight-color: transparent;\n}\n\n.slosh-gauge[data-interactive='true'] {\n  cursor: ns-resize;\n  touch-action: none;\n  -webkit-touch-callout: none;\n}\n\n.slosh-gauge[data-disabled='true'] {\n  opacity: 0.5;\n  cursor: not-allowed;\n}\n\n.slosh-gauge__value {\n  position: absolute;\n  inset: 0;\n  display: grid;\n  place-items: center;\n  color: color-mix(in srgb, currentColor 65%, transparent);\n  pointer-events: none;\n}\n\n.slosh-gauge__liquid {\n  position: absolute;\n  inset: 0;\n  background: var(--sg-liquid);\n  clip-path: polygon(0 100%, 100% 100%, 100% 100%, 0 100%);\n}\n\n.slosh-gauge__liquid .slosh-gauge__value {\n  color: var(--sg-on-liquid);\n}\n\n.slosh-gauge__ticks {\n  position: absolute;\n  right: 0;\n  bottom: 0;\n  width: 22%;\n  height: 100%;\n  background-image: linear-gradient(to top, color-mix(in srgb, currentColor 22%, transparent) 0 1px, transparent 1px);\n  background-size: 100% calc(100% / var(--sg-ticks));\n  background-repeat: repeat-y;\n  background-position: bottom;\n  pointer-events: none;\n}\n\n.slosh-gauge__marker {\n  position: absolute;\n  top: 0;\n  right: 10%;\n  left: 10%;\n  height: 2px;\n  margin-top: -1px;\n  border-radius: 1px;\n  background: currentColor;\n  opacity: 0;\n  transition: opacity 150ms ease;\n  pointer-events: none;\n}\n\n.slosh-gauge[data-held='true'] .slosh-gauge__marker {\n  opacity: 1;\n}\n"
		},
		{
			"type": "registry:component",
			"path": "SloshGauge.jsx",
			"content": "'use client';\n\nimport { useEffect, useLayoutEffect, useRef, useState } from 'react';\nimport './SloshGauge.css';\n\nconst clamp = (v, a, b) => Math.min(b, Math.max(a, v));\nconst H = 1 / 120;\nconst DT_MAX = 0.05;\nconst TILT_GAIN = 0.07;\nconst TILT_MAX = 30;\nconst REST_POS = 0.05;\nconst REST_VEL = 2;\nconst STEP = 2;\nconst BIG = 10;\nconst BAND = 10;\n\nconst onColor = hex => {\n  const raw = hex.replace('#', '');\n  const full = raw.length === 3 ? [...raw].map(c => c + c).join('') : raw;\n  const n = parseInt(full, 16);\n  if (Number.isNaN(n)) return '#ffffff';\n  return (((n >> 16) & 255) * 299 + ((n >> 8) & 255) * 587 + (n & 255) * 114) / 1000 >= 150 ? '#111111' : '#ffffff';\n};\n\nexport default function SloshGauge({\n  value,\n  defaultValue = 60,\n  onChange,\n  interactive = false,\n  showValue = true,\n  disabled = false,\n  liquidColor = '#f5f5f5',\n  glassColor = '#27272a',\n  width = 88,\n  height = 180,\n  radius = 20,\n  ticks = 4,\n  viscosity = 0.15,\n  tilt = 0.45,\n  splash = 0.42,\n  unit = '%',\n  ariaLabel = 'Level',\n  className = ''\n}) {\n  const root = useRef(null);\n  const liquid = useRef(null);\n  const marker = useRef(null);\n  const textA = useRef(null);\n  const textB = useRef(null);\n  const start = clamp(value ?? defaultValue, 0, 100);\n  const sim = useRef({ x: start, v: 0, L: start, raf: 0, last: 0 });\n  const grip = useRef(null);\n  const reduce = useRef(false);\n  const [held, setHeld] = useState(false);\n  const live = useRef({});\n  live.current = { viscosity, tilt, splash, width, height, value, onChange, unit };\n\n  const paint = () => {\n    const { x, v, L } = sim.current;\n    const { tilt: t, width: W, height: Hh } = live.current;\n    const th = reduce.current ? 0 : clamp(t * v * TILT_GAIN, -TILT_MAX, TILT_MAX);\n    const lean = (Math.tan((th * Math.PI) / 180) * W) / 2;\n    const top = 100 - x;\n    if (liquid.current) {\n      liquid.current.style.clipPath = `polygon(0 calc(${top}% + ${lean}px), 100% calc(${top}% - ${lean}px), 100% 100%, 0 100%)`;\n    }\n    if (marker.current) marker.current.style.transform = `translateY(${((100 - L) * Hh) / 100}px)`;\n  };\n\n  const say = () => {\n    const n = Math.round(sim.current.L);\n    const s = `${n}${live.current.unit}`;\n    root.current?.setAttribute('aria-valuenow', String(n));\n    if (textA.current) textA.current.textContent = s;\n    if (textB.current) textB.current.textContent = s;\n  };\n\n  const tick = now => {\n    const s = sim.current;\n    const { viscosity: vis, splash: give } = live.current;\n    const dt = s.last ? Math.min((now - s.last) / 1000, DT_MAX) : H;\n    s.last = now;\n    if (vis <= 0) {\n      s.x = s.L;\n      s.v = 0;\n    } else {\n      const k = 1224 - 1044 * vis;\n      const zeta = reduce.current ? 1 : 0.26 - 0.17 * vis;\n      const c = 2 * zeta * Math.sqrt(k);\n      const rest = reduce.current ? 0 : give;\n      for (let n = Math.ceil(dt / H), h = dt / n; n > 0; n -= 1) {\n        s.v = s.v * Math.exp(-c * h) + k * (s.L - s.x) * h;\n        s.x += s.v * h;\n        if (s.x > 100) {\n          s.x = 100;\n          s.v = -s.v * rest;\n        } else if (s.x < 0) {\n          s.x = 0;\n          s.v = -s.v * rest;\n        }\n      }\n      if (!grip.current && Math.abs(s.L - s.x) < REST_POS && Math.abs(s.v) < REST_VEL) {\n        s.x = s.L;\n        s.v = 0;\n      }\n    }\n    paint();\n    const parked = !grip.current && s.x === s.L && s.v === 0;\n    if (parked) {\n      s.raf = 0;\n      s.last = 0;\n    } else {\n      s.raf = requestAnimationFrame(tick);\n    }\n  };\n  const wake = () => {\n    if (!sim.current.raf) sim.current.raf = requestAnimationFrame(tick);\n  };\n  const setLevel = (L, instant) => {\n    const s = sim.current;\n    s.L = clamp(L, 0, 100);\n    if (instant) {\n      s.x = s.L;\n      s.v = 0;\n    }\n    say();\n    wake();\n  };\n\n  useLayoutEffect(() => {\n    if (value === undefined) return;\n    if (grip.current && Math.round(sim.current.L) === value) return;\n    setLevel(value, false);\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [value]);\n\n  useLayoutEffect(() => {\n    const mq = window.matchMedia('(prefers-reduced-motion: reduce)');\n    const sync = () => {\n      reduce.current = mq.matches;\n    };\n    sync();\n    mq.addEventListener('change', sync);\n    say();\n    paint();\n    const s = sim.current;\n    return () => {\n      mq.removeEventListener('change', sync);\n      cancelAnimationFrame(s.raf);\n    };\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, []);\n\n  useEffect(() => {\n    paint();\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [width, height, tilt, showValue, interactive, disabled]);\n\n  const levelAt = (clientY, g) =>\n    clamp(((g.rect.bottom - clientY) / g.scale / (root.current?.offsetHeight || g.rect.height)) * 100, 0, 100);\n  const report = () => {\n    const g = grip.current;\n    const n = Math.round(sim.current.L);\n    if (g && n !== g.sent) {\n      g.sent = n;\n      live.current.onChange?.(n);\n    }\n  };\n\n  const down = e => {\n    if (!interactive || disabled || grip.current || e.button !== 0) return;\n    const el = root.current;\n    const rect = el.getBoundingClientRect();\n    const scale = rect.height / (el.offsetHeight || rect.height) || 1;\n    const markerY = rect.top + ((100 - sim.current.L) / 100) * rect.height;\n    const g = {\n      id: e.pointerId,\n      rect,\n      scale,\n      band: Math.abs(e.clientY - markerY) <= BAND * scale,\n      grab: null,\n      at: sim.current.L,\n      sent: NaN\n    };\n    grip.current = g;\n    try {\n      el.setPointerCapture(e.pointerId);\n    } catch {}\n    setHeld(true);\n    if (g.band) {\n      wake();\n    } else {\n      setLevel(levelAt(e.clientY, g));\n      report();\n    }\n  };\n  const move = e => {\n    const g = grip.current;\n    if (!g || g.id !== e.pointerId) return;\n    const at = levelAt(e.clientY, g);\n    if (g.band && g.grab === null) {\n      g.grab = sim.current.L - at;\n      return;\n    }\n    setLevel(at + (g.grab ?? 0));\n    report();\n  };\n  const up = (e, reason) => {\n    const g = grip.current;\n    if (!g || g.id !== e.pointerId) return;\n    grip.current = null;\n    try {\n      root.current?.releasePointerCapture(e.pointerId);\n    } catch {}\n    setHeld(false);\n    if (reason === 'escape') {\n      setLevel(g.at);\n      live.current.onChange?.(Math.round(g.at));\n    } else if (live.current.value !== undefined && Math.round(sim.current.L) !== live.current.value) {\n      setLevel(live.current.value);\n    }\n    wake();\n  };\n  const key = e => {\n    if (!interactive || disabled) return;\n    if (e.key === 'Escape') {\n      if (grip.current) up({ pointerId: grip.current.id }, 'escape');\n      return;\n    }\n    const L = sim.current.L;\n    const d = e.shiftKey ? BIG : STEP;\n    const next = {\n      ArrowUp: L + d,\n      ArrowRight: L + d,\n      ArrowDown: L - d,\n      ArrowLeft: L - d,\n      PageUp: L + BIG,\n      PageDown: L - BIG,\n      Home: 0,\n      End: 100\n    }[e.key];\n    if (next === undefined) return;\n    e.preventDefault();\n    setLevel(next, true);\n    live.current.onChange?.(Math.round(sim.current.L));\n  };\n\n  const r = Math.min(radius, width / 2, height / 2);\n  const label = `${Math.round(start)}${unit}`;\n\n  return (\n    <div\n      ref={root}\n      className={`slosh-gauge${className ? ` ${className}` : ''}`}\n      role={interactive ? 'slider' : 'meter'}\n      tabIndex={interactive && !disabled ? 0 : undefined}\n      aria-label={ariaLabel}\n      aria-valuemin={0}\n      aria-valuemax={100}\n      aria-valuenow={Math.round(start)}\n      aria-orientation={interactive ? 'vertical' : undefined}\n      aria-disabled={disabled || undefined}\n      data-interactive={interactive ? 'true' : 'false'}\n      data-held={held ? 'true' : 'false'}\n      data-disabled={disabled ? 'true' : 'false'}\n      style={{\n        '--sg-w': `${width}px`,\n        '--sg-h': `${height}px`,\n        '--sg-r': `${r}px`,\n        '--sg-glass': glassColor,\n        '--sg-liquid': liquidColor,\n        '--sg-on-liquid': onColor(liquidColor),\n        '--sg-ticks': ticks,\n        '--sg-font': `${clamp(Math.round(width * 0.16), 12, 20)}px`\n      }}\n      onPointerDown={down}\n      onPointerMove={move}\n      onPointerUp={e => up(e)}\n      onPointerCancel={e => up(e, 'cancel')}\n      onLostPointerCapture={e => up(e, 'cancel')}\n      onKeyDown={key}\n    >\n      {showValue ? (\n        <span ref={textA} className=\"slosh-gauge__value\" aria-hidden=\"true\">\n          {label}\n        </span>\n      ) : null}\n      <div ref={liquid} className=\"slosh-gauge__liquid\" aria-hidden=\"true\">\n        {showValue ? (\n          <span ref={textB} className=\"slosh-gauge__value\">\n            {label}\n          </span>\n        ) : null}\n      </div>\n      {ticks > 0 ? <div className=\"slosh-gauge__ticks\" aria-hidden=\"true\" /> : null}\n      {interactive && !disabled ? <div ref={marker} className=\"slosh-gauge__marker\" aria-hidden=\"true\" /> : null}\n    </div>\n  );\n}\n"
		}
	],
	"registryDependencies": [],
	"dependencies": []
}