32 px
tsx
import {
  useEffect,
  useId,
  useLayoutEffect,
  useRef,
  useState,
  type CSSProperties,
  type ChangeEvent,
  type KeyboardEvent,
  type PointerEvent,
  type TransitionEvent,
} from "react";

export type DraggableInputProps = {
  id?: string;
  label: string;
  value: number;
  min: number;
  max: number;
  step: number;
  onChange: (value: number) => void;
  formatValue?: (value: number) => string;
  inputScale?: number;
  disabled?: boolean;
  onInteractionCancel?: () => void;
  onInteractionEnd?: () => void;
  onInteractionStart?: () => void;
};

type DraggableInputStyle = CSSProperties & {
  "--draggable-input-progress": string;
};

const SLIDER_MARKER_INSET = 10;
const SLIDER_OVERDRAG_LIMIT = 8;
const SLIDER_OVERDRAG_RESISTANCE = 24;
const SLIDER_CONTENT_SHIFT_LIMIT = 2;
const SLIDER_MARKER_SHIFT_LIMIT = 3;
const SLIDER_VERTICAL_COMPRESSION_LIMIT = 0.04;
const SLIDER_MARKER_GHOST_OPACITY = 0.16;
const SLIDER_MARKER_DARK_GHOST_OPACITY = 0.08;
const SLIDER_MARKER_COLLISION_FADE_DISTANCE = 16;
const SLIDER_MARKER_COLLISION_PADDING = 4;
const SLIDER_EDITOR_SHIELD_ENTRY_DISTANCE = 16;
const PREVIEW_REST_VALUE = 50;
const PREVIEW_INTENT_DELAY = 90;
const PREVIEW_SCRUB_DURATION = 360;
const PREVIEW_TUG_DURATION = 140;
const PREVIEW_POINTER_TRAVEL = 32;
const PREVIEW_RELEASE_DURATION = 240;
const PREVIEW_RESET_DURATION = 220;

type SliderTextZone = {
  left: number;
  right: number;
};

function clampAndSnap(value: number, min: number, max: number, step: number) {
  const clamped = Math.min(max, Math.max(min, value));
  if (!Number.isFinite(step) || step <= 0) return clamped;
  const snapped = min + Math.round((clamped - min) / step) * step;
  const precision = Math.max(0, (String(step).split(".")[1] ?? "").length);
  return Number(Math.min(max, Math.max(min, snapped)).toFixed(precision));
}

function progressFor(value: number, min: number, max: number) {
  const span = max - min;
  if (!Number.isFinite(value) || !Number.isFinite(span) || span <= 0) return 0;
  return Number(
    Math.min(100, Math.max(0, ((value - min) / span) * 100)).toFixed(6),
  );
}

function pointerValueFor(
  clientX: number,
  left: number,
  right: number,
  min: number,
  max: number,
  step: number,
) {
  const width = right - left;
  if (width <= 0) return null;
  const fillEdge = Math.min(
    width,
    Math.max(0, clientX - left + SLIDER_MARKER_INSET),
  );
  const progress = fillEdge / width;
  return clampAndSnap(min + progress * (max - min), min, max, step);
}

function overdragFor(clientX: number, left: number, right: number) {
  const distance = clientX < left
    ? clientX - left
    : clientX > right
      ? clientX - right
      : 0;
  if (distance === 0) return 0;
  const magnitude = SLIDER_OVERDRAG_LIMIT * (
    1 - Math.exp(-Math.abs(distance) / SLIDER_OVERDRAG_RESISTANCE)
  );
  return Number((Math.sign(distance) * magnitude).toFixed(3));
}

function rubberBandFor(overdrag: number, width: number) {
  const progress = Math.min(1, Math.abs(overdrag) / SLIDER_OVERDRAG_LIMIT);
  const direction = Math.sign(overdrag);
  return {
    capsuleScaleX: Number((1 + Math.abs(overdrag) / width).toFixed(6)),
    capsuleScaleY: Number((
      1 - progress * SLIDER_VERTICAL_COMPRESSION_LIMIT
    ).toFixed(6)),
    contentShift: Number((
      direction * progress * SLIDER_CONTENT_SHIFT_LIMIT
    ).toFixed(3)),
    markerShift: Number((
      direction * progress * SLIDER_MARKER_SHIFT_LIMIT
    ).toFixed(3)),
  };
}

function easeInOutCubic(progress: number) {
  return progress < 0.5
    ? 4 * progress * progress * progress
    : 1 - Math.pow(-2 * progress + 2, 3) / 2;
}

function easeOutCubic(progress: number) {
  return 1 - Math.pow(1 - progress, 3);
}

function waitForPreview(ms: number, signal: AbortSignal) {
  return new Promise<boolean>((resolve) => {
    if (signal.aborted) {
      resolve(false);
      return;
    }
    let timeout = 0;
    const abort = () => finish(false);
    const finish = (completed: boolean) => {
      window.clearTimeout(timeout);
      signal.removeEventListener("abort", abort);
      resolve(completed);
    };
    timeout = window.setTimeout(() => finish(true), ms);
    signal.addEventListener("abort", abort, { once: true });
  });
}

function animatePreview(
  duration: number,
  signal: AbortSignal,
  onFrame: (progress: number) => void,
  easing: (progress: number) => number = (progress) => progress,
) {
  return new Promise<boolean>((resolve) => {
    if (signal.aborted) {
      resolve(false);
      return;
    }
    if (duration <= 0) {
      onFrame(1);
      resolve(true);
      return;
    }
    let frame = 0;
    let settled = false;
    const startedAt = performance.now();
    const finish = (completed: boolean) => {
      if (settled) return;
      settled = true;
      cancelAnimationFrame(frame);
      signal.removeEventListener("abort", abort);
      resolve(completed);
    };
    const abort = () => finish(false);
    const tick = (now: number) => {
      if (signal.aborted) {
        finish(false);
        return;
      }
      const progress = Math.min(1, (now - startedAt) / duration);
      onFrame(easing(progress));
      if (progress === 1) {
        finish(true);
        return;
      }
      frame = requestAnimationFrame(tick);
    };
    signal.addEventListener("abort", abort, { once: true });
    frame = requestAnimationFrame(tick);
  });
}

function clearOverdragStyles(row: HTMLElement) {
  row.removeAttribute("data-overdragging");
  row.removeAttribute("data-overdrag-settling");
  row.removeAttribute("data-overdrag-direction");
  row.style.removeProperty("--draggable-input-overdrag");
  row.style.removeProperty("--draggable-input-capsule-scale-x");
  row.style.removeProperty("--draggable-input-capsule-scale-y");
  row.style.removeProperty("--draggable-input-content-shift");
  row.style.removeProperty("--draggable-input-marker-shift");
}

function writePreviewOverdrag(
  row: HTMLElement,
  width: number,
  pointerTravel: number,
) {
  const bounds = { left: 0, right: width };
  const overdrag = overdragFor(
    bounds.right + pointerTravel,
    bounds.left,
    bounds.right,
  );
  const rubberBand = rubberBandFor(overdrag, bounds.right - bounds.left);
  row.removeAttribute("data-overdrag-settling");
  row.toggleAttribute("data-overdragging", overdrag !== 0);
  row.setAttribute("data-overdrag-direction", "right");
  row.style.setProperty("--draggable-input-overdrag", `${overdrag}px`);
  row.style.setProperty(
    "--draggable-input-capsule-scale-x",
    String(rubberBand.capsuleScaleX),
  );
  row.style.setProperty(
    "--draggable-input-capsule-scale-y",
    String(rubberBand.capsuleScaleY),
  );
  row.style.setProperty(
    "--draggable-input-content-shift",
    `${rubberBand.contentShift}px`,
  );
  row.style.setProperty(
    "--draggable-input-marker-shift",
    `${rubberBand.markerShift}px`,
  );
}

function releasePreviewOverdrag(row: HTMLElement) {
  if (!row.hasAttribute("data-overdragging")) return 0;
  row.removeAttribute("data-overdragging");
  row.setAttribute("data-overdrag-settling", "");
  row.style.setProperty("--draggable-input-overdrag", "0px");
  row.style.setProperty("--draggable-input-capsule-scale-x", "1");
  row.style.setProperty("--draggable-input-capsule-scale-y", "1");
  row.style.setProperty("--draggable-input-content-shift", "0px");
  row.style.setProperty("--draggable-input-marker-shift", "0px");
  return PREVIEW_RELEASE_DURATION;
}

function markerOpacityFor(
  markerX: number,
  zones: SliderTextZone[],
  ghostOpacity = SLIDER_MARKER_GHOST_OPACITY,
) {
  if (zones.length === 0) return 1;
  const distance = Math.min(...zones.map((zone) => {
    if (markerX < zone.left) return zone.left - markerX;
    if (markerX > zone.right) return markerX - zone.right;
    return 0;
  }));
  const fadeProgress = Math.min(
    1,
    distance / SLIDER_MARKER_COLLISION_FADE_DISTANCE,
  );
  return Number((
    ghostOpacity + (1 - ghostOpacity) * fadeProgress
  ).toFixed(3));
}

function editorShieldOpacityFor(
  markerX: number,
  zone: SliderTextZone | undefined,
) {
  if (!zone) return 0;
  const entryStart = zone.left - SLIDER_EDITOR_SHIELD_ENTRY_DISTANCE;
  if (markerX <= entryStart) return 0;
  if (markerX < zone.left) {
    return Number((
      (markerX - entryStart) / SLIDER_EDITOR_SHIELD_ENTRY_DISTANCE
    ).toFixed(3));
  }
  return 1;
}

function textZoneFor(element: HTMLElement | null): SliderTextZone | null {
  if (!element) return null;
  const bounds = element.getBoundingClientRect();
  return {
    left: bounds.left - SLIDER_MARKER_COLLISION_PADDING,
    right: bounds.right + SLIDER_MARKER_COLLISION_PADDING,
  };
}

export function DraggableInput({
  id,
  label,
  value,
  min,
  max,
  step,
  onChange,
  formatValue,
  inputScale = 1,
  disabled = false,
  onInteractionCancel,
  onInteractionEnd,
  onInteractionStart,
}: DraggableInputProps) {
  const generatedId = useId();
  const inputId = id ?? `draggable-input-${generatedId}`;
  const scaledValue = Number((value * inputScale).toFixed(6));
  const [draft, setDraft] = useState(String(scaledValue));
  const [isEditing, setIsEditing] = useState(false);
  const [valueFocusedByPointer, setValueFocusedByPointer] = useState(false);
  const displayedDraft = draft;
  const parsedDisplayedDraft = Number(displayedDraft);
  const accessibleValue =
    displayedDraft.trim() !== "" && Number.isFinite(parsedDisplayedDraft)
      ? parsedDisplayedDraft
      : scaledValue;
  const formattedValue = formatValue?.(value) ?? String(scaledValue);
  const rowRef = useRef<HTMLDivElement>(null);
  const labelTextRef = useRef<HTMLSpanElement>(null);
  const formattedValueRef = useRef<HTMLSpanElement>(null);
  const markerRef = useRef<HTMLSpanElement>(null);
  const valueInputRef = useRef<HTMLInputElement>(null);
  const previousCanonicalRef = useRef(scaledValue);
  const editStartValueRef = useRef(value);
  const interactionActiveRef = useRef(false);
  const activePointerIdRef = useRef<number | null>(null);
  const rangeBoundsRef = useRef<{ left: number; right: number } | null>(null);
  const markerCollisionZonesRef = useRef<SliderTextZone[]>([]);
  const lastOverdragRef = useRef<number | null>(null);
  const lastMarkerOpacityRef = useRef<number | null>(null);
  const lastEditorShieldOpacityRef = useRef<number | null>(null);
  const lastPointerValueRef = useRef<number | null>(null);
  const suppressNextValueBlurRef = useRef(false);
  const valuePointerDownRef = useRef(false);
  const onInteractionEndRef = useRef(onInteractionEnd);
  const onInteractionCancelRef = useRef(onInteractionCancel);
  const onInteractionStartRef = useRef(onInteractionStart);
  onInteractionEndRef.current = onInteractionEnd;
  onInteractionCancelRef.current = onInteractionCancel;
  onInteractionStartRef.current = onInteractionStart;
  const style: DraggableInputStyle = {
    "--draggable-input-progress": `${progressFor(value, min, max)}%`,
  };

  useEffect(() => {
    if (previousCanonicalRef.current === scaledValue) return;
    previousCanonicalRef.current = scaledValue;
    setDraft(String(scaledValue));
  }, [scaledValue]);

  useEffect(() => {
    return () => {
      if (!interactionActiveRef.current) return;
      interactionActiveRef.current = false;
      onInteractionEndRef.current?.();
    };
  }, []);

  useEffect(() => {
    if (isEditing) valueInputRef.current?.select();
  }, [isEditing]);

  useLayoutEffect(() => {
    updateRestingMarkerOpacity();
  }, [formattedValue, label, value]);

  function startInteraction() {
    if (disabled || interactionActiveRef.current) return;
    interactionActiveRef.current = true;
    onInteractionStartRef.current?.();
  }

  function endInteraction() {
    if (!interactionActiveRef.current) return;
    interactionActiveRef.current = false;
    onInteractionEndRef.current?.();
  }

  function cancelInteraction() {
    if (!interactionActiveRef.current) return;
    interactionActiveRef.current = false;
    if (onInteractionCancelRef.current) {
      onInteractionCancelRef.current();
      return;
    }
    onInteractionEndRef.current?.();
  }

  function commitDraft() {
    if (displayedDraft.trim() === "") {
      setDraft(String(scaledValue));
      return;
    }
    const parsed = Number(displayedDraft);
    if (!Number.isFinite(parsed)) {
      setDraft(String(scaledValue));
      return;
    }
    const nextValue = clampAndSnap(parsed / inputScale, min, max, step);
    setDraft(String(Number((nextValue * inputScale).toFixed(6))));
    if (nextValue !== value) onChange(nextValue);
  }

  function handleValueKeyDown(event: KeyboardEvent<HTMLInputElement>) {
    if (event.key === "Enter") {
      event.preventDefault();
      suppressNextValueBlurRef.current = true;
      commitDraft();
      event.currentTarget.blur();
      suppressNextValueBlurRef.current = false;
    } else if (event.key === "ArrowUp" || event.key === "ArrowDown") {
      event.preventDefault();
      const canonicalDraft =
        displayedDraft.trim() !== "" && Number.isFinite(parsedDisplayedDraft)
          ? parsedDisplayedDraft / inputScale
          : value;
      const nextValue = clampAndSnap(
        canonicalDraft + (event.key === "ArrowUp" ? step : -step),
        min,
        max,
        step,
      );
      const nextDraft = String(Number((nextValue * inputScale).toFixed(6)));
      setDraft(nextDraft);
    } else if (event.key === "Escape") {
      event.preventDefault();
      event.stopPropagation();
      suppressNextValueBlurRef.current = true;
      const editStartValue = editStartValueRef.current;
      const editStartScaledValue = Number(
        (editStartValue * inputScale).toFixed(6),
      );
      previousCanonicalRef.current = editStartScaledValue;
      setDraft(String(editStartScaledValue));
      if (editStartValue !== value) onChange(editStartValue);
      event.currentTarget.blur();
      suppressNextValueBlurRef.current = false;
    }
  }

  function handleValueBlur() {
    setIsEditing(false);
    if (suppressNextValueBlurRef.current) {
      suppressNextValueBlurRef.current = false;
      return;
    }
    commitDraft();
  }

  function handleValueChange(event: ChangeEvent<HTMLInputElement>) {
    const nextDraft = event.currentTarget.value;
    setDraft(nextDraft);
    if (!window.matchMedia("(pointer: coarse)").matches) return;
    if (nextDraft.trim() === "") return;

    const parsed = Number(nextDraft);
    if (!Number.isFinite(parsed)) return;

    const nextValue = clampAndSnap(parsed / inputScale, min, max, step);
    const nextScaledValue = Number((nextValue * inputScale).toFixed(6));
    // Preserve intermediate text such as "1." or "140" when the controlled
    // value echoes this live update back through the synchronization effect.
    previousCanonicalRef.current = nextScaledValue;
    if (nextValue !== value) onChange(nextValue);
  }

  function handleRangeChange(event: ChangeEvent<HTMLInputElement>) {
    if (
      activePointerIdRef.current !== null &&
      rangeBoundsRef.current !== null
    ) {
      return;
    }
    const nextValue = event.currentTarget.valueAsNumber;
    if (Number.isFinite(nextValue)) onChange(nextValue);
  }

  function reportPointerValue(
    clientX: number,
    bounds: { left: number; right: number },
  ) {
    const nextValue = pointerValueFor(
      clientX,
      bounds.left,
      bounds.right,
      min,
      max,
      step,
    );
    if (nextValue === null || lastPointerValueRef.current === nextValue) return;
    lastPointerValueRef.current = nextValue;
    if (nextValue !== value) onChange(nextValue);
  }

  function writeMarkerOpacity(
    row: HTMLElement,
    markerX: number,
    zones: SliderTextZone[],
  ) {
    const markerOpacity = markerOpacityFor(markerX, zones);
    const darkMarkerOpacity = markerOpacityFor(
      markerX,
      zones,
      SLIDER_MARKER_DARK_GHOST_OPACITY,
    );
    const editorShieldOpacity = editorShieldOpacityFor(
      markerX,
      zones[zones.length - 1],
    );
    const markerOpacityChanged = lastMarkerOpacityRef.current !== markerOpacity;
    const editorShieldOpacityChanged =
      lastEditorShieldOpacityRef.current !== editorShieldOpacity;
    if (!markerOpacityChanged && !editorShieldOpacityChanged) return;
    if (markerOpacityChanged) {
      lastMarkerOpacityRef.current = markerOpacity;
      row.style.setProperty(
        "--draggable-input-marker-opacity",
        String(markerOpacity),
      );
      row.style.setProperty(
        "--draggable-input-marker-opacity-dark",
        String(darkMarkerOpacity),
      );
    }
    if (editorShieldOpacityChanged) {
      lastEditorShieldOpacityRef.current = editorShieldOpacity;
      row.style.setProperty(
        "--draggable-input-editor-shield-opacity",
        String(editorShieldOpacity),
      );
    }
  }

  function updateRestingMarkerOpacity() {
    const row = rowRef.current;
    const marker = markerRef.current;
    if (!row || !marker) return;
    const markerBounds = marker.getBoundingClientRect();
    writeMarkerOpacity(
      row,
      (markerBounds.left + markerBounds.right) / 2,
      [
        textZoneFor(labelTextRef.current),
        textZoneFor(formattedValueRef.current),
      ].filter((zone): zone is SliderTextZone => zone !== null),
    );
  }

  function handleRangePointerDown(event: PointerEvent<HTMLInputElement>) {
    if (disabled || interactionActiveRef.current) return;
    const row = rowRef.current;
    row?.removeAttribute("data-overdrag-settling");
    row?.removeAttribute("data-overdragging");
    row?.removeAttribute("data-overdrag-direction");
    row?.style.removeProperty("--draggable-input-overdrag");
    row?.style.removeProperty("--draggable-input-capsule-scale-x");
    row?.style.removeProperty("--draggable-input-capsule-scale-y");
    row?.style.removeProperty("--draggable-input-content-shift");
    row?.style.removeProperty("--draggable-input-marker-shift");
    row?.style.removeProperty("--draggable-input-marker-opacity");
    row?.style.removeProperty("--draggable-input-marker-opacity-dark");
    row?.style.removeProperty("--draggable-input-editor-shield-opacity");
    const bounds = event.currentTarget.getBoundingClientRect();
    activePointerIdRef.current = event.pointerId;
    const rangeBounds = bounds.width > 0
      ? { left: bounds.left, right: bounds.right }
      : null;
    rangeBoundsRef.current = rangeBounds;
    markerCollisionZonesRef.current = [
      textZoneFor(labelTextRef.current),
      textZoneFor(formattedValueRef.current),
    ].filter((zone): zone is SliderTextZone => zone !== null);
    lastOverdragRef.current = 0;
    lastMarkerOpacityRef.current = null;
    lastEditorShieldOpacityRef.current = null;
    lastPointerValueRef.current = null;
    startInteraction();
    if (rangeBounds) reportPointerValue(event.clientX, rangeBounds);
    if (row && rangeBounds) {
      writeMarkerOpacity(
        row,
        Math.min(
          rangeBounds.right - SLIDER_MARKER_INSET,
          Math.max(rangeBounds.left + SLIDER_MARKER_INSET, event.clientX),
        ),
        markerCollisionZonesRef.current,
      );
    }
    event.currentTarget.setPointerCapture?.(event.pointerId);
  }

  function handleRangePointerMove(event: PointerEvent<HTMLInputElement>) {
    if (
      !interactionActiveRef.current ||
      activePointerIdRef.current !== event.pointerId
    ) {
      return;
    }
    const row = rowRef.current;
    const bounds = rangeBoundsRef.current;
    if (!row || !bounds) return;
    reportPointerValue(event.clientX, bounds);
    const overdrag = overdragFor(event.clientX, bounds.left, bounds.right);
    const rubberBand = rubberBandFor(overdrag, bounds.right - bounds.left);
    const markerX = Math.min(
      bounds.right - 10,
      Math.max(bounds.left + 10, event.clientX),
    ) + rubberBand.markerShift;
    const overdragChanged = lastOverdragRef.current !== overdrag;
    const markerOpacityChanged = lastMarkerOpacityRef.current !==
      markerOpacityFor(markerX, markerCollisionZonesRef.current);
    const editorShieldOpacityChanged =
      lastEditorShieldOpacityRef.current !== editorShieldOpacityFor(
        markerX,
        markerCollisionZonesRef.current[
          markerCollisionZonesRef.current.length - 1
        ],
      );
    if (
      !overdragChanged &&
      !markerOpacityChanged &&
      !editorShieldOpacityChanged
    ) return;

    if (overdragChanged) {
      lastOverdragRef.current = overdrag;
      row.removeAttribute("data-overdrag-settling");
      row.toggleAttribute("data-overdragging", overdrag !== 0);
      if (overdrag === 0) {
        row.removeAttribute("data-overdrag-direction");
      } else {
        row.setAttribute(
          "data-overdrag-direction",
          overdrag < 0 ? "left" : "right",
        );
      }
      row.style.setProperty("--draggable-input-overdrag", `${overdrag}px`);
      row.style.setProperty(
        "--draggable-input-capsule-scale-x",
        String(rubberBand.capsuleScaleX),
      );
      row.style.setProperty(
        "--draggable-input-capsule-scale-y",
        String(rubberBand.capsuleScaleY),
      );
      row.style.setProperty(
        "--draggable-input-content-shift",
        `${rubberBand.contentShift}px`,
      );
      row.style.setProperty(
        "--draggable-input-marker-shift",
        `${rubberBand.markerShift}px`,
      );
    }
    if (markerOpacityChanged || editorShieldOpacityChanged) {
      writeMarkerOpacity(row, markerX, markerCollisionZonesRef.current);
    }
  }

  function clearActivePointer(pointerId?: number) {
    if (
      activePointerIdRef.current === null ||
      (pointerId !== undefined && activePointerIdRef.current !== pointerId)
    ) {
      return false;
    }
    activePointerIdRef.current = null;
    rangeBoundsRef.current = null;
    markerCollisionZonesRef.current = [];
    lastOverdragRef.current = null;
    lastMarkerOpacityRef.current = null;
    lastEditorShieldOpacityRef.current = null;
    lastPointerValueRef.current = null;
    return true;
  }

  function settleRangeOverdrag() {
    const row = rowRef.current;
    if (!row) return;
    const overdrag = Number.parseFloat(
      row.style.getPropertyValue("--draggable-input-overdrag"),
    );
    row.removeAttribute("data-overdragging");
    if (!Number.isFinite(overdrag) || overdrag === 0) {
      clearRangeOverdragStyles(row);
      return;
    }
    if (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) {
      clearRangeOverdragStyles(row);
      return;
    }
    row.setAttribute("data-overdrag-settling", "");
    row.style.setProperty("--draggable-input-overdrag", "0px");
    row.style.setProperty("--draggable-input-capsule-scale-x", "1");
    row.style.setProperty("--draggable-input-capsule-scale-y", "1");
    row.style.setProperty("--draggable-input-content-shift", "0px");
    row.style.setProperty("--draggable-input-marker-shift", "0px");
  }

  function clearRangeOverdragStyles(row: HTMLElement) {
    clearOverdragStyles(row);
    updateRestingMarkerOpacity();
  }

  function handleRangePointerEnd(event: PointerEvent<HTMLInputElement>) {
    if (!clearActivePointer(event.pointerId)) return;
    if (event.currentTarget.hasPointerCapture?.(event.pointerId)) {
      event.currentTarget.releasePointerCapture(event.pointerId);
    }
    settleRangeOverdrag();
    endInteraction();
  }

  function handleRangePointerCancel(event: PointerEvent<HTMLInputElement>) {
    if (!clearActivePointer(event.pointerId)) return;
    if (event.currentTarget.hasPointerCapture?.(event.pointerId)) {
      event.currentTarget.releasePointerCapture(event.pointerId);
    }
    settleRangeOverdrag();
    cancelInteraction();
  }

  function handleRangeBlur() {
    clearActivePointer();
    settleRangeOverdrag();
    endInteraction();
  }

  function handleRangeLostPointerCapture(
    event: PointerEvent<HTMLInputElement>,
  ) {
    if (!clearActivePointer(event.pointerId)) return;
    settleRangeOverdrag();
    endInteraction();
  }

  function handleRowTransitionEnd(event: TransitionEvent<HTMLDivElement>) {
    if (
      !(event.target instanceof HTMLElement) ||
      event.propertyName !== "transform" ||
      !event.target.hasAttribute("data-slider-surface") ||
      !event.currentTarget.hasAttribute("data-overdrag-settling")
    ) {
      return;
    }
    clearRangeOverdragStyles(event.currentTarget);
  }

  function handleRangeKeyDown(event: KeyboardEvent<HTMLInputElement>) {
    if (activePointerIdRef.current !== null) return;
    if (
      event.key === "ArrowLeft" ||
      event.key === "ArrowRight" ||
      event.key === "ArrowUp" ||
      event.key === "ArrowDown" ||
      event.key === "Home" ||
      event.key === "End"
    ) {
      startInteraction();
    }
  }

  function handleRangeKeyUp(event: KeyboardEvent<HTMLInputElement>) {
    if (activePointerIdRef.current !== null) return;
    if (
      event.key === "ArrowLeft" ||
      event.key === "ArrowRight" ||
      event.key === "ArrowUp" ||
      event.key === "ArrowDown" ||
      event.key === "Home" ||
      event.key === "End"
    ) {
      endInteraction();
    }
  }

  return (
    <div
      className="draggable-input"
      data-disabled={disabled ? "" : undefined}
      data-draggable-input=""
      onTransitionEnd={handleRowTransitionEnd}
      ref={rowRef}
      style={style}
    >
      <style>{styles}</style>
      <span
        aria-hidden="true"
        className="draggable-input__surface"
        data-slider-surface=""
      />
      <span
        aria-hidden="true"
        className="draggable-input__marker"
        ref={markerRef}
      />
      <label className="draggable-input__label" htmlFor={inputId}>
        <span className="draggable-input__label-text" ref={labelTextRef}>
          {label}
        </span>
      </label>
      <input
        aria-label={label}
        className="draggable-input__range"
        disabled={disabled}
        id={inputId}
        max={max}
        min={min}
        onBlur={handleRangeBlur}
        onChange={handleRangeChange}
        onKeyDown={handleRangeKeyDown}
        onKeyUp={handleRangeKeyUp}
        onLostPointerCapture={handleRangeLostPointerCapture}
        onPointerCancel={handleRangePointerCancel}
        onPointerDown={handleRangePointerDown}
        onPointerEnter={updateRestingMarkerOpacity}
        onPointerMove={handleRangePointerMove}
        onPointerUp={handleRangePointerEnd}
        onFocus={updateRestingMarkerOpacity}
        step={step}
        type="range"
        value={value}
      />
      <span
        className="draggable-input__value-zone"
        data-slider-value-zone=""
      >
        <input
          aria-label={`${label} value`}
          aria-valuemax={max * inputScale}
          aria-valuemin={min * inputScale}
          aria-valuenow={accessibleValue}
          className="draggable-input__value"
          data-pointer-focus={valueFocusedByPointer ? "" : undefined}
          disabled={disabled}
          inputMode="decimal"
          max={max * inputScale}
          min={min * inputScale}
          onBlur={() => {
            valuePointerDownRef.current = false;
            setValueFocusedByPointer(false);
            handleValueBlur();
          }}
          onChange={handleValueChange}
          onFocus={() => {
            editStartValueRef.current = value;
            setIsEditing(true);
            setValueFocusedByPointer(valuePointerDownRef.current);
            valuePointerDownRef.current = false;
          }}
          onKeyDown={handleValueKeyDown}
          onPointerDown={() => {
            valuePointerDownRef.current = true;
            setValueFocusedByPointer(true);
          }}
          ref={valueInputRef}
          role="spinbutton"
          spellCheck={false}
          step={step * inputScale}
          type={isEditing ? "text" : "number"}
          value={displayedDraft}
        />
        <span
          aria-hidden="true"
          className="draggable-input__formatted"
          ref={formattedValueRef}
        >
          {formattedValue}
        </span>
      </span>
    </div>
  );
}

const styles = `.draggable-input-example {
  width: min(228px, calc(100vw - 48px));
}

.draggable-input-example[data-preview]
  .draggable-input[data-disabled] {
  cursor: default;
  opacity: 1;
}

.draggable-input:has(.draggable-input__range:focus-visible)
  .draggable-input__surface {
  outline: 2px solid var(--draggable-input-focus);
  outline-offset: 2px;
}

.draggable-input {
  --draggable-input-radius: 16px;
  --draggable-input-fill-radius: 12px;
  --draggable-input-value-width: 56px;
  --draggable-input-ink: var(--foreground, light-dark(#111, #fefefe));
  --draggable-input-surface: light-dark(#f0f0f0, #1b1b1a);
  --draggable-input-fill: light-dark(#000, #fff);
  --draggable-input-adaptive: #fff;
  --draggable-input-focus: var(--foreground, light-dark(#0d0d0d, #fefefe));
  --draggable-input-marker-opacity-resolved:
    var(--draggable-input-marker-opacity, 1);
  position: relative;
  isolation: isolate;
  display: grid;
  width: 100%;
  min-height: 44px;
  grid-template-columns: minmax(0, 1fr) auto;
  align-items: center;
  border-radius: var(--draggable-input-radius);
  color: var(--draggable-input-ink);
}

[data-theme="dark"] .draggable-input {
  --draggable-input-marker-opacity-resolved:
    var(--draggable-input-marker-opacity-dark, 1);
}

@media (prefers-color-scheme: dark) {
  :root:not([data-theme]) .draggable-input {
    --draggable-input-marker-opacity-resolved:
      var(--draggable-input-marker-opacity-dark, 1);
  }
}

.draggable-input__surface,
.draggable-input__surface::before,
.draggable-input__marker {
  position: absolute;
  pointer-events: none;
}

.draggable-input__marker,
.draggable-input__label,
.draggable-input__formatted {
  mix-blend-mode: difference;
}

.draggable-input__surface,
.draggable-input__surface::before {
  content: "";
}

.draggable-input__surface {
  z-index: 0;
  inset: 0;
  overflow: clip;
  border-radius: var(--draggable-input-radius);
  background: var(--draggable-input-surface);
  transform: scaleX(var(--draggable-input-capsule-scale-x, 1))
    scaleY(var(--draggable-input-capsule-scale-y, 1));
}

.draggable-input[data-overdrag-direction="left"]
  .draggable-input__surface {
  transform-origin: right center;
}

.draggable-input[data-overdrag-direction="right"]
  .draggable-input__surface {
  transform-origin: left center;
}

.draggable-input__surface::before {
  inset: 0 auto 0 0;
  width: var(--draggable-input-progress);
  border-radius:
    var(--draggable-input-radius)
    var(--draggable-input-fill-radius)
    var(--draggable-input-fill-radius)
    var(--draggable-input-radius);
  background: var(--draggable-input-fill);
}

.draggable-input__marker {
  z-index: 1;
  top: 50%;
  opacity: 0;
  transition: opacity 80ms cubic-bezier(0.215, 0.61, 0.355, 1);
  left: clamp(
    10px,
    calc(var(--draggable-input-progress) - 10px),
    calc(100% - 10px)
  );
  width: 4px;
  height: 16px;
  border-radius: 999px;
  background: var(--draggable-input-adaptive);
  transform: translate(
    calc(-50% + var(--draggable-input-marker-shift, 0px)),
    -50%
  );
}

.draggable-input__label,
.draggable-input__formatted {
  transform: translateX(var(--draggable-input-content-shift, 0px));
}

.draggable-input[data-overdragging]
  :is(
    .draggable-input__surface,
    .draggable-input__label,
    .draggable-input__formatted,
    .draggable-input__marker
  ),
.draggable-input[data-overdrag-settling]
  :is(
    .draggable-input__surface,
    .draggable-input__label,
    .draggable-input__formatted,
    .draggable-input__marker
  ) {
  will-change: transform;
}

.draggable-input[data-overdrag-settling]
  :is(
    .draggable-input__surface,
    .draggable-input__label,
    .draggable-input__formatted,
    .draggable-input__marker
  ) {
  transition: transform 240ms cubic-bezier(0.22, 1.35, 0.36, 1);
}

.draggable-input[data-overdrag-settling] .draggable-input__marker {
  transition:
    opacity 80ms cubic-bezier(0.215, 0.61, 0.355, 1),
    transform 240ms cubic-bezier(0.22, 1.35, 0.36, 1);
}

.draggable-input[data-preview-active] .draggable-input__marker,
.draggable-input:has(.draggable-input__range:focus-visible)
  .draggable-input__marker,
.draggable-input:has(.draggable-input__range:hover)
  .draggable-input__marker,
.draggable-input:has(.draggable-input__range:active)
  .draggable-input__marker {
  opacity: var(--draggable-input-marker-opacity-resolved, 1);
}

.draggable-input__label {
  position: relative;
  z-index: 2;
  min-width: 0;
  padding: 12px;
  pointer-events: none;
  color: var(--draggable-input-adaptive);
  font: 500 14px/1.15 "Geist Sans", system-ui, sans-serif;
}

.draggable-input__range {
  position: absolute;
  z-index: 1;
  inset: 0;
  width: 100%;
  height: 100%;
  margin: 0;
  appearance: none;
  border: 0;
  background: transparent;
  cursor: grab;
  opacity: 0;
  touch-action: none;
}

.draggable-input__range:active {
  cursor: grabbing;
}

.draggable-input__value-zone {
  position: relative;
  display: grid;
  min-width: 24px;
  height: 32px;
  place-items: center end;
  margin-right: 14px;
}

.draggable-input__value-zone:has(.draggable-input__value:focus)::before,
.draggable-input__value-zone:has(.draggable-input__value:focus)::after {
  content: "";
  position: absolute;
  z-index: 0;
  top: 0;
  right: -8px;
  width: var(--draggable-input-value-width);
  height: 32px;
  border-radius: 10px;
  pointer-events: none;
}

.draggable-input__value-zone:has(.draggable-input__value:focus)::before {
  background: rgb(255 255 255 / 8%);
  mix-blend-mode: difference;
}

.draggable-input__value-zone:has(.draggable-input__value:focus)::after {
  background: light-dark(#242424, #e1e1e1);
  opacity: var(--draggable-input-editor-shield-opacity, 0);
}

.draggable-input__value {
  position: absolute;
  z-index: 1;
  top: 50%;
  right: -4px;
  box-sizing: border-box;
  width: calc(100% + 8px);
  height: 32px;
  padding: 0 4px;
  appearance: textfield;
  border: 0;
  border-radius: 7px;
  background: transparent;
  color: transparent;
  caret-color: var(--draggable-input-ink);
  font: 500 14px/1.15 "Geist Sans", system-ui, sans-serif;
  font-variant-numeric: tabular-nums;
  text-align: right;
  transform: translateY(-50%);
}

.draggable-input__value:focus {
  right: -8px;
  width: var(--draggable-input-value-width);
  padding: 0 8px;
  outline: none;
  background: transparent;
  color: var(--draggable-input-adaptive);
  caret-color: var(--draggable-input-adaptive);
  mix-blend-mode: difference;
}

.draggable-input__value:focus-visible:not([data-pointer-focus]) {
  box-shadow: inset 0 0 0 1px rgb(255 255 255 / 50%);
}

.draggable-input__value::selection {
  background: rgb(127 127 127 / 24%);
  color: inherit;
}

.draggable-input__value::-webkit-inner-spin-button,
.draggable-input__value::-webkit-outer-spin-button {
  margin: 0;
  appearance: none;
}

.draggable-input__formatted {
  position: relative;
  z-index: 0;
  pointer-events: none;
  color: var(--draggable-input-adaptive);
  font: 500 14px/1.15 "Geist Sans", system-ui, sans-serif;
  font-variant-numeric: tabular-nums;
  opacity: 0.6;
}

.draggable-input__value:focus + .draggable-input__formatted {
  visibility: hidden;
}

.draggable-input[data-disabled] {
  cursor: not-allowed;
  opacity: 0.45;
}

.draggable-input[data-disabled] .draggable-input__range {
  cursor: not-allowed;
}

/* iOS browsers zoom focused inputs below 16px. Keep the editable value and its
   resting display at the same mobile size so focus neither zooms nor reflows. */
@media (pointer: coarse) {
  .draggable-input__value,
  .draggable-input__formatted {
    font-size: 16px;
  }
}

@media (prefers-reduced-motion: reduce) {
  .draggable-input__marker,
  .draggable-input[data-overdrag-settling]
    :is(
      .draggable-input__surface,
      .draggable-input__label,
      .draggable-input__formatted,
      .draggable-input__marker
    ) {
    transition: none;
  }
}`;

type DraggableInputExampleProps = {
  preview?: boolean;
  previewActive?: boolean;
};

export default function DraggableInputExample({
  preview = false,
  previewActive = false,
}: DraggableInputExampleProps = {}) {
  const initialValue = preview ? PREVIEW_REST_VALUE : 32;
  const [blur, setBlur] = useState(initialValue);
  const blurRef = useRef(initialValue);
  const exampleRef = useRef<HTMLDivElement>(null);

  function setBlurValue(nextValue: number) {
    blurRef.current = nextValue;
    setBlur(nextValue);
  }

  useEffect(() => {
    if (!preview) return;
    const row = exampleRef.current?.querySelector<HTMLElement>(
      ".draggable-input",
    );
    if (!row) return;
    const previewMotionAllowed = window.matchMedia(
      "(hover: hover) and (pointer: fine)",
    ).matches && !window.matchMedia(
      "(prefers-reduced-motion: reduce)",
    ).matches;
    if (!previewMotionAllowed) {
      clearOverdragStyles(row);
      setBlurValue(PREVIEW_REST_VALUE);
      row.removeAttribute("data-preview-active");
      return;
    }
    const controller = new AbortController();
    const { signal } = controller;

    const animateValueTo = (
      nextValue: number,
      duration: number,
      easing: (progress: number) => number,
    ) => {
      const fromValue = blurRef.current;
      return animatePreview(
        duration,
        signal,
        (progress) => {
          const value = Math.round(
            fromValue + (nextValue - fromValue) * progress,
          );
          if (value !== blurRef.current) setBlurValue(value);
        },
        easing,
      );
    };

    const playPreview = async () => {
      row.setAttribute("data-preview-active", "");
      if (row.hasAttribute("data-overdrag-settling")) {
        const settled = await waitForPreview(PREVIEW_RELEASE_DURATION, signal);
        if (!settled) return;
      }
      if (blurRef.current === PREVIEW_REST_VALUE) {
        const intended = await waitForPreview(PREVIEW_INTENT_DELAY, signal);
        if (!intended) return;
      }
      const remainingTravel = Math.abs(100 - blurRef.current) / 50;
      const scrubbed = await animateValueTo(
        100,
        PREVIEW_SCRUB_DURATION * remainingTravel,
        easeInOutCubic,
      );
      if (!scrubbed) return;
      clearOverdragStyles(row);
      const width = row.offsetWidth;
      await animatePreview(
        PREVIEW_TUG_DURATION,
        signal,
        (progress) => {
          const pointerTravel = PREVIEW_POINTER_TRAVEL * progress;
          writePreviewOverdrag(row, width, pointerTravel);
        },
      );
    };

    const resetPreview = async () => {
      const releaseDuration = releasePreviewOverdrag(row);
      if (releaseDuration > 0) {
        const released = await waitForPreview(releaseDuration, signal);
        if (!released) return;
      }
      const remainingTravel = Math.abs(
        blurRef.current - PREVIEW_REST_VALUE,
      ) / 50;
      const reset = await animateValueTo(
        PREVIEW_REST_VALUE,
        PREVIEW_RESET_DURATION * remainingTravel,
        easeOutCubic,
      );
      if (reset) row.removeAttribute("data-preview-active");
    };

    void (previewActive ? playPreview() : resetPreview());
    return () => controller.abort();
  }, [preview, previewActive]);

  return (
    <div
      className="draggable-input-example"
      data-preview={preview ? "" : undefined}
      ref={exampleRef}
    >
      <DraggableInput
        disabled={preview}
        label="Blur"
        value={blur}
        min={0}
        max={100}
        step={1}
        onChange={setBlurValue}
        formatValue={(nextValue) => `${nextValue} px`}
      />
    </div>
  );
}
Draggable input