# Range Slider

Six sliders that share one accessible core: drag, click, or use the arrow, Page and Home/End keys. Every variant respects reduced motion.

**Category:** Inputs  
**Exports:** `RangeSlider`, `InlineSlider`, `FluidSlider`, `WaveSlider`, `BubbleSlider`, `RulerSlider`  
**npm dependencies:** motion (+ clsx, tailwind-merge for `cn`)

## Install

```bash
npx shadcn@latest add https://charcoalui.com/r/range-slider.json
```
The file lands at `components/ui/range-slider.tsx`; import from `@/components/ui/range-slider`.

## API — Inline Slider
Label and value live inside the track. Ten stops span the rail; any marker under text hides, and text dims while the thumb passes over it.

| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `label` | `string` | — | Compact label inside the left edge of the track. |
| `format` | `(value: number) => string` | — | Formats the inline value. |
| `showTicks` | `boolean` | `true` |  |
| `value` | `number` | — | Controlled value. |
| `defaultValue` | `number` | — | Initial value when uncontrolled. |
| `onValueChange` | `(value: number) => void` | — |  |
| `min` | `number` | `0` |  |
| `max` | `number` | `100` |  |
| `step` | `number` | `1` | Values snap to this step; readouts keep its decimal precision. |
| `disabled` | `boolean` | `false` |  |
| `aria-label` | `string` | — | Required when there is no visible label. |
| `formatValueText` | `(value: number) => string` | — | Announced instead of the raw number, e.g. “72.5 kg”. |

## API — Tick Slider
Tick dots and a vertical-bar thumb that bounces as it lands on each step.

| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `label` | `string` | — |  |
| `format` | `(value: number) => string` | — |  |
| `showTicks` | `boolean` | `true` | A dot per step, or 11 evenly spaced dots past 40 steps. |
| `value` | `number` | — | Controlled value. |
| `defaultValue` | `number` | — | Initial value when uncontrolled. |
| `onValueChange` | `(value: number) => void` | — |  |
| `min` | `number` | `0` |  |
| `max` | `number` | `100` |  |
| `step` | `number` | `1` | Values snap to this step; readouts keep its decimal precision. |
| `disabled` | `boolean` | `false` |  |
| `aria-label` | `string` | — | Required when there is no visible label. |
| `formatValueText` | `(value: number) => string` | — | Announced instead of the raw number, e.g. “72.5 kg”. |

## API — Fluid Slider
No thumb. The fill trails on a soft spring with a stretching cap, and the text flips colour wherever the fill covers it.

| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `label` | `string` | — |  |
| `format` | `(value: number) => string` | `(v) => `${v}%`` |  |
| `value` | `number` | — | Controlled value. |
| `defaultValue` | `number` | — | Initial value when uncontrolled. |
| `onValueChange` | `(value: number) => void` | — |  |
| `min` | `number` | `0` |  |
| `max` | `number` | `100` |  |
| `step` | `number` | `1` | Values snap to this step; readouts keep its decimal precision. |
| `disabled` | `boolean` | `false` |  |
| `aria-label` | `string` | — | Required when there is no visible label. |
| `formatValueText` | `(value: number) => string` | — | Announced instead of the raw number, e.g. “72.5 kg”. |

## API — Wave Slider
Equalizer bars swell around the value while you hover or drag, then settle back.

| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `bars` | `number` | `32` | Number of bars across the track. |
| `label` | `string` | — |  |
| `format` | `(value: number) => string` | — |  |
| `value` | `number` | — | Controlled value. |
| `defaultValue` | `number` | — | Initial value when uncontrolled. |
| `onValueChange` | `(value: number) => void` | — |  |
| `min` | `number` | `0` |  |
| `max` | `number` | `100` |  |
| `step` | `number` | `1` | Values snap to this step; readouts keep its decimal precision. |
| `disabled` | `boolean` | `false` |  |
| `aria-label` | `string` | — | Required when there is no visible label. |
| `formatValueText` | `(value: number) => string` | — | Announced instead of the raw number, e.g. “72.5 kg”. |

## API — Bubble Slider
Grab the thumb and a value bubble pops out. It leans and squashes with your drag speed, then settles upright.

| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `format` | `(value: number) => string` | — |  |
| `value` | `number` | — | Controlled value. |
| `defaultValue` | `number` | — | Initial value when uncontrolled. |
| `onValueChange` | `(value: number) => void` | — |  |
| `min` | `number` | `0` |  |
| `max` | `number` | `100` |  |
| `step` | `number` | `1` | Values snap to this step; readouts keep its decimal precision. |
| `disabled` | `boolean` | `false` |  |
| `aria-label` | `string` | — | Required when there is no visible label. |
| `formatValueText` | `(value: number) => string` | — | Announced instead of the raw number, e.g. “72.5 kg”. |

## API — Ruler Slider
The ember needle stays put while the scale scrolls under it. Flick it and it glides, then snaps to the nearest tick.

| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `gap` | `number` | `14` | Pixels between two steps. |
| `majorEvery` | `number` | `5` | Label every Nth step and draw it tall. |
| `unit` | `string` | — | Shown next to the value. |
| `value` | `number` | — | Controlled value. |
| `defaultValue` | `number` | — | Initial value when uncontrolled. |
| `onValueChange` | `(value: number) => void` | — |  |
| `min` | `number` | `0` |  |
| `max` | `number` | `100` |  |
| `step` | `number` | `1` | Values snap to this step; readouts keep its decimal precision. |
| `disabled` | `boolean` | `false` |  |
| `aria-label` | `string` | — | Required when there is no visible label. |
| `formatValueText` | `(value: number) => string` | — | Announced instead of the raw number, e.g. “72.5 kg”. |

## Example — Inline Slider

```tsx
import { InlineSlider } from "@/components/ui/range-slider";

export default function InlineSliderDemo({
  label = "Icon size",
  step = 8,
  showTicks = true,
  disabled = false,
}: {
  label?: string;
  step?: number;
  showTicks?: boolean;
  disabled?: boolean;
}) {
  return (
    <div className="w-full max-w-sm">
      <InlineSlider label={label} defaultValue={48} min={16} max={96} step={step} showTicks={showTicks} disabled={disabled} formatValueText={(v) => `${v} pixels`} />
    </div>
  );
}
```

More examples: “Tick Slider”, “Fluid Slider”, “Wave Slider”, “Bubble Slider”, “Ruler Slider” — call get_component_examples.

## Source — components/ui/range-slider.tsx

```tsx
"use client";

import * as React from "react";
import {
  animate,
  AnimatePresence,
  motion,
  useAnimate,
  useDragControls,
  useMotionValue,
  useMotionValueEvent,
  useReducedMotion,
  useSpring,
  useTransform,
  useVelocity,
  type MotionValue,
} from "motion/react";
import { cn } from "@/lib/utils";

/* -------------------------------------------------------------------------------------------------
 * Shared slider core
 * -----------------------------------------------------------------------------------------------*/

export type SliderBaseProps = {
  value?: number;
  defaultValue?: number;
  onValueChange?: (value: number) => void;
  min?: number;
  max?: number;
  step?: number;
  disabled?: boolean;
  className?: string;
  "aria-label"?: string;
  /** Announced instead of the raw number. Pass one when the value carries a unit ("72.5 kg"). */
  formatValueText?: (value: number) => string;
};

const POSITION_SPRING = { stiffness: 520, damping: 38, mass: 0.6 };

const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));

function decimalsOf(n: number) {
  const s = n.toString();
  if (s.includes("e-")) return Number(s.split("e-")[1]);
  return s.split(".")[1]?.length ?? 0;
}

function snapTo(raw: number, min: number, max: number, step: number) {
  const snapped = Math.round((raw - min) / step) * step + min;
  return clamp(Number(snapped.toFixed(decimalsOf(step))), min, max);
}

function useControllable(
  prop: number | undefined,
  fallback: number,
  onChange?: (value: number) => void,
) {
  const [inner, setInner] = React.useState(fallback);
  const controlled = prop !== undefined;
  const onChangeRef = React.useRef(onChange);
  React.useLayoutEffect(() => {
    onChangeRef.current = onChange;
  });
  const set = React.useCallback(
    (next: number) => {
      if (!controlled) setInner(next);
      onChangeRef.current?.(next);
    },
    [controlled],
  );
  return [controlled ? prop : inner, set] as const;
}

/** A motion value that springs toward `target`, or jumps when reduced motion is on. */
function useFollow(target: number, reduce: boolean | null, config = POSITION_SPRING) {
  const mv = useSpring(target, config);
  React.useEffect(() => {
    if (reduce) mv.jump(target);
    else mv.set(target);
  }, [target, reduce, mv]);
  return mv;
}

function useSlider(props: SliderBaseProps, labelId?: string) {
  const { min = 0, max = 100, step = 1, disabled = false } = props;
  const [value, setValue] = useControllable(props.value, props.defaultValue ?? min, props.onValueChange);
  const valueRef = React.useRef(value);
  valueRef.current = value;

  const railRef = React.useRef<HTMLDivElement | null>(null);
  const focusRef = React.useRef<HTMLDivElement | null>(null);
  const draggingRef = React.useRef(false);
  const [dragging, setDragging] = React.useState(false);

  const commit = React.useCallback(
    (raw: number) => {
      const next = snapTo(raw, min, max, step);
      if (next === valueRef.current) return;
      valueRef.current = next;
      setValue(next);
    },
    [min, max, step, setValue],
  );

  const valueFromPointer = (clientX: number) => {
    const rect = railRef.current?.getBoundingClientRect();
    if (!rect || rect.width === 0) return valueRef.current;
    return min + clamp((clientX - rect.left) / rect.width, 0, 1) * (max - min);
  };

  const end = () => {
    if (!draggingRef.current) return;
    draggingRef.current = false;
    setDragging(false);
  };

  const trackProps = {
    onPointerDown: (e: React.PointerEvent<HTMLElement>) => {
      if (disabled || e.button !== 0) return;
      e.preventDefault();
      e.currentTarget.setPointerCapture(e.pointerId);
      draggingRef.current = true;
      setDragging(true);
      commit(valueFromPointer(e.clientX));
      focusRef.current?.focus({ preventScroll: true });
    },
    onPointerMove: (e: React.PointerEvent<HTMLElement>) => {
      if (draggingRef.current) commit(valueFromPointer(e.clientX));
    },
    onPointerUp: end,
    onPointerCancel: end,
    onLostPointerCapture: end,
  };

  const onKeyDown = (e: React.KeyboardEvent) => {
    if (disabled) return;
    const big = Math.max(step, (max - min) / 10);
    const deltas: Record<string, number> = {
      ArrowRight: step,
      ArrowUp: step,
      ArrowLeft: -step,
      ArrowDown: -step,
      PageUp: big,
      PageDown: -big,
    };
    if (e.key in deltas) commit(valueRef.current + deltas[e.key]);
    else if (e.key === "Home") commit(min);
    else if (e.key === "End") commit(max);
    else return;
    e.preventDefault();
  };

  const focusProps = {
    role: "slider" as const,
    tabIndex: disabled ? -1 : 0,
    "aria-valuemin": min,
    "aria-valuemax": max,
    "aria-valuenow": value,
    "aria-valuetext": props.formatValueText?.(value),
    "aria-label": props["aria-label"],
    "aria-labelledby": props["aria-label"] ? undefined : labelId,
    "aria-disabled": disabled || undefined,
    "aria-orientation": "horizontal" as const,
    onKeyDown,
  };

  const pct = max === min ? 0 : ((value - min) / (max - min)) * 100;
  return { value, pct, min, max, step, disabled, dragging, railRef, focusRef, trackProps, focusProps };
}

/** Bounce the element whenever `trigger` changes (skips first render and reduced motion). */
function useLandBounce(trigger: unknown, reduce: boolean | null, keyframes: Record<string, number[]>) {
  const [scope, run] = useAnimate<HTMLDivElement>();
  const first = React.useRef(true);
  React.useEffect(() => {
    if (first.current) {
      first.current = false;
      return;
    }
    if (reduce || !scope.current) return;
    run(scope.current, keyframes, { type: "spring", stiffness: 700, damping: 13, mass: 0.5 });
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [trigger, reduce]);
  return scope;
}

/* -------------------------------------------------------------------------------------------------
 * RangeSlider — tick dots and a vertical-bar thumb that bounces as it lands on each step
 * -----------------------------------------------------------------------------------------------*/

export type RangeSliderProps = SliderBaseProps & {
  /** Visible label above the track. */
  label?: string;
  /** Formats the visible readout. */
  format?: (value: number) => string;
  /** Render a tick dot at each step (capped at 41 dots). */
  showTicks?: boolean;
};

export function RangeSlider({ label, format, showTicks = true, className, ...props }: RangeSliderProps) {
  const labelId = React.useId();
  const s = useSlider(props, label ? labelId : undefined);
  const reduce = useReducedMotion();
  const pos = useFollow(s.pct, reduce);
  const left = useTransform(pos, (p) => `${p}%`);
  const bar = useLandBounce(s.value, reduce, { scaleY: [1.5, 1], scaleX: [0.6, 1] });

  const stepCount = Math.round((s.max - s.min) / s.step);
  const ticks = showTicks
    ? Array.from({ length: stepCount <= 40 ? stepCount + 1 : 11 }, (_, i) => i).map((i, _, all) => (i / (all.length - 1)) * 100)
    : [];

  return (
    <div className={cn("w-full select-none", s.disabled && "opacity-50", className)}>
      {(label || format) && (
        <div className="mb-2.5 flex items-baseline justify-between gap-4 text-sm">
          <span id={labelId} className="text-muted-foreground">
            {label}
          </span>
          <span className="font-mono tabular-nums text-foreground">{format ? format(s.value) : s.value}</span>
        </div>
      )}
      <div
        {...s.trackProps}
        className={cn(
          "relative h-9 touch-none rounded-lg bg-muted ring-1 ring-inset ring-border",
          s.disabled ? "cursor-not-allowed" : "cursor-pointer",
        )}
      >
        <motion.div
          aria-hidden
          className="absolute inset-y-0 left-0 rounded-l-lg bg-foreground/[0.06]"
          style={{ width: useTransform(pos, (p) => `calc(1rem + (100% - 2rem) * ${p / 100})`) }}
        />
        <div ref={s.railRef} className="absolute inset-x-4 inset-y-0">
          {ticks.map((t) => (
            <span
              key={t}
              aria-hidden
              className={cn(
                "absolute top-1/2 size-1 -translate-x-1/2 -translate-y-1/2 rounded-full transition-colors duration-200",
                t <= s.pct ? "bg-foreground/55" : "bg-muted-foreground/35",
              )}
              style={{ left: `${t}%` }}
            />
          ))}
          <motion.div
            ref={s.focusRef}
            {...s.focusProps}
            style={{ left }}
            className="absolute inset-y-0 flex w-6 -translate-x-1/2 items-center justify-center rounded-md outline-none focus-visible:ring-2 focus-visible:ring-ring"
          >
            <motion.div
              ref={bar}
              className="h-5 w-1 rounded-full bg-foreground shadow-[0_0_0_3px_var(--color-muted)]"
              animate={{ height: s.dragging ? 24 : 20 }}
              transition={{ type: "spring", stiffness: 500, damping: 30 }}
            />
          </motion.div>
        </div>
      </div>
    </div>
  );
}

/* -------------------------------------------------------------------------------------------------
 * InlineSlider — inset fill with the label and value living inside the track
 * -----------------------------------------------------------------------------------------------*/

export type InlineSliderProps = SliderBaseProps & {
  label: string;
  format?: (value: number) => string;
  /** Show ten evenly spaced markers, hidden wherever they would sit under text. */
  showTicks?: boolean;
};

export function InlineSlider({ label, format, showTicks = true, className, ...props }: InlineSliderProps) {
  const labelId = React.useId();
  const s = useSlider(props, labelId);
  const reduce = useReducedMotion();
  const pos = useFollow(s.pct, reduce);
  const left = useTransform(pos, (p) => `${p}%`);
  const bar = useLandBounce(s.value, reduce, { scaleY: [1.35, 1] });

  const labelRef = React.useRef<HTMLSpanElement>(null);
  const valueRef = React.useRef<HTMLSpanElement>(null);
  const [m, setM] = React.useState({ rail: 0, label: 0, value: 0 });
  React.useLayoutEffect(() => {
    const measure = () =>
      setM({
        rail: s.railRef.current?.offsetWidth ?? 0,
        label: labelRef.current?.offsetWidth ?? 0,
        value: valueRef.current?.offsetWidth ?? 0,
      });
    measure();
    const ro = new ResizeObserver(measure);
    [s.railRef.current, labelRef.current, valueRef.current].forEach((el) => el && ro.observe(el));
    return () => ro.disconnect();
  }, [s.railRef]);

  // Rail sits 8px in from the track; text sits 14px in, so text spans start 6px into the rail.
  const pad = 8;
  const labelSpan: [number, number] = [6 - pad, 6 + m.label + pad];
  const valueSpan: [number, number] = [m.rail - 6 - m.value - pad, m.rail - 6 + pad];
  const inSpan = (px: number, [a, b]: [number, number]) => px >= a && px <= b;
  const thumbPx = (s.pct / 100) * m.rail;
  const display = format ? format(s.value) : s.value.toFixed(decimalsOf(s.step));

  return (
    <div
      {...s.trackProps}
      className={cn(
        "relative h-10 w-full touch-none select-none rounded-xl bg-muted ring-1 ring-inset ring-border",
        s.disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer",
        className,
      )}
    >
      <motion.div
        aria-hidden
        className="absolute inset-y-1 left-1 rounded-lg bg-foreground/[0.09]"
        style={{ width: useTransform(pos, (p) => `calc(0.25rem + (100% - 1rem) * ${p / 100})`) }}
      />
      <span
        ref={labelRef}
        id={labelId}
        className={cn(
          "pointer-events-none absolute left-3.5 top-1/2 -translate-y-1/2 text-sm font-medium transition-opacity duration-150",
          inSpan(thumbPx, labelSpan) ? "opacity-30" : "opacity-100",
        )}
      >
        {label}
      </span>
      <span
        ref={valueRef}
        className={cn(
          "pointer-events-none absolute right-3.5 top-1/2 -translate-y-1/2 font-mono text-sm tabular-nums transition-opacity duration-150",
          inSpan(thumbPx, valueSpan) ? "opacity-30" : "opacity-100",
        )}
      >
        {display}
      </span>
      <div ref={s.railRef} className="absolute inset-x-2 inset-y-0">
        {showTicks &&
          Array.from({ length: 10 }, (_, i) => (i / 9) * 100).map((t) => {
            const px = (t / 100) * m.rail;
            const hidden = inSpan(px, labelSpan) || inSpan(px, valueSpan);
            return (
              <span
                key={t}
                aria-hidden
                className={cn(
                  "absolute top-1/2 size-1 -translate-x-1/2 -translate-y-1/2 rounded-full bg-muted-foreground/40 transition-opacity",
                  hidden && "opacity-0",
                )}
                style={{ left: `${t}%` }}
              />
            );
          })}
        <motion.div
          ref={s.focusRef}
          {...s.focusProps}
          style={{ left }}
          className="absolute inset-y-0 flex w-5 -translate-x-1/2 items-center justify-center rounded-md outline-none focus-visible:ring-2 focus-visible:ring-ring"
        >
          <motion.div ref={bar} className="h-5 w-[3px] rounded-full bg-foreground" />
        </motion.div>
      </div>
    </div>
  );
}

/* -------------------------------------------------------------------------------------------------
 * FluidSlider — no thumb; a liquid fill, and text that flips colour where the fill covers it
 * -----------------------------------------------------------------------------------------------*/

export type FluidSliderProps = SliderBaseProps & {
  label?: string;
  format?: (value: number) => string;
};

export function FluidSlider({ label, format = (v) => `${v}%`, className, ...props }: FluidSliderProps) {
  const labelId = React.useId();
  const s = useSlider(props, label ? labelId : undefined);
  const reduce = useReducedMotion();
  // A softer, slightly underdamped spring is what makes the fill feel liquid.
  const pos = useFollow(s.pct, reduce, { stiffness: 260, damping: 22, mass: 0.8 });
  const width = useTransform(pos, (p) => `${clamp(p, 0, 100)}%`);
  const clip = useTransform(pos, (p) => `inset(0 ${100 - clamp(p, 0, 100)}% 0 0)`);
  const velocity = useVelocity(pos);
  const stretch = useSpring(useTransform(velocity, (v) => 1 + clamp(Math.abs(v) / 900, 0, 0.35)), {
    stiffness: 400,
    damping: 30,
  });

  const setRefs = (el: HTMLDivElement | null) => {
    s.railRef.current = el;
    s.focusRef.current = el;
  };

  const text = (inverse: boolean) => (
    <div
      className={cn(
        "flex h-full items-center justify-between px-4 text-sm font-medium",
        inverse ? "text-background" : "text-foreground",
      )}
    >
      <span id={inverse ? undefined : labelId}>{label}</span>
      <span className="font-mono tabular-nums">{format(s.value)}</span>
    </div>
  );

  return (
    <motion.div
      ref={setRefs}
      {...s.trackProps}
      {...s.focusProps}
      animate={{ scale: s.dragging && !reduce ? 0.985 : 1 }}
      transition={{ type: "spring", stiffness: 500, damping: 30 }}
      className={cn(
        "relative h-11 w-full touch-none select-none overflow-hidden rounded-xl bg-muted outline-none ring-1 ring-inset ring-border focus-visible:ring-2 focus-visible:ring-ring",
        s.disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer",
        className,
      )}
    >
      <motion.div aria-hidden className="absolute inset-y-0 left-0 bg-foreground" style={{ width }}>
        <motion.div
          className="absolute inset-y-0 right-0 w-3 origin-left translate-x-1/2 rounded-full bg-foreground"
          style={{ scaleX: stretch }}
        />
      </motion.div>
      {text(false)}
      <motion.div aria-hidden className="absolute inset-0" style={{ clipPath: clip }}>
        {text(true)}
      </motion.div>
    </motion.div>
  );
}

/* -------------------------------------------------------------------------------------------------
 * WaveSlider — equalizer bars that swell around the handle
 * -----------------------------------------------------------------------------------------------*/

export type WaveSliderProps = SliderBaseProps & {
  label?: string;
  format?: (value: number) => string;
  /** Number of bars drawn across the track. */
  bars?: number;
};

function WaveBar({
  center,
  width,
  pos,
  energy,
  active,
}: {
  center: number;
  width: number;
  pos: MotionValue<number>;
  energy: MotionValue<number>;
  active: boolean;
}) {
  const target = useTransform(() => {
    const d = (center - pos.get()) / width;
    const g = Math.exp(-(d * d) / (2 * 2.4 * 2.4));
    return 6 + 26 * g * energy.get() + (center <= pos.get() ? 6 : 0);
  });
  const height = useSpring(target, { stiffness: 420, damping: 28 });
  return (
    <motion.span
      aria-hidden
      className={cn(
        "w-full max-w-1.5 rounded-full transition-colors duration-200",
        active ? "bg-foreground" : "bg-muted-foreground/30",
      )}
      style={{ height }}
    />
  );
}

export function WaveSlider({ label, format, bars = 32, className, ...props }: WaveSliderProps) {
  const labelId = React.useId();
  const s = useSlider(props, label ? labelId : undefined);
  const reduce = useReducedMotion();
  const pos = useFollow(s.pct, reduce);
  const [hot, setHot] = React.useState(false);
  const energy = useFollow(s.dragging || hot ? 1 : 0.3, reduce, { stiffness: 200, damping: 24, mass: 1 });

  const setRefs = (el: HTMLDivElement | null) => {
    s.railRef.current = el;
    s.focusRef.current = el;
  };

  return (
    <div className={cn("w-full select-none", s.disabled && "opacity-50", className)}>
      {(label || format) && (
        <div className="mb-2 flex items-baseline justify-between text-sm">
          <span id={labelId} className="text-muted-foreground">
            {label}
          </span>
          <span className="font-mono tabular-nums">{format ? format(s.value) : s.value}</span>
        </div>
      )}
      <div
        ref={setRefs}
        {...s.trackProps}
        {...s.focusProps}
        onPointerEnter={() => setHot(true)}
        onPointerLeave={() => setHot(false)}
        onFocus={() => setHot(true)}
        onBlur={() => setHot(false)}
        className={cn(
          "flex h-12 w-full touch-none items-center gap-[3px] rounded-lg outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-4 focus-visible:ring-offset-background",
          s.disabled ? "cursor-not-allowed" : "cursor-pointer",
        )}
      >
        {Array.from({ length: bars }, (_, i) => {
          const center = ((i + 0.5) / bars) * 100;
          return (
            <span key={i} className="flex h-full flex-1 items-center justify-center">
              <WaveBar center={center} width={100 / bars} pos={pos} energy={energy} active={center <= s.pct} />
            </span>
          );
        })}
      </div>
    </div>
  );
}

/* -------------------------------------------------------------------------------------------------
 * BubbleSlider — a value bubble pops out of the thumb and leans with your drag speed
 * -----------------------------------------------------------------------------------------------*/

export type BubbleSliderProps = SliderBaseProps & {
  format?: (value: number) => string;
};

export function BubbleSlider({ format = (v) => String(v), className, ...props }: BubbleSliderProps) {
  const s = useSlider(props);
  const reduce = useReducedMotion();
  const pos = useFollow(s.pct, reduce, { stiffness: 700, damping: 45, mass: 0.4 });
  const left = useTransform(pos, (p) => `${p}%`);
  const velocity = useVelocity(pos);
  const rotate = useSpring(
    useTransform(velocity, (v) => clamp(-v / 10, -28, 28)),
    { stiffness: 260, damping: 11 },
  );
  const scaleX = useSpring(useTransform(velocity, (v) => 1 + clamp(Math.abs(v) / 1400, 0, 0.18)), {
    stiffness: 300,
    damping: 18,
  });
  const scaleY = useTransform(scaleX, (x) => 2 - x);
  const [focused, setFocused] = React.useState(false);
  const open = s.dragging || focused;

  return (
    <div
      {...s.trackProps}
      className={cn(
        "relative flex h-14 w-full touch-none select-none items-end pb-3",
        s.disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer",
        className,
      )}
    >
      <div ref={s.railRef} className="relative h-1.5 w-full rounded-full bg-muted ring-1 ring-inset ring-border">
        <motion.div aria-hidden className="absolute inset-y-0 left-0 rounded-full bg-foreground" style={{ width: left }} />
        <motion.div
          ref={s.focusRef}
          {...s.focusProps}
          onFocus={(e) => setFocused(e.currentTarget.matches(":focus-visible"))}
          onBlur={() => setFocused(false)}
          className="absolute top-1/2 size-5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-foreground shadow-[0_0_0_4px_var(--color-background)] outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-4 focus-visible:ring-offset-background"
          style={{ left }}
          animate={{ scale: s.dragging ? 1.15 : 1 }}
          transition={{ type: "spring", stiffness: 500, damping: 25 }}
        >
          <AnimatePresence>
            {open && (
              <motion.div
                aria-hidden
                className="pointer-events-none absolute bottom-[calc(100%+10px)] left-1/2 origin-bottom"
                style={{ x: "-50%", rotate: reduce ? 0 : rotate, scaleX: reduce ? 1 : scaleX, scaleY: reduce ? 1 : scaleY }}
                initial={{ opacity: 0, scale: 0.3, y: 8 }}
                animate={{ opacity: 1, scale: 1, y: 0 }}
                exit={{ opacity: 0, scale: 0.4, y: 6, transition: { duration: 0.12 } }}
                transition={{ type: "spring", stiffness: 600, damping: 22 }}
              >
                <div className="relative min-w-10 rounded-lg bg-foreground px-2 py-1 text-center font-mono text-xs font-medium tabular-nums text-background">
                  {format(s.value)}
                  <span className="absolute -bottom-1 left-1/2 size-2 -translate-x-1/2 rotate-45 rounded-[2px] bg-foreground" />
                </div>
              </motion.div>
            )}
          </AnimatePresence>
        </motion.div>
      </div>
    </div>
  );
}

/* -------------------------------------------------------------------------------------------------
 * RulerSlider — the needle stays put and the scale scrolls under it, with momentum
 * -----------------------------------------------------------------------------------------------*/

export type RulerSliderProps = SliderBaseProps & {
  /** Pixels between two steps. */
  gap?: number;
  /** Label every Nth step; those ticks are drawn tall. */
  majorEvery?: number;
  /** Unit shown next to the value. */
  unit?: string;
};

export function RulerSlider({ gap = 14, majorEvery = 5, unit, className, ...props }: RulerSliderProps) {
  const { min = 0, max = 100, step = 1, disabled = false } = props;
  const [value, setValue] = useControllable(props.value, props.defaultValue ?? min, props.onValueChange);
  const reduce = useReducedMotion();
  const decimals = decimalsOf(step);
  const steps = Math.round((max - min) / step);
  const toX = React.useCallback((v: number) => -((v - min) / step) * gap, [min, step, gap]);

  const x = useMotionValue(toX(value));
  const controls = useDragControls();
  const lastInternal = React.useRef(value);

  useMotionValueEvent(x, "change", (latest) => {
    const next = snapTo(min + (-latest / gap) * step, min, max, step);
    if (next !== lastInternal.current) {
      lastInternal.current = next;
      setValue(next);
    }
  });

  const glide = React.useCallback(
    (v: number) => {
      if (reduce) x.set(toX(v));
      else animate(x, toX(v), { type: "spring", stiffness: 400, damping: 40 });
    },
    [reduce, toX, x],
  );

  // Follow external (controlled) changes that did not come from the ruler itself.
  React.useEffect(() => {
    if (value !== lastInternal.current) {
      lastInternal.current = value;
      glide(value);
    }
  }, [value, glide]);

  const onKeyDown = (e: React.KeyboardEvent) => {
    if (disabled) return;
    const deltas: Record<string, number> = {
      ArrowRight: step,
      ArrowUp: step,
      ArrowLeft: -step,
      ArrowDown: -step,
      PageUp: step * majorEvery,
      PageDown: -step * majorEvery,
    };
    let next: number;
    if (e.key in deltas) next = snapTo(value + deltas[e.key], min, max, step);
    else if (e.key === "Home") next = min;
    else if (e.key === "End") next = max;
    else return;
    e.preventDefault();
    lastInternal.current = next;
    setValue(next);
    glide(next);
  };

  return (
    <div className={cn("w-full select-none", disabled && "opacity-50", className)}>
      <div className="mb-3 flex items-baseline justify-center gap-1.5" aria-hidden>
        <span className="text-4xl font-semibold tracking-tight tabular-nums">{value.toFixed(decimals)}</span>
        {unit && <span className="text-base text-muted-foreground">{unit}</span>}
      </div>
      <div
        role="slider"
        tabIndex={disabled ? -1 : 0}
        aria-valuemin={min}
        aria-valuemax={max}
        aria-valuenow={value}
        aria-valuetext={props.formatValueText?.(value) ?? (unit ? `${value.toFixed(decimals)} ${unit}` : undefined)}
        aria-label={props["aria-label"]}
        aria-disabled={disabled || undefined}
        onKeyDown={onKeyDown}
        onPointerDown={(e) => !disabled && controls.start(e)}
        className={cn(
          "relative h-16 w-full touch-none overflow-hidden rounded-xl outline-none focus-visible:ring-2 focus-visible:ring-ring",
          "[mask-image:linear-gradient(to_right,transparent,black_22%,black_78%,transparent)]",
          disabled ? "cursor-not-allowed" : "cursor-grab active:cursor-grabbing",
        )}
      >
        <motion.div
          className="absolute inset-y-0 left-1/2"
          style={{ x, width: steps * gap }}
          drag={disabled ? false : "x"}
          dragControls={controls}
          dragListener={false}
          dragConstraints={{ left: toX(max), right: 0 }}
          dragElastic={0.06}
          dragTransition={{
            power: 0.22,
            timeConstant: 240,
            modifyTarget: (t) => Math.round(t / gap) * gap,
            bounceStiffness: 500,
            bounceDamping: 40,
          }}
        >
          {Array.from({ length: steps + 1 }, (_, i) => {
            const major = i % majorEvery === 0;
            return (
              <span
                key={i}
                aria-hidden
                className="absolute bottom-2 flex -translate-x-1/2 flex-col items-center"
                style={{ left: i * gap }}
              >
                {major && (
                  <span className="mb-1.5 font-mono text-[11px] tabular-nums text-muted-foreground">
                    {(min + i * step).toFixed(decimals)}
                  </span>
                )}
                <span className={cn("w-px rounded-full", major ? "h-5 bg-foreground/60" : "h-3 bg-muted-foreground/40")} />
              </span>
            );
          })}
        </motion.div>
        <span
          aria-hidden
          className="absolute bottom-1 left-1/2 h-8 w-[3px] -translate-x-1/2 rounded-full bg-ember shadow-[0_0_12px_var(--color-ember-glow)]"
        />
      </div>
    </div>
  );
}
```