{
  "name": "liquid-gauge",
  "type": "registry:component",
  "files": [
    {
      "type": "registry:component",
      "content": "\"use client\";\n\n/**\n * @author: @dorianbaffier\n * @description: Liquid Gauge\n * @version: 1.0.0\n * @date: 2026-10-14\n * @license: MIT\n * @website: https://kokonutui.com\n * @github: https://github.com/kokonut-labs/kokonutui\n */\n\nimport {\n  type KeyboardEvent,\n  type PointerEvent,\n  useCallback,\n  useEffect,\n  useRef,\n  useState,\n} from \"react\";\nimport { cn } from \"@/lib/utils\";\n\n// The surface is a row of columns joined by springs. Each column bobs\n// around the fill line and pulls on its neighbours, so a push travels.\nconst COLUMNS = 28;\n// Simulation, in px and seconds, on a fixed step.\nconst STEP = 1 / 120;\nconst SPREAD = 2600;\nconst RESTORE = 18;\nconst SURFACE_DAMPING = 2.2;\n// The fill line follows the value on an underdamped spring, so a big change\n// overshoots a little and settles, like a poured glass.\nconst LEVEL_STIFFNESS = 90;\nconst LEVEL_DAMPING = 11;\n// How hard a sideways drag rocks the liquid, and how hard the pointer\n// stirs it when moving through it.\nconst SLOSH = 4.5;\nconst STIR = 20;\nconst MAX_WAVE = 26;\n// The meniscus: a lighter band riding just above the surface.\nconst RIM = 3;\n// Bubbles rise while the level is climbing.\nconst BUBBLES = 10;\nconst BUBBLE_RATE = 0.06;\nconst REST_EPSILON = 0.02;\n\nexport interface LiquidGaugeProps {\n  /** Controlled fill, 0 to 100. */\n  value?: number;\n  /** Initial fill when uncontrolled. */\n  defaultValue?: number;\n  onValueChange?: (value: number) => void;\n  /** Turns the gauge into a read-only display. */\n  readOnly?: boolean;\n  label?: string;\n  /** Capacity shown in the subline, in `unit`. */\n  total?: number;\n  unit?: string;\n  /** From this fill on, the liquid turns red. */\n  warnAt?: number;\n  className?: string;\n}\n\ninterface Bubble {\n  x: number;\n  y: number;\n  r: number;\n  speed: number;\n  phase: number;\n  alive: boolean;\n}\n\nconst clamp = (v: number, min: number, max: number) =>\n  Math.min(max, Math.max(min, v));\n\nfunction haptic() {\n  if (typeof navigator !== \"undefined\" && \"vibrate\" in navigator) {\n    navigator.vibrate(6);\n  }\n}\n\nfunction surfacePolygon(\n  heights: Float64Array,\n  levelY: number,\n  width: number,\n  height: number,\n  lift: number\n) {\n  let points = \"\";\n  for (let i = 0; i < COLUMNS; i++) {\n    const x = (i / (COLUMNS - 1)) * width;\n    const y = clamp(levelY + heights[i] - lift, -40, height + 40);\n    points += `${x.toFixed(1)}px ${y.toFixed(1)}px,`;\n  }\n  return `polygon(${points}${width}px ${height + 40}px,0px ${height + 40}px)`;\n}\n\nfunction flatPolygon(value: number, lift = 0) {\n  const top = 100 - value;\n  return `polygon(0% calc(${top}% - ${lift}px),100% calc(${top}% - ${lift}px),100% 100%,0% 100%)`;\n}\n\nfunction drawBubbles(\n  bubbles: Bubble[],\n  nodes: (HTMLSpanElement | null)[],\n  time: number\n) {\n  bubbles.forEach((bubble, i) => {\n    const node = nodes[i];\n    if (!node) {\n      return;\n    }\n    if (!bubble.alive) {\n      node.style.opacity = \"0\";\n      return;\n    }\n    const sway = Math.sin(time * 7 + bubble.phase) * 2.5;\n    node.style.opacity = \"1\";\n    node.style.transform = `translate3d(${(bubble.x + sway - bubble.r).toFixed(1)}px,${(bubble.y - bubble.r).toFixed(1)}px,0) scale(${(bubble.r / 4).toFixed(3)})`;\n  });\n}\n\nfunction setText(nodes: (HTMLSpanElement | null)[], text: string) {\n  for (const node of nodes) {\n    if (node) {\n      node.textContent = text;\n    }\n  }\n}\n\nfunction GaugeText({\n  label,\n  status,\n  value,\n  used,\n  total,\n  unit,\n  percentRef,\n  usedRef,\n}: {\n  label: string;\n  status: string;\n  value: number;\n  used: string;\n  total: number;\n  unit: string;\n  percentRef: (node: HTMLSpanElement | null) => void;\n  usedRef: (node: HTMLSpanElement | null) => void;\n}) {\n  return (\n    <>\n      <div className=\"absolute inset-x-0 top-0 p-6\">\n        <p className=\"font-medium text-sm\">{label}</p>\n        <p className=\"mt-3 font-semibold text-6xl tabular-nums tracking-tight\">\n          <span ref={percentRef}>{Math.round(value)}</span>\n          <span className=\"ml-0.5 font-medium text-3xl opacity-60\">%</span>\n        </p>\n        <p className=\"mt-1 text-sm tabular-nums opacity-60\">\n          <span ref={usedRef}>{used}</span> of {total} {unit} used\n        </p>\n      </div>\n      <p className=\"absolute bottom-6 left-6 font-medium text-sm\">{status}</p>\n      {/* Every ten percent, a short tick on the right wall. */}\n      <div className=\"absolute inset-y-0 right-0 w-4\">\n        {[10, 20, 30, 40, 50, 60, 70, 80, 90].map((tick) => (\n          <span\n            className={cn(\n              \"absolute right-0 h-px bg-current opacity-30\",\n              tick === 50 ? \"w-3\" : \"w-1.5\"\n            )}\n            key={tick}\n            style={{ top: `${100 - tick}%` }}\n          />\n        ))}\n      </div>\n    </>\n  );\n}\n\nexport default function LiquidGauge({\n  value: valueProp,\n  defaultValue = 72,\n  onValueChange,\n  readOnly = false,\n  label = \"Storage\",\n  total = 128,\n  unit = \"GB\",\n  warnAt = 90,\n  className,\n}: LiquidGaugeProps) {\n  const [internal, setInternal] = useState(defaultValue);\n  const value = clamp(valueProp ?? internal, 0, 100);\n  const [dragging, setDragging] = useState(false);\n\n  const rootRef = useRef<HTMLDivElement>(null);\n  const liquidRef = useRef<HTMLDivElement>(null);\n  const rimRef = useRef<HTMLDivElement>(null);\n  const bubbleRefs = useRef<(HTMLSpanElement | null)[]>([]);\n  const percentRefs = useRef<(HTMLSpanElement | null)[]>([]);\n  const usedRefs = useRef<(HTMLSpanElement | null)[]>([]);\n\n  const sim = useRef({\n    heights: new Float64Array(COLUMNS),\n    speeds: new Float64Array(COLUMNS),\n    level: value,\n    levelSpeed: 0,\n    target: value,\n    width: 0,\n    height: 0,\n    bubbles: Array.from(\n      { length: BUBBLES },\n      (): Bubble => ({ x: 0, y: 0, r: 0, speed: 0, phase: 0, alive: false })\n    ),\n    spawn: 0,\n    shown: Math.round(value),\n    raf: 0,\n    last: 0,\n    carry: 0,\n    time: 0,\n    pointerX: -1,\n    pointerId: -1,\n    visible: true,\n    reduceMotion: false,\n  });\n\n  // Read inside the animation loop, which outlives the render it came from.\n  const totalRef = useRef(total);\n  totalRef.current = total;\n\n  const setValue = useCallback(\n    (next: number) => {\n      const rounded = Math.round(clamp(next, 0, 100));\n      if (valueProp === undefined) {\n        setInternal(rounded);\n      }\n      onValueChange?.(rounded);\n    },\n    [valueProp, onValueChange]\n  );\n\n  const draw = useCallback(() => {\n    const s = sim.current;\n    const { width, height } = s;\n    if (!(width && height)) {\n      return;\n    }\n    const levelY = height * (1 - s.level / 100);\n    if (liquidRef.current) {\n      liquidRef.current.style.clipPath = surfacePolygon(\n        s.heights,\n        levelY,\n        width,\n        height,\n        0\n      );\n    }\n    if (rimRef.current) {\n      rimRef.current.style.clipPath = surfacePolygon(\n        s.heights,\n        levelY,\n        width,\n        height,\n        RIM\n      );\n    }\n    drawBubbles(s.bubbles, bubbleRefs.current, s.time);\n    const shown = Math.round(clamp(s.level, 0, 100));\n    if (shown !== s.shown) {\n      s.shown = shown;\n      const used = ((shown / 100) * totalRef.current).toFixed(1);\n      setText(percentRefs.current, String(shown));\n      setText(usedRefs.current, used);\n    }\n  }, []);\n\n  const spawnBubbles = useCallback((dt: number) => {\n    const s = sim.current;\n    // Only a climbing level makes bubbles, more the faster it climbs.\n    s.spawn += Math.max(0, s.levelSpeed) * BUBBLE_RATE * dt;\n    while (s.spawn >= 1) {\n      s.spawn -= 1;\n      const free = s.bubbles.find((b) => !b.alive);\n      if (!free) {\n        s.spawn = 0;\n        break;\n      }\n      free.alive = true;\n      free.x = s.width * (0.12 + Math.random() * 0.76);\n      free.y = s.height - 6;\n      free.r = 1.5 + Math.random() * 2.5;\n      free.speed = 70 + Math.random() * 80;\n      free.phase = Math.random() * Math.PI * 2;\n    }\n  }, []);\n\n  const advanceBubbles = useCallback((dt: number) => {\n    const s = sim.current;\n    const levelY = s.height * (1 - s.level / 100);\n    const column = (x: number) =>\n      clamp(Math.round((x / s.width) * (COLUMNS - 1)), 0, COLUMNS - 1);\n    let alive = 0;\n    for (const bubble of s.bubbles) {\n      if (!bubble.alive) {\n        continue;\n      }\n      bubble.y -= bubble.speed * dt;\n      const i = column(bubble.x);\n      // At the surface it pops, and leaves a tiny dimple behind.\n      if (bubble.y - bubble.r <= levelY + s.heights[i]) {\n        bubble.alive = false;\n        s.speeds[i] += bubble.r * 6;\n      } else {\n        alive++;\n      }\n    }\n    return alive;\n  }, []);\n\n  const step = useCallback(() => {\n    const s = sim.current;\n    const { heights, speeds } = s;\n    // Fill line spring.\n    const force =\n      (s.target - s.level) * LEVEL_STIFFNESS - s.levelSpeed * LEVEL_DAMPING;\n    s.levelSpeed += force * STEP;\n    s.level += s.levelSpeed * STEP;\n    // Surface springs, with walls that reflect.\n    let energy = Math.abs(s.target - s.level) + Math.abs(s.levelSpeed) * 0.1;\n    for (let i = 0; i < COLUMNS; i++) {\n      const left = heights[i > 0 ? i - 1 : i];\n      const right = heights[i < COLUMNS - 1 ? i + 1 : i];\n      const accel =\n        (left + right - 2 * heights[i]) * SPREAD -\n        heights[i] * RESTORE -\n        speeds[i] * SURFACE_DAMPING;\n      speeds[i] += accel * STEP;\n    }\n    for (let i = 0; i < COLUMNS; i++) {\n      heights[i] = clamp(heights[i] + speeds[i] * STEP, -MAX_WAVE, MAX_WAVE);\n      energy = Math.max(\n        energy,\n        Math.abs(heights[i]),\n        Math.abs(speeds[i]) * 0.1\n      );\n    }\n    s.time += STEP;\n    return energy;\n  }, []);\n\n  const tick = useCallback(\n    (now: number) => {\n      const s = sim.current;\n      s.raf = 0;\n      const elapsed = s.last ? Math.min((now - s.last) / 1000, 0.05) : STEP;\n      s.last = now;\n      s.carry += elapsed;\n      let energy = 0;\n      while (s.carry >= STEP) {\n        energy = Math.max(energy, step());\n        s.carry -= STEP;\n      }\n      spawnBubbles(elapsed);\n      const bubbles = advanceBubbles(elapsed);\n      draw();\n      const busy = s.pointerId >= 0 || bubbles > 0 || energy > REST_EPSILON;\n      if (busy && s.visible) {\n        s.raf = requestAnimationFrame(tick);\n      } else {\n        s.last = 0;\n        if (!busy) {\n          // Settle exactly, so the resting surface is flat.\n          s.level = s.target;\n          s.levelSpeed = 0;\n          s.heights.fill(0);\n          s.speeds.fill(0);\n          draw();\n        }\n      }\n    },\n    [advanceBubbles, draw, spawnBubbles, step]\n  );\n\n  const wake = useCallback(() => {\n    const s = sim.current;\n    if (!s.raf && s.visible) {\n      s.raf = requestAnimationFrame(tick);\n    }\n  }, [tick]);\n\n  // A new value: the spring takes it from here.\n  useEffect(() => {\n    const s = sim.current;\n    if (s.target === value) {\n      return;\n    }\n    s.target = value;\n    if (s.reduceMotion) {\n      s.level = value;\n      s.levelSpeed = 0;\n      draw();\n      return;\n    }\n    wake();\n  }, [value, draw, wake]);\n\n  useEffect(() => {\n    const s = sim.current;\n    const root = rootRef.current;\n    if (!root) {\n      return;\n    }\n    const motion = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n    s.reduceMotion = motion.matches;\n    const onMotion = () => {\n      s.reduceMotion = motion.matches;\n    };\n    motion.addEventListener(\"change\", onMotion);\n\n    const resize = new ResizeObserver(([entry]) => {\n      s.width = entry.contentRect.width;\n      s.height = entry.contentRect.height;\n      draw();\n    });\n    resize.observe(root);\n\n    // Offscreen or in a background tab, the loop waits.\n    const pause = () => {\n      if (!s.visible && s.raf) {\n        cancelAnimationFrame(s.raf);\n        s.raf = 0;\n        s.last = 0;\n      } else if (s.visible) {\n        wake();\n      }\n    };\n    let onScreen = true;\n    const intersection = new IntersectionObserver(([entry]) => {\n      onScreen = entry.isIntersecting;\n      s.visible = onScreen && !document.hidden;\n      pause();\n    });\n    intersection.observe(root);\n    const onVisibility = () => {\n      s.visible = onScreen && !document.hidden;\n      pause();\n    };\n    document.addEventListener(\"visibilitychange\", onVisibility);\n\n    return () => {\n      motion.removeEventListener(\"change\", onMotion);\n      resize.disconnect();\n      intersection.disconnect();\n      document.removeEventListener(\"visibilitychange\", onVisibility);\n      cancelAnimationFrame(s.raf);\n      s.raf = 0;\n    };\n  }, [draw, wake]);\n\n  const valueAt = (clientY: number) => {\n    const rect = rootRef.current?.getBoundingClientRect();\n    if (!rect) {\n      return value;\n    }\n    return (1 - (clientY - rect.top) / rect.height) * 100;\n  };\n\n  // Push the columns around x by `amount`, fading out over a few columns.\n  const disturb = (clientX: number, amount: number, reach = 3) => {\n    const s = sim.current;\n    const rect = rootRef.current?.getBoundingClientRect();\n    if (!rect || s.reduceMotion) {\n      return;\n    }\n    const center = ((clientX - rect.left) / rect.width) * (COLUMNS - 1);\n    for (let i = 0; i < COLUMNS; i++) {\n      const falloff = 1 - Math.abs(i - center) / reach;\n      if (falloff > 0) {\n        s.speeds[i] += amount * falloff;\n      }\n    }\n  };\n\n  const handlePointerDown = (event: PointerEvent<HTMLDivElement>) => {\n    if (readOnly || (event.pointerType === \"mouse\" && event.button !== 0)) {\n      return;\n    }\n    const s = sim.current;\n    event.currentTarget.setPointerCapture(event.pointerId);\n    s.pointerId = event.pointerId;\n    s.pointerX = event.clientX;\n    setDragging(true);\n    // The press itself lands like a drop.\n    disturb(event.clientX, 260, 2.5);\n    setValue(valueAt(event.clientY));\n    wake();\n  };\n\n  const handlePointerMove = (event: PointerEvent<HTMLDivElement>) => {\n    const s = sim.current;\n    const dx = s.pointerX < 0 ? 0 : event.clientX - s.pointerX;\n    s.pointerX = event.clientX;\n    if (s.reduceMotion) {\n      if (s.pointerId === event.pointerId) {\n        setValue(valueAt(event.clientY));\n      }\n      return;\n    }\n    if (s.pointerId === event.pointerId) {\n      // A sideways drag rocks the whole body: one side rises, the other\n      // falls, and the springs carry it back and forth.\n      for (let i = 0; i < COLUMNS; i++) {\n        s.speeds[i] += ((i / (COLUMNS - 1)) * 2 - 1) * dx * SLOSH;\n      }\n      setValue(valueAt(event.clientY));\n      wake();\n      return;\n    }\n    // Hovering through the liquid stirs it, behind the cursor.\n    const rect = rootRef.current?.getBoundingClientRect();\n    if (event.pointerType !== \"mouse\" || !rect || dx === 0) {\n      return;\n    }\n    const levelY = rect.height * (1 - s.level / 100);\n    if (event.clientY - rect.top > levelY + 8) {\n      disturb(event.clientX, Math.abs(dx) * STIR, 2);\n      wake();\n    }\n  };\n\n  const handlePointerUp = (event: PointerEvent<HTMLDivElement>) => {\n    const s = sim.current;\n    if (s.pointerId !== event.pointerId) {\n      return;\n    }\n    s.pointerId = -1;\n    setDragging(false);\n    haptic();\n    wake();\n  };\n\n  const handlePointerLeave = () => {\n    if (sim.current.pointerId < 0) {\n      sim.current.pointerX = -1;\n    }\n  };\n\n  const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {\n    if (readOnly) {\n      return;\n    }\n    const steps: Record<string, number> = {\n      ArrowUp: 1,\n      ArrowRight: 1,\n      ArrowDown: -1,\n      ArrowLeft: -1,\n      PageUp: 10,\n      PageDown: -10,\n    };\n    let next: number | undefined;\n    if (event.key in steps) {\n      next = value + steps[event.key] * (event.shiftKey ? 10 : 1);\n    } else if (event.key === \"Home\") {\n      next = 0;\n    } else if (event.key === \"End\") {\n      next = 100;\n    }\n    if (next === undefined) {\n      return;\n    }\n    event.preventDefault();\n    // A keyed change still lands with a small ripple.\n    const rect = rootRef.current?.getBoundingClientRect();\n    if (rect) {\n      disturb(rect.left + rect.width / 2, next > value ? 120 : -120, 4);\n    }\n    setValue(next);\n    wake();\n  };\n\n  const warn = value >= warnAt;\n  const status = warn ? \"Almost full\" : `${100 - Math.round(value)}% free`;\n  const used = ((Math.round(value) / 100) * total).toFixed(1);\n  const textProps = {\n    label,\n    status,\n    value,\n    used,\n    total,\n    unit,\n  };\n\n  return (\n    <div\n      aria-label={label}\n      aria-readonly={readOnly || undefined}\n      aria-valuemax={100}\n      aria-valuemin={0}\n      aria-valuenow={Math.round(value)}\n      aria-valuetext={`${Math.round(value)}%, ${used} of ${total} ${unit} used`}\n      className={cn(\n        \"relative h-[22.5rem] w-72 touch-none select-none overflow-hidden rounded-3xl border border-zinc-200 bg-white text-zinc-900 outline-none dark:border-zinc-800 dark:bg-zinc-950 dark:text-zinc-100\",\n        \"focus-visible:ring-2 focus-visible:ring-zinc-400 focus-visible:ring-offset-2 dark:focus-visible:ring-offset-zinc-950\",\n        !readOnly && (dragging ? \"cursor-grabbing\" : \"cursor-ns-resize\"),\n        className\n      )}\n      onKeyDown={handleKeyDown}\n      onLostPointerCapture={handlePointerUp}\n      onPointerCancel={handlePointerUp}\n      onPointerDown={handlePointerDown}\n      onPointerLeave={handlePointerLeave}\n      onPointerMove={handlePointerMove}\n      onPointerUp={handlePointerUp}\n      ref={rootRef}\n      role=\"slider\"\n      tabIndex={readOnly ? -1 : 0}\n    >\n      <GaugeText\n        {...textProps}\n        percentRef={(node) => {\n          percentRefs.current[0] = node;\n        }}\n        usedRef={(node) => {\n          usedRefs.current[0] = node;\n        }}\n      />\n\n      {/* The meniscus, a lighter band just above the liquid. */}\n      <div\n        aria-hidden=\"true\"\n        className={cn(\n          \"pointer-events-none absolute inset-0 transition-colors duration-300 ease-out\",\n          warn ? \"bg-red-300 dark:bg-red-900\" : \"bg-zinc-300 dark:bg-zinc-600\"\n        )}\n        ref={rimRef}\n        style={{ clipPath: flatPolygon(value, RIM) }}\n      />\n\n      {/* The liquid, with a second copy of the text in its own colour. */}\n      <div\n        aria-hidden=\"true\"\n        className={cn(\n          \"pointer-events-none absolute inset-0 transition-colors duration-300 ease-out\",\n          warn\n            ? \"bg-red-500 text-white\"\n            : \"bg-zinc-900 text-white dark:bg-zinc-100 dark:text-zinc-900\"\n        )}\n        ref={liquidRef}\n        style={{ clipPath: flatPolygon(value) }}\n      >\n        <GaugeText\n          {...textProps}\n          percentRef={(node) => {\n            percentRefs.current[1] = node;\n          }}\n          usedRef={(node) => {\n            usedRefs.current[1] = node;\n          }}\n        />\n        {Array.from({ length: BUBBLES }, (_, i) => (\n          <span\n            className=\"absolute top-0 left-0 size-2 rounded-full border border-current bg-current/20 opacity-0\"\n            key={`bubble-${i.toString()}`}\n            ref={(node) => {\n              bubbleRefs.current[i] = node;\n            }}\n          />\n        ))}\n      </div>\n    </div>\n  );\n}\n",
      "path": "/components/kokonutui/liquid-gauge.tsx",
      "target": "components/kokonutui/liquid-gauge.tsx"
    }
  ]
}