shadimage
tool-ui
blockparameter-slidersource: ok · hit

Parameter Slider

Numeric parameter adjustment controls.

npx shadcn add "https://www.tool-ui.com/r/parameter-slider.json"
free

Interaction

carousel

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light
ui-primitives@radix-ui/react-slider
formszod

Registry dependencies

buttonseparatorslider

Files16 · source cached 10/8/2026, 9:03:31 PM

components/tool-ui/parameter-slider/_adapter.tsx
/**
 * Adapter: UI and utility re-exports for copy-standalone portability.
 *
 * When copying this component to another project, update these imports
 * to match your project's paths:
 *
 *   cn        → Your Tailwind merge utility (e.g., "@/lib/utils", "~/lib/cn")
 *   Button    → shadcn/ui Button
 *   Separator → shadcn/ui Separator
 *   Slider    → shadcn/ui Slider
 */

export { cn } from "@/lib/utils";
export { Button } from "@/components/ui/button";
export { Separator } from "@/components/ui/separator";
export { Slider } from "@/components/ui/slider";
components/tool-ui/parameter-slider/index.tsx
export { ParameterSlider } from "./parameter-slider";
export type {
  ParameterSliderProps,
  SliderConfig,
  SliderValue,
  SerializableParameterSlider,
} from "./schema";
components/tool-ui/parameter-slider/math.ts
import type { SliderConfig, SliderValue } from "./schema";

type SliderPercentInput = {
  value: number;
  min: number;
  max: number;
};

function clampPercent(value: number): number {
  if (!Number.isFinite(value)) return 0;
  return Math.max(0, Math.min(100, value));
}

export function sliderRangeToPercent({
  value,
  min,
  max,
}: SliderPercentInput): number {
  const range = max - min;
  if (!Number.isFinite(range) || range <= 0) return 0;
  return clampPercent(((value - min) / range) * 100);
}

export function createSliderValueSnapshot(
  sliders: SliderConfig[],
): SliderValue[] {
  return sliders.map((slider) => ({ id: slider.id, value: slider.value }));
}

export function createSliderSignature(sliders: SliderConfig[]): string {
  return JSON.stringify(
    sliders.map(({ id, min, max, step, value, unit, precision }) => ({
      id,
      min,
      max,
      step: step ?? 1,
      value,
      unit: unit ?? "",
      precision: precision ?? null,
    })),
  );
}
components/tool-ui/parameter-slider/parameter-slider.tsx
"use client";

import {
  useCallback,
  useEffect,
  useLayoutEffect,
  useMemo,
  useRef,
  useState,
} from "react";
import * as SliderPrimitive from "@radix-ui/react-slider";
import type { ParameterSliderProps, SliderConfig, SliderValue } from "./schema";
import { ActionButtons } from "../shared/action-buttons";
import { normalizeActionsConfig } from "../shared/actions-config";
import { useControllableState } from "../shared/use-controllable-state";
import { useSignatureReset } from "../shared/use-signature-reset";

import { cn } from "./_adapter";
import {
  createSliderSignature,
  createSliderValueSnapshot,
  sliderRangeToPercent,
} from "./math";

function formatSignedValue(
  value: number,
  min: number,
  max: number,
  precision?: number,
  unit?: string,
): string {
  const crossesZero = min < 0 && max > 0;
  const fixed =
    precision !== undefined ? value.toFixed(precision) : String(value);
  const numericPart = crossesZero && value >= 0 ? `+${fixed}` : fixed;
  return unit ? `${numericPart} ${unit}` : numericPart;
}

function getAriaValueText(
  value: number,
  min: number,
  max: number,
  unit?: string,
): string {
  const crossesZero = min < 0 && max > 0;
  if (crossesZero) {
    if (value > 0) {
      return unit ? `plus ${value} ${unit}` : `plus ${value}`;
    } else if (value < 0) {
      return unit
        ? `minus ${Math.abs(value)} ${unit}`
        : `minus ${Math.abs(value)}`;
    }
  }
  return unit ? `${value} ${unit}` : String(value);
}

const TICK_COUNT = 16;
const TEXT_PADDING_X = 4;
const TEXT_PADDING_X_OUTER = 0; // Less inset on outer-facing side (near edges)
const TEXT_PADDING_Y = 2;
const DETECTION_MARGIN_X = 12;
const DETECTION_MARGIN_X_OUTER = 4; // Small margin at edges for steep falloff - segments fully close at terminal positions
const DETECTION_MARGIN_Y = 12;
const TRACK_HEIGHT = 48;
const TEXT_RELEASE_INSET = 8;
const TRACK_EDGE_INSET = 4; // px from track edge - keeps elements visible at extremes
const THUMB_WIDTH = 12; // w-3
// Text vertical offset: raised slightly from center
// Positive = raised, negative = lowered
const TEXT_VERTICAL_OFFSET = 0.5;

function clampPercent(value: number): number {
  if (!Number.isFinite(value)) return 0;
  return Math.max(0, Math.min(100, value));
}

// Convert a percentage (0-100) to an inset position string
// At 0%: 4px from left edge; at 100%: 4px from right edge
function toInsetPosition(percent: number): string {
  const safePercent = clampPercent(percent);
  return `calc(${TRACK_EDGE_INSET}px + (100% - ${TRACK_EDGE_INSET * 2}px) * ${safePercent / 100})`;
}

// Radix keeps the thumb in bounds by applying a percent-dependent px offset.
// Matching this for fill clipping prevents handle/fill drift near extremes.
function getRadixThumbInBoundsOffsetPx(percent: number): number {
  const safePercent = clampPercent(percent);
  const halfWidth = THUMB_WIDTH / 2;
  return halfWidth - (safePercent * halfWidth) / 50;
}

function toRadixThumbPosition(percent: number): string {
  const safePercent = clampPercent(percent);
  const offsetPx = getRadixThumbInBoundsOffsetPx(safePercent);
  return `calc(${safePercent}% + ${offsetPx}px)`;
}

function signedDistanceToRoundedRect(
  px: number,
  py: number,
  left: number,
  right: number,
  top: number,
  bottom: number,
  radiusLeft: number,
  radiusRight: number,
): number {
  const innerLeft = left + radiusLeft;
  const innerRight = right - radiusRight;
  const innerTop = top + Math.max(radiusLeft, radiusRight);
  const innerBottom = bottom - Math.max(radiusLeft, radiusRight);

  const inLeftCorner = px < innerLeft;
  const inRightCorner = px > innerRight;
  const inCornerY = py < innerTop || py > innerBottom;

  if ((inLeftCorner || inRightCorner) && inCornerY) {
    const radius = inLeftCorner ? radiusLeft : radiusRight;
    const cornerX = inLeftCorner ? innerLeft : innerRight;
    const cornerY = py < innerTop ? top + radius : bottom - radius;
    const distToCornerCenter = Math.hypot(px - cornerX, py - cornerY);
    return distToCornerCenter - radius;
  }

  const dx = Math.max(left - px, px - right, 0);
  const dy = Math.max(top - py, py - bottom, 0);

  if (dx === 0 && dy === 0) {
    return -Math.min(px - left, right - px, py - top, bottom - py);
  }

  return Math.max(dx, dy);
}

const OUTER_EDGE_RADIUS_FACTOR = 0.3; // Reduced radius on outer-facing sides for steeper falloff

function calculateGap(
  thumbCenterX: number,
  textRect: { left: number; right: number; height: number; centerY: number },
  isLeftAligned: boolean,
): number {
  const { left, right, height, centerY } = textRect;
  // Asymmetric padding/margin: outer-facing side has less padding, more margin
  const paddingLeft = isLeftAligned ? TEXT_PADDING_X_OUTER : TEXT_PADDING_X;
  const paddingRight = isLeftAligned ? TEXT_PADDING_X : TEXT_PADDING_X_OUTER;
  const marginLeft = isLeftAligned
    ? DETECTION_MARGIN_X_OUTER
    : DETECTION_MARGIN_X;
  const marginRight = isLeftAligned
    ? DETECTION_MARGIN_X
    : DETECTION_MARGIN_X_OUTER;
  const paddingY = TEXT_PADDING_Y;
  const marginY = DETECTION_MARGIN_Y;
  const thumbCenterY = centerY;

  // Inner boundary (where max gap occurs)
  const innerLeft = left - paddingLeft;
  const innerRight = right + paddingRight;
  const innerTop = centerY - height / 2 - paddingY;
  const innerBottom = centerY + height / 2 + paddingY;
  const innerHeight = height + paddingY * 2;
  const innerRadius = innerHeight / 2;
  // Smaller radius on outer-facing side (left for label, right for value)
  const innerRadiusLeft = isLeftAligned
    ? innerRadius * OUTER_EDGE_RADIUS_FACTOR
    : innerRadius;
  const innerRadiusRight = isLeftAligned
    ? innerRadius
    : innerRadius * OUTER_EDGE_RADIUS_FACTOR;

  // Outer boundary (where effect starts) - proportionally larger
  const outerLeft = left - paddingLeft - marginLeft;
  const outerRight = right + paddingRight + marginRight;
  const outerTop = centerY - height / 2 - paddingY - marginY;
  const outerBottom = centerY + height / 2 + paddingY + marginY;
  const outerHeight = height + paddingY * 2 + marginY * 2;
  const outerRadius = outerHeight / 2;
  const outerRadiusLeft = isLeftAligned
    ? outerRadius * OUTER_EDGE_RADIUS_FACTOR
    : outerRadius;
  const outerRadiusRight = isLeftAligned
    ? outerRadius
    : outerRadius * OUTER_EDGE_RADIUS_FACTOR;

  const outerDist = signedDistanceToRoundedRect(
    thumbCenterX,
    thumbCenterY,
    outerLeft,
    outerRight,
    outerTop,
    outerBottom,
    outerRadiusLeft,
    outerRadiusRight,
  );

  // Outside outer boundary - no gap
  if (outerDist > 0) return 0;

  const innerDist = signedDistanceToRoundedRect(
    thumbCenterX,
    thumbCenterY,
    innerLeft,
    innerRight,
    innerTop,
    innerBottom,
    innerRadiusLeft,
    innerRadiusRight,
  );

  // Inside inner boundary - max gap
  const maxGap = height + paddingY * 2;
  if (innerDist <= 0) return maxGap;

  // Between boundaries - linear interpolation
  // outerDist is negative (inside outer), innerDist is positive (outside inner)
  const totalDist = Math.abs(outerDist) + innerDist;
  const t = Math.abs(outerDist) / totalDist;

  return maxGap * t;
}

interface SliderRowProps {
  config: SliderConfig;
  value: number;
  onChange: (value: number) => void;
  trackClassName?: string;
  fillClassName?: string;
  handleClassName?: string;
}

function SliderRow({
  config,
  value,
  onChange,
  trackClassName,
  fillClassName,
  handleClassName,
}: SliderRowProps) {
  const { id, label, min, max, step = 1, unit, precision, disabled } = config;
  // Per-slider theming overrides component-level theming
  const resolvedTrackClassName = config.trackClassName ?? trackClassName;
  const resolvedFillClassName = config.fillClassName ?? fillClassName;
  const resolvedHandleClassName = config.handleClassName ?? handleClassName;
  const crossesZero = min < 0 && max > 0;
  const [isDragging, setIsDragging] = useState(false);
  const [isHovered, setIsHovered] = useState(false);

  const trackRef = useRef<HTMLSpanElement>(null);
  const labelRef = useRef<HTMLSpanElement>(null);
  const valueRef = useRef<HTMLSpanElement>(null);

  const [dragGap, setDragGap] = useState(0);
  const [fullGap, setFullGap] = useState(0);
  const [intersectsText, setIntersectsText] = useState(false);
  const [layoutVersion, setLayoutVersion] = useState(0);

  useEffect(() => {
    if (!isDragging) return;
    const handlePointerUp = () => setIsDragging(false);
    document.addEventListener("pointerup", handlePointerUp);
    return () => document.removeEventListener("pointerup", handlePointerUp);
  }, [isDragging]);

  useEffect(() => {
    const track = trackRef.current;
    const labelEl = labelRef.current;
    const valueEl = valueRef.current;
    if (!track || !labelEl || !valueEl) return;

    const bumpLayoutVersion = () => setLayoutVersion((v) => v + 1);

    if (typeof ResizeObserver !== "undefined") {
      const observer = new ResizeObserver(() => {
        bumpLayoutVersion();
      });
      observer.observe(track);
      observer.observe(labelEl);
      observer.observe(valueEl);
      return () => observer.disconnect();
    }

    window.addEventListener("resize", bumpLayoutVersion);
    return () => window.removeEventListener("resize", bumpLayoutVersion);
  }, []);

  useLayoutEffect(() => {
    const track = trackRef.current;
    const labelEl = labelRef.current;
    const valueEl = valueRef.current;

    if (!track || !labelEl || !valueEl) return;

    const trackRect = track.getBoundingClientRect();
    const labelRect = labelEl.getBoundingClientRect();
    const valueRect = valueEl.getBoundingClientRect();

    const trackWidth = trackRect.width;
    const valuePercent = sliderRangeToPercent({ value, min, max });
    // Use same inset coordinate system as visual elements
    const thumbCenterPx =
      (trackWidth * clampPercent(valuePercent)) / 100 +
      getRadixThumbInBoundsOffsetPx(valuePercent);
    const thumbHalfWidth = THUMB_WIDTH / 2;

    // Text is raised by TEXT_VERTICAL_OFFSET from center
    const trackCenterY = TRACK_HEIGHT / 2 - TEXT_VERTICAL_OFFSET;

    const labelGap = calculateGap(
      thumbCenterPx,
      {
        left: labelRect.left - trackRect.left,
        right: labelRect.right - trackRect.left,
        height: labelRect.height,
        centerY: trackCenterY,
      },
      true,
    ); // label is left-aligned

    const valueGap = calculateGap(
      thumbCenterPx,
      {
        left: valueRect.left - trackRect.left,
        right: valueRect.right - trackRect.left,
        height: valueRect.height,
        centerY: trackCenterY,
      },
      false,
    ); // value is right-aligned

    setDragGap(Math.max(labelGap, valueGap));

    // Tight intersection check for release state
    // Inset by px-2 (8px) padding to check against actual text, not padded container
    const labelLeft = labelRect.left - trackRect.left + TEXT_RELEASE_INSET;
    const labelRight = labelRect.right - trackRect.left - TEXT_RELEASE_INSET;
    const valueLeft = valueRect.left - trackRect.left + TEXT_RELEASE_INSET;
    const valueRight = valueRect.right - trackRect.left - TEXT_RELEASE_INSET;

    const thumbLeft = thumbCenterPx - thumbHalfWidth;
    const thumbRight = thumbCenterPx + thumbHalfWidth;

    const hitsLabel = thumbRight > labelLeft && thumbLeft < labelRight;
    const hitsValue = thumbRight > valueLeft && thumbLeft < valueRight;

    setIntersectsText(hitsLabel || hitsValue);

    // Calculate full separation gap for release state
    // Use the max gap of whichever text element(s) the handle intersects
    const labelFullGap = labelRect.height + TEXT_PADDING_Y * 2;
    const valueFullGap = valueRect.height + TEXT_PADDING_Y * 2;
    const releaseGap =
      hitsLabel && hitsValue
        ? Math.max(labelFullGap, valueFullGap)
        : hitsLabel
          ? labelFullGap
          : hitsValue
            ? valueFullGap
            : 0;
    setFullGap(releaseGap);
  }, [value, min, max, layoutVersion]);

  // While dragging: use distance-based separation, but never collapse below
  // the release split when the thumb still intersects text.
  const gap = isDragging
    ? Math.max(dragGap, intersectsText ? fullGap : 0)
    : intersectsText
      ? fullGap
      : 0;

  const ticks = useMemo(() => {
    // Generate equidistant ticks regardless of step value
    const majorTickCount = TICK_COUNT;
    const result: { percent: number; isCenter: boolean; isSubtick: boolean }[] =
      [];

    for (let i = 0; i <= majorTickCount; i++) {
      const percent = (i / majorTickCount) * 100;
      const isCenter = !crossesZero && percent === 50;

      // Skip the center tick (50%) for crossesZero sliders
      if (crossesZero && percent === 50) continue;

      // Add subtick at midpoint before this tick (except for first)
      if (i > 0) {
        const prevPercent = ((i - 1) / majorTickCount) * 100;
        // Don't add subtick if it would be at 50% for crossesZero
        const midPercent = (prevPercent + percent) / 2;
        if (!(crossesZero && midPercent === 50)) {
          result.push({
            percent: midPercent,
            isCenter: false,
            isSubtick: true,
          });
        }
      }

      result.push({ percent, isCenter, isSubtick: false });
    }

    return result;
  }, [crossesZero]);

  const zeroPercent = crossesZero
    ? sliderRangeToPercent({ value: 0, min, max })
    : 0;
  const valuePercent = sliderRangeToPercent({ value, min, max });

  // Fill clip-path uses the same inset coordinate system as the handle.
  // This keeps the collapsed stroke aligned with the fill edge near extremes.
  const fillClipPath = useMemo(() => {
    const toClipFromRightInset = (percent: number) =>
      `calc(100% - ${toRadixThumbPosition(percent)})`;
    const toClipFromLeftInset = (percent: number) =>
      toRadixThumbPosition(percent);
    const TERMINAL_EPSILON = 1e-6;
    const snapLeftInset = (percent: number) => {
      if (percent <= TERMINAL_EPSILON) return "0";
      if (percent >= 100 - TERMINAL_EPSILON) return "100%";
      return toClipFromLeftInset(percent);
    };
    const snapRightInset = (percent: number) => {
      if (percent <= TERMINAL_EPSILON) return "100%";
      if (percent >= 100 - TERMINAL_EPSILON) return "0";
      return toClipFromRightInset(percent);
    };

    if (crossesZero) {
      // Keep center anchor stable by always clipping the low/high pair,
      // independent of sign branch, then snapping at terminal edges.
      const lowPercent = Math.min(valuePercent, zeroPercent);
      const highPercent = Math.max(valuePercent, zeroPercent);
      return `inset(0 ${snapRightInset(highPercent)} 0 ${snapLeftInset(lowPercent)})`;
    }
    // Non-crossing: fill starts at left edge; snap right inset at terminals.
    return `inset(0 ${snapRightInset(valuePercent)} 0 0)`;
  }, [crossesZero, zeroPercent, valuePercent]);

  const fillMaskImage = crossesZero
    ? "linear-gradient(to right, rgba(0,0,0,0.2) 0%, rgba(0,0,0,0.35) 50%, rgba(0,0,0,0.7) 100%)"
    : "linear-gradient(to right, rgba(0,0,0,0.3) 0%, rgba(0,0,0,0.7) 100%)";

  // Metallic reflection gradient that follows the handle position
  // Visible while dragging OR when resting at edges (0%/100%)
  const reflectionStyle = useMemo(() => {
    const edgeThreshold = 3;
    const nearEdge =
      valuePercent <= edgeThreshold || valuePercent >= 100 - edgeThreshold;

    // Narrower spread when stationary at edges (~35% narrower)
    const spreadPercent = nearEdge && !isDragging ? 6.5 : 10;
    const handlePos = toRadixThumbPosition(valuePercent);
    const start = `clamp(0%, calc(${handlePos} - ${spreadPercent}%), 100%)`;
    const end = `clamp(0%, calc(${handlePos} + ${spreadPercent}%), 100%)`;

    const gradient = `linear-gradient(to right,
      transparent ${start},
      white ${handlePos},
      transparent ${end})`;

    return {
      background: gradient,
      WebkitMask:
        "linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)",
      WebkitMaskComposite: "xor",
      maskComposite: "exclude",
      padding: "1px",
    };
  }, [valuePercent, isDragging]);

  // Opacity scales with handle size: rest → hover → drag
  const reflectionOpacity = useMemo(() => {
    const edgeThreshold = 3;
    const atEdge =
      valuePercent <= edgeThreshold || valuePercent >= 100 - edgeThreshold;

    if (isDragging || atEdge) {
      return 1;
    }
    if (isHovered) {
      return 0.6;
    }
    return 0;
  }, [valuePercent, isDragging, isHovered]);

  const handleValueChange = useCallback(
    (values: number[]) => {
      if (values[0] !== undefined) {
        onChange(values[0]);
      }
    },
    [onChange],
  );

  return (
    <div className="py-2">
      <SliderPrimitive.Root
        id={id}
        className={cn(
          "group/slider relative flex w-full touch-none items-center select-none",
          "isolate h-12",
          isDragging
            ? "[&>span]:transition-[left,transform] [&>span]:duration-45 [&>span]:ease-linear"
            : "[&>span]:transition-[left,transform] [&>span]:duration-90 [&>span]:ease-[cubic-bezier(0.22,1,0.36,1)]",
          "[&>span]:will-change-[left,transform]",
          "motion-reduce:[&>span]:transition-none",
          disabled && "pointer-events-none opacity-50",
        )}
        value={[value]}
        onValueChange={handleValueChange}
        onPointerDown={() => setIsDragging(true)}
        onPointerUp={() => setIsDragging(false)}
        onPointerEnter={() => setIsHovered(true)}
        onPointerLeave={() => setIsHovered(false)}
        min={min}
        max={max}
        step={step}
        disabled={disabled}
        aria-valuetext={getAriaValueText(value, min, max, unit)}
      >
        <SliderPrimitive.Track
          ref={trackRef}
          className={cn(
            "squircle relative h-12 w-full grow overflow-hidden rounded-sm",
            "ring-border ring-1 ring-inset",
            "dark:ring-white/10",
            resolvedTrackClassName ?? "bg-muted",
          )}
        >
          <div
            className={cn(
              "absolute inset-0 will-change-[clip-path]",
              isDragging
                ? "transition-[clip-path] duration-45 ease-linear"
                : "transition-[clip-path] duration-90 ease-[cubic-bezier(0.22,1,0.36,1)]",
              "motion-reduce:transition-none",
              resolvedFillClassName ?? "bg-primary/30 dark:bg-primary/40",
            )}
            style={{
              maskImage: fillMaskImage,
              WebkitMaskImage: fillMaskImage,
              clipPath: fillClipPath,
            }}
          />

          {ticks.map((tick, i) => {
            const isEdge =
              !tick.isSubtick && (tick.percent === 0 || tick.percent === 100);
            return (
              <span
                key={i}
                className={cn(
                  "pointer-events-none absolute bottom-px w-px",
                  tick.isSubtick ? "h-1.5" : "h-2",
                  isEdge
                    ? "bg-transparent"
                    : tick.isSubtick
                      ? "bg-foreground/8 dark:bg-white/5"
                      : tick.isCenter
                        ? "bg-foreground/30 dark:bg-white/25"
                        : "bg-foreground/15 dark:bg-white/8",
                )}
                style={{
                  left: toInsetPosition(tick.percent),
                  transform: "translateX(-50%)",
                }}
              />
            );
          })}
        </SliderPrimitive.Track>

        {/* Metallic reflection overlay - follows handle, brightness scales with interaction */}
        <div
          className={cn(
            "squircle pointer-events-none absolute inset-0 rounded-sm",
            isDragging
              ? "transition-[opacity,background] duration-45 ease-linear"
              : "transition-[opacity,background] duration-90 ease-[cubic-bezier(0.22,1,0.36,1)]",
            "motion-reduce:transition-none",
          )}
          style={{
            ...reflectionStyle,
            opacity: reflectionOpacity,
            filter: "blur(1px)",
            mixBlendMode: "overlay",
          }}
        />

        <SliderPrimitive.Thumb
          className={cn(
            "group/thumb z-0 block w-3 shrink-0 cursor-grab rounded-sm",
            "relative bg-transparent outline-none",
            "transition-[height,opacity] duration-150 ease-[var(--cubic-ease-in-out)]",
            "focus-visible:outline-ring focus-visible:outline-2 focus-visible:outline-offset-1",
            "active:cursor-grabbing",
            "disabled:pointer-events-none disabled:opacity-50",
            // Height morphs: rest (track height) → hover → active
            isDragging ? "h-[56px]" : isHovered ? "h-[54px]" : "h-12",
          )}
        >
          {(() => {
            // Calculate morph state
            const isActive = isHovered || isDragging;

            // Indicator stays centered on the real thumb while CSS transitions
            // smooth thumb wrapper and fill movement together.
            const fillEdgeOffset = 0;

            // Hide rest-state indicator at edges (0% or 100%) - the reflection gradient handles this
            const edgeThreshold = 3;
            const atEdge =
              valuePercent <= edgeThreshold ||
              valuePercent >= 100 - edgeThreshold;
            const restOpacity = atEdge ? 0 : 0.25;

            // Asymmetric segment heights: gap is shifted up to match raised text position
            // Top segment is shorter, bottom segment is taller
            const topHeight =
              isActive && gap > 0
                ? `calc(50% - ${gap / 2 + TEXT_VERTICAL_OFFSET}px)`
                : "50%";
            const bottomHeight =
              isActive && gap > 0
                ? `calc(50% - ${gap / 2 - TEXT_VERTICAL_OFFSET}px)`
                : "50%";

            return (
              <>
                <span
                  className={cn(
                    "absolute top-0 left-1/2",
                    "transition-all duration-100 ease-[var(--cubic-ease-in-out)]",
                    isActive
                      ? gap > 0
                        ? "rounded-full"
                        : "rounded-t-full"
                      : "rounded-t-sm",
                    isDragging ? "w-2" : isActive ? "w-1.5" : "w-px",
                    resolvedHandleClassName ?? "bg-primary",
                  )}
                  style={{
                    transform: `translateX(calc(-50% + ${fillEdgeOffset}px))`,
                    height: topHeight,
                    opacity: isActive ? 1 : restOpacity,
                  }}
                />
                <span
                  className={cn(
                    "absolute bottom-0 left-1/2",
                    "transition-all duration-100 ease-[var(--cubic-ease-in-out)]",
                    isActive
                      ? gap > 0
                        ? "rounded-full"
                        : "rounded-b-full"
                      : "rounded-b-sm",
                    isDragging ? "w-2" : isActive ? "w-1.5" : "w-px",
                    resolvedHandleClassName ?? "bg-primary",
                  )}
                  style={{
                    transform: `translateX(calc(-50% + ${fillEdgeOffset}px))`,
                    height: bottomHeight,
                    opacity: isActive ? 1 : restOpacity,
                  }}
                />
              </>
            );
          })()}
        </SliderPrimitive.Thumb>

        <div
          className="pointer-events-none absolute inset-x-3 top-1/2 z-10 flex items-center justify-between"
          style={{
            transform: `translateY(calc(-50% - ${TEXT_VERTICAL_OFFSET}px))`,
          }}
        >
          <span
            ref={labelRef}
            className="text-primary -mt-px rounded-full px-2 py-px text-sm font-normal tracking-wide"
          >
            {label}
          </span>
          <span
            ref={valueRef}
            className="text-foreground -mt-px -mb-0.5 flex h-6 items-center rounded-full px-2 font-mono text-xs tabular-nums"
          >
            {formatSignedValue(value, min, max, precision, unit)}
          </span>
        </div>
      </SliderPrimitive.Root>
    </div>
  );
}

export function ParameterSlider({
  id,
  sliders,
  values: controlledValues,
  onChange,
  actions,
  onAction,
  onBeforeAction,
  className,
  trackClassName,
  fillClassName,
  handleClassName,
}: ParameterSliderProps) {
  const slidersSignature = useMemo(
    () => createSliderSignature(sliders),
    [sliders],
  );
  const sliderSnapshot = useMemo(
    () => createSliderValueSnapshot(sliders),
    [sliders],
  );
  const {
    value: currentValues,
    isControlled,
    setValue,
    setUncontrolledValue,
  } = useControllableState<SliderValue[]>({
    value: controlledValues,
    defaultValue: sliderSnapshot,
    onChange,
  });

  useSignatureReset(slidersSignature, () => {
    if (!isControlled) {
      setUncontrolledValue(sliderSnapshot);
    }
  });

  const valueMap = useMemo(() => {
    const map = new Map<string, number>();
    for (const v of currentValues) {
      map.set(v.id, v.value);
    }
    return map;
  }, [currentValues]);

  const updateValue = useCallback(
    (sliderId: string, newValue: number) => {
      setValue((prev) =>
        prev.map((v) => (v.id === sliderId ? { ...v, value: newValue } : v)),
      );
    },
    [setValue],
  );

  const handleReset = useCallback(() => {
    setValue(sliderSnapshot);
  }, [setValue, sliderSnapshot]);

  const handleAction = useCallback(
    async (actionId: string) => {
      let nextValues = currentValues;
      if (actionId === "reset") {
        handleReset();
        nextValues = sliderSnapshot;
      }

      await onAction?.(actionId, nextValues);
    },
    [currentValues, handleReset, onAction, sliderSnapshot],
  );

  const normalizedActions = useMemo(() => {
    const normalized = normalizeActionsConfig(actions);
    if (normalized) return normalized;
    return {
      items: [
        { id: "reset", label: "Reset", variant: "ghost" as const },
        { id: "apply", label: "Apply", variant: "default" as const },
      ],
      align: "right" as const,
    };
  }, [actions]);

  return (
    <article
      className={cn(
        "@container/parameter-slider isolate flex w-full max-w-md min-w-80 flex-col gap-3",
        "text-foreground",
        className,
      )}
      data-slot="parameter-slider"
      data-tool-ui-id={id}
    >
      <div
        className={cn(
          "bg-card flex w-full flex-col overflow-hidden rounded-2xl border px-5 py-3 shadow-xs",
        )}
      >
        {sliders.map((slider) => (
          <SliderRow
            key={slider.id}
            config={slider}
            value={valueMap.get(slider.id) ?? slider.value}
            onChange={(v) => updateValue(slider.id, v)}
            trackClassName={trackClassName}
            fillClassName={fillClassName}
            handleClassName={handleClassName}
          />
        ))}
      </div>

      <div className="@container/actions">
        <ActionButtons
          actions={normalizedActions.items}
          align={normalizedActions.align}
          confirmTimeout={normalizedActions.confirmTimeout}
          onAction={handleAction}
          onBeforeAction={
            onBeforeAction
              ? (actionId) => onBeforeAction(actionId, currentValues)
              : undefined
          }
        />
      </div>
    </article>
  );
}
components/tool-ui/parameter-slider/README.md
# Parameter Slider

Implementation for the "parameter-slider" Tool UI surface.

## Files

- public exports: components/tool-ui/parameter-slider/index.tsx
- serializable schema + parse helpers: components/tool-ui/parameter-slider/schema.ts

## Companion assets

- Docs page: app/docs/parameter-slider/content.mdx
- Preset payload: lib/presets/parameter-slider.ts

## Quick check

Run this after edits:

pnpm test
components/tool-ui/parameter-slider/schema.ts
import { z } from "zod";
import { type ActionsProp } from "../shared/actions-config";
import type { EmbeddedActionsProps } from "../shared/embedded-actions";
import { defineToolUiContract } from "../shared/contract";
import {
  SerializableActionSchema,
  SerializableActionsConfigSchema,
  ToolUIIdSchema,
  ToolUIRoleSchema,
} from "../shared/schema";

export const SliderConfigSchema = z
  .object({
    id: z.string().min(1),
    label: z.string().min(1),
    min: z.number().finite(),
    max: z.number().finite(),
    step: z.number().finite().positive().optional(),
    value: z.number().finite(),
    unit: z.string().optional(),
    precision: z.number().int().min(0).optional(),
    disabled: z.boolean().optional(),
    trackClassName: z.string().optional(),
    fillClassName: z.string().optional(),
    handleClassName: z.string().optional(),
  })
  .superRefine((slider, ctx) => {
    if (slider.max <= slider.min) {
      ctx.addIssue({
        code: z.ZodIssueCode.custom,
        path: ["max"],
        message: "max must be greater than min",
      });
    }

    if (slider.value < slider.min || slider.value > slider.max) {
      ctx.addIssue({
        code: z.ZodIssueCode.custom,
        path: ["value"],
        message: "value must be between min and max",
      });
    }
  });

export type SliderConfig = z.infer<typeof SliderConfigSchema>;

export const SerializableParameterSliderSchema = z
  .object({
    id: ToolUIIdSchema,
    role: ToolUIRoleSchema.optional(),
    sliders: z.array(SliderConfigSchema).min(1),
    actions: z
      .union([
        z.array(SerializableActionSchema),
        SerializableActionsConfigSchema,
      ])
      .optional(),
  })
  .strict()
  .superRefine((payload, ctx) => {
    const seenIds = new Map<string, number>();

    payload.sliders.forEach((slider, index) => {
      const firstSeenAt = seenIds.get(slider.id);
      if (firstSeenAt !== undefined) {
        ctx.addIssue({
          code: z.ZodIssueCode.custom,
          path: ["sliders", index, "id"],
          message: `duplicate slider id '${slider.id}' (first seen at index ${firstSeenAt})`,
        });
        return;
      }
      seenIds.set(slider.id, index);
    });
  });

export type SerializableParameterSlider = z.infer<
  typeof SerializableParameterSliderSchema
>;

const SerializableParameterSliderSchemaContract = defineToolUiContract(
  "ParameterSlider",
  SerializableParameterSliderSchema,
);

export const parseSerializableParameterSlider: (
  input: unknown,
) => SerializableParameterSlider =
  SerializableParameterSliderSchemaContract.parse;

export const safeParseSerializableParameterSlider: (
  input: unknown,
) => SerializableParameterSlider | null =
  SerializableParameterSliderSchemaContract.safeParse;

export interface SliderValue {
  id: string;
  value: number;
}

export interface ParameterSliderProps extends Omit<
  SerializableParameterSlider,
  "actions"
> {
  className?: string;
  values?: SliderValue[];
  onChange?: (values: SliderValue[]) => void;
  actions?: ActionsProp;
  onAction?: EmbeddedActionsProps<SliderValue[]>["onAction"];
  onBeforeAction?: EmbeddedActionsProps<SliderValue[]>["onBeforeAction"];
  trackClassName?: string;
  fillClassName?: string;
  handleClassName?: string;
}
components/tool-ui/shared/_adapter.tsx
/**
 * Adapter: UI and utility re-exports for copy-standalone portability.
 *
 * When copying this component to another project, update these imports
 * to match your project's paths:
 *
 *   cn     → Your Tailwind merge utility (e.g., "@/lib/utils", "~/lib/cn")
 *   Button → shadcn/ui Button
 */

export { cn } from "@/lib/utils";
export { Button } from "@/components/ui/button";
components/tool-ui/shared/action-buttons.tsx
"use client";

import type { Action } from "./schema";
import { cn, Button } from "./_adapter";
import { useActionButtons } from "./use-action-buttons";

export interface ActionButtonsProps {
  actions: Action[];
  onAction: (actionId: string) => void | Promise<void>;
  onBeforeAction?: (actionId: string) => boolean | Promise<boolean>;
  confirmTimeout?: number;
  align?: "left" | "center" | "right";
  className?: string;
}

export function ActionButtons({
  actions,
  onAction,
  onBeforeAction,
  confirmTimeout = 3000,
  align = "right",
  className,
}: ActionButtonsProps) {
  const { actions: resolvedActions, runAction } = useActionButtons({
    actions,
    onAction,
    onBeforeAction,
    confirmTimeout,
  });

  return (
    <div
      className={cn(
        "flex flex-col gap-3",
        "@sm/actions:flex-row @sm/actions:flex-wrap @sm/actions:items-center @sm/actions:gap-2",
        align === "left" && "@sm/actions:justify-start",
        align === "center" && "@sm/actions:justify-center",
        align === "right" && "@sm/actions:justify-end",
        className,
      )}
    >
      {resolvedActions.map((action) => {
        const label = action.currentLabel;
        const variant = action.variant || "default";

        return (
          <Button
            key={action.id}
            variant={variant}
            onClick={() => runAction(action.id)}
            disabled={action.isDisabled}
            className={cn(
              "rounded-full px-4!",
              "justify-center",
              "min-h-11 w-full text-base",
              "@sm/actions:min-h-0 @sm/actions:w-auto @sm/actions:px-3 @sm/actions:py-2 @sm/actions:text-sm",
              action.isConfirming &&
                "ring-destructive ring-2 ring-offset-2 motion-safe:animate-pulse",
            )}
            aria-label={
              action.shortcut ? `${label} (${action.shortcut})` : label
            }
          >
            {action.isLoading && (
              <svg
                className="mr-2 h-4 w-4 motion-safe:animate-spin"
                xmlns="http://www.w3.org/2000/svg"
                fill="none"
                viewBox="0 0 24 24"
              >
                <circle
                  className="opacity-25"
                  cx="12"
                  cy="12"
                  r="10"
                  stroke="currentColor"
                  strokeWidth="4"
                />
                <path
                  className="opacity-75"
                  fill="currentColor"
                  d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
                />
              </svg>
            )}
            {action.icon && !action.isLoading && (
              <span className="mr-2">{action.icon}</span>
            )}
            {label}
            {action.shortcut && !action.isLoading && (
              <kbd className="border-border bg-muted ml-2.5 hidden rounded-lg border px-2 py-0.5 font-mono text-xs font-medium sm:inline-block">
                {action.shortcut}
              </kbd>
            )}
          </Button>
        );
      })}
    </div>
  );
}
components/tool-ui/shared/actions-config.ts
import type { Action, ActionsConfig } from "./schema";

export type ActionsProp = ActionsConfig | Action[];

const NEGATORY_ACTION_IDS = new Set([
  "cancel",
  "dismiss",
  "skip",
  "no",
  "reset",
  "close",
  "decline",
  "reject",
  "back",
  "later",
  "not-now",
  "maybe-later",
]);

function inferVariant(action: Action): Action {
  if (action.variant) return action;
  if (NEGATORY_ACTION_IDS.has(action.id)) {
    return { ...action, variant: "ghost" };
  }
  return action;
}

export function normalizeActionsConfig(
  actions?: ActionsProp,
): ActionsConfig | null {
  if (!actions) return null;

  const rawItems = Array.isArray(actions) ? actions : (actions.items ?? []);

  if (rawItems.length === 0) {
    return null;
  }

  const items = rawItems.map(inferVariant);

  return Array.isArray(actions)
    ? { items }
    : {
        items,
        align: actions.align,
        confirmTimeout: actions.confirmTimeout,
      };
}
components/tool-ui/shared/contract.ts
import { z } from "zod";
import { parseWithSchema, safeParseWithSchema } from "./parse";

export interface ToolUiContract<T> {
  schema: z.ZodType<T>;
  parse: (input: unknown) => T;
  safeParse: (input: unknown) => T | null;
}

export function defineToolUiContract<T>(
  componentName: string,
  schema: z.ZodType<T>,
): ToolUiContract<T> {
  return {
    schema,
    parse: (input: unknown) => parseWithSchema(schema, input, componentName),
    safeParse: (input: unknown) => safeParseWithSchema(schema, input),
  };
}
components/tool-ui/shared/embedded-actions.ts
import type { ActionsProp } from "./actions-config";

export type EmbeddedActionHandler<TState> = (
  actionId: string,
  state: TState,
) => void | Promise<void>;

export type EmbeddedBeforeActionHandler<TState> = (
  actionId: string,
  state: TState,
) => boolean | Promise<boolean>;

export interface EmbeddedActionsProps<TState> {
  actions?: ActionsProp;
  onAction?: EmbeddedActionHandler<TState>;
  onBeforeAction?: EmbeddedBeforeActionHandler<TState>;
}
components/tool-ui/shared/parse.ts
import { z } from "zod";

function formatZodPath(path: Array<string | number | symbol>): string {
  if (path.length === 0) return "root";
  return path
    .map((segment) =>
      typeof segment === "number" ? `[${segment}]` : String(segment),
    )
    .join(".");
}

/**
 * Format Zod errors into a compact `path: message` string.
 */
export function formatZodError(error: z.ZodError): string {
  const parts = error.issues.map((issue) => {
    const path = formatZodPath(issue.path);
    return `${path}: ${issue.message}`;
  });

  return Array.from(new Set(parts)).join("; ");
}

/**
 * Parse unknown input and throw a readable error.
 */
export function parseWithSchema<T>(
  schema: z.ZodType<T>,
  input: unknown,
  name: string,
): T {
  const res = schema.safeParse(input);
  if (!res.success) {
    throw new Error(`Invalid ${name} payload: ${formatZodError(res.error)}`);
  }
  return res.data;
}

/**
 * Parse unknown input, returning `null` instead of throwing on failure.
 *
 * Use this in assistant-ui `render` functions where `args` stream in
 * incrementally and may be incomplete until the tool call finishes.
 */
export function safeParseWithSchema<T>(
  schema: z.ZodType<T>,
  input: unknown,
): T | null {
  const res = schema.safeParse(input);
  return res.success ? res.data : null;
}
components/tool-ui/shared/schema.ts
import { z } from "zod";
import type { ReactNode } from "react";

/**
 * Tool UI conventions:
 * - Serializable schemas are JSON-safe (no callbacks/ReactNode/`className`).
 * - Schema: `SerializableXSchema`
 * - Parser: `parseSerializableX(input: unknown)` (throws on invalid)
 * - Safe parser: `safeParseSerializableX(input: unknown)` (returns `null` on invalid)
 * - Actions: `LocalActions` for non-receipt actions and `DecisionActions` for consequential actions
 * - Root attrs: `data-tool-ui-id` + `data-slot`
 */

/**
 * Schema for tool UI identity.
 *
 * Every tool UI should have a unique identifier that:
 * - Is stable across re-renders
 * - Is meaningful (not auto-generated)
 * - Is unique within the conversation
 *
 * Format recommendation: `{component-type}-{semantic-identifier}`
 * Examples: "data-table-expenses-q3", "option-list-deploy-target"
 */
export const ToolUIIdSchema = z.string().min(1);

export type ToolUIId = z.infer<typeof ToolUIIdSchema>;

/**
 * Primary role of a Tool UI surface in a chat context.
 */
export const ToolUIRoleSchema = z.enum([
  "information",
  "decision",
  "control",
  "state",
  "composite",
]);

export type ToolUIRole = z.infer<typeof ToolUIRoleSchema>;

export const ToolUIReceiptOutcomeSchema = z.enum([
  "success",
  "partial",
  "failed",
  "cancelled",
]);

export type ToolUIReceiptOutcome = z.infer<typeof ToolUIReceiptOutcomeSchema>;

/**
 * Optional receipt metadata: a durable summary of an outcome.
 */
export const ToolUIReceiptSchema = z.object({
  outcome: ToolUIReceiptOutcomeSchema,
  summary: z.string().min(1),
  identifiers: z.record(z.string(), z.string()).optional(),
  at: z.string().datetime(),
});

export type ToolUIReceipt = z.infer<typeof ToolUIReceiptSchema>;

/**
 * Base schema for Tool UI payloads (id + optional role/receipt).
 */
export const ToolUISurfaceSchema = z.object({
  id: ToolUIIdSchema,
  role: ToolUIRoleSchema.optional(),
  receipt: ToolUIReceiptSchema.optional(),
});

export type ToolUISurface = z.infer<typeof ToolUISurfaceSchema>;

export const ActionSchema = z.object({
  id: z.string().min(1),
  label: z.string().min(1),
  /**
   * Canonical narration the assistant can use after this action is taken.
   *
   * Example: "I exported the table as CSV." / "I opened the link in a new tab."
   */
  sentence: z.string().optional(),
  confirmLabel: z.string().optional(),
  variant: z
    .enum(["default", "destructive", "secondary", "ghost", "outline"])
    .optional(),
  icon: z.custom<ReactNode>().optional(),
  loading: z.boolean().optional(),
  disabled: z.boolean().optional(),
  shortcut: z.string().optional(),
});

export type Action = z.infer<typeof ActionSchema>;
export type LocalAction = Action;
export type DecisionAction = Action;

export const DecisionResultSchema = z.object({
  kind: z.literal("decision"),
  version: z.literal(1),
  decisionId: z.string().min(1),
  actionId: z.string().min(1),
  actionLabel: z.string().min(1),
  at: z.string().datetime(),
  payload: z.record(z.string(), z.unknown()).optional(),
});

export type DecisionResult<
  TPayload extends Record<string, unknown> = Record<string, unknown>,
> = Omit<z.infer<typeof DecisionResultSchema>, "payload"> & {
  payload?: TPayload;
};

export function createDecisionResult<
  TPayload extends Record<string, unknown> = Record<string, unknown>,
>(args: {
  decisionId: string;
  action: { id: string; label: string };
  payload?: TPayload;
}): DecisionResult<TPayload> {
  return {
    kind: "decision",
    version: 1,
    decisionId: args.decisionId,
    actionId: args.action.id,
    actionLabel: args.action.label,
    at: new Date().toISOString(),
    payload: args.payload,
  };
}

export const ActionButtonsPropsSchema = z.object({
  actions: z.array(ActionSchema).min(1),
  align: z.enum(["left", "center", "right"]).optional(),
  confirmTimeout: z.number().positive().optional(),
  className: z.string().optional(),
});

export const SerializableActionSchema = ActionSchema.omit({ icon: true });
export const SerializableActionsSchema = ActionButtonsPropsSchema.extend({
  actions: z.array(SerializableActionSchema),
}).omit({ className: true });

export interface ActionsConfig {
  items: Action[];
  align?: "left" | "center" | "right";
  confirmTimeout?: number;
}

export const SerializableActionsConfigSchema = z.object({
  items: z.array(SerializableActionSchema).min(1),
  align: z.enum(["left", "center", "right"]).optional(),
  confirmTimeout: z.number().positive().optional(),
});

export type SerializableActionsConfig = z.infer<
  typeof SerializableActionsConfigSchema
>;

export type SerializableAction = z.infer<typeof SerializableActionSchema>;
components/tool-ui/shared/use-action-buttons.tsx
"use client";

import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { Action } from "./schema";

export type UseActionButtonsOptions = {
  actions: Action[];
  onAction: (actionId: string) => void | Promise<void>;
  onBeforeAction?: (actionId: string) => boolean | Promise<boolean>;
  confirmTimeout?: number;
};

export type UseActionButtonsResult = {
  actions: Array<
    Action & {
      currentLabel: string;
      isConfirming: boolean;
      isExecuting: boolean;
      isDisabled: boolean;
      isLoading: boolean;
    }
  >;
  runAction: (actionId: string) => Promise<void>;
  confirmingActionId: string | null;
  executingActionId: string | null;
};

type ActionExecutionLock = {
  tryAcquire: () => boolean;
  release: () => void;
};

export function createActionExecutionLock(): ActionExecutionLock {
  let locked = false;

  return {
    tryAcquire: () => {
      if (locked) return false;
      locked = true;
      return true;
    },
    release: () => {
      locked = false;
    },
  };
}

export function useActionButtons(
  options: UseActionButtonsOptions,
): UseActionButtonsResult {
  const { actions, onAction, onBeforeAction, confirmTimeout = 3000 } = options;

  const [confirmingActionId, setConfirmingActionId] = useState<string | null>(
    null,
  );
  const [executingActionId, setExecutingActionId] = useState<string | null>(
    null,
  );
  const executionLockRef = useRef<ActionExecutionLock>(
    createActionExecutionLock(),
  );

  useEffect(() => {
    if (!confirmingActionId) return;
    const id = setTimeout(() => setConfirmingActionId(null), confirmTimeout);
    return () => clearTimeout(id);
  }, [confirmingActionId, confirmTimeout]);

  useEffect(() => {
    if (!confirmingActionId) return;
    const handleKeyDown = (e: KeyboardEvent) => {
      if (e.key === "Escape") {
        setConfirmingActionId(null);
      }
    };

    window.addEventListener("keydown", handleKeyDown);
    return () => window.removeEventListener("keydown", handleKeyDown);
  }, [confirmingActionId]);

  const runAction = useCallback(
    async (actionId: string) => {
      const action = actions.find((a) => a.id === actionId);
      if (!action) return;

      const isAnyActionExecuting = executingActionId !== null;
      if (action.disabled || action.loading || isAnyActionExecuting) {
        return;
      }

      if (action.confirmLabel && confirmingActionId !== action.id) {
        setConfirmingActionId(action.id);
        return;
      }

      if (!executionLockRef.current.tryAcquire()) {
        return;
      }

      if (onBeforeAction) {
        const shouldProceed = await onBeforeAction(action.id);
        if (!shouldProceed) {
          setConfirmingActionId(null);
          executionLockRef.current.release();
          return;
        }
      }

      try {
        setExecutingActionId(action.id);
        await onAction(action.id);
      } finally {
        executionLockRef.current.release();
        setExecutingActionId(null);
        setConfirmingActionId(null);
      }
    },
    [actions, confirmingActionId, executingActionId, onAction, onBeforeAction],
  );

  const resolvedActions = useMemo(
    () =>
      actions.map((action) => {
        const isConfirming = confirmingActionId === action.id;
        const isThisActionExecuting = executingActionId === action.id;
        const isLoading = action.loading || isThisActionExecuting;
        const isDisabled =
          action.disabled ||
          (executingActionId !== null && !isThisActionExecuting);
        const currentLabel =
          isConfirming && action.confirmLabel
            ? action.confirmLabel
            : action.label;

        return {
          ...action,
          currentLabel,
          isConfirming,
          isExecuting: isThisActionExecuting,
          isDisabled,
          isLoading,
        };
      }),
    [actions, confirmingActionId, executingActionId],
  );

  return {
    actions: resolvedActions,
    runAction,
    confirmingActionId,
    executingActionId,
  };
}
components/tool-ui/shared/use-controllable-state.ts
"use client";

import { useCallback, useMemo, useRef, useState } from "react";

export type UseControllableStateOptions<T> = {
  value?: T;
  defaultValue: T;
  onChange?: (next: T) => void;
};

export function useControllableState<T>({
  value,
  defaultValue,
  onChange,
}: UseControllableStateOptions<T>) {
  const [uncontrolled, setUncontrolled] = useState<T>(defaultValue);
  const isControlled = value !== undefined;

  const currentValue = useMemo(
    () => (isControlled ? (value as T) : uncontrolled),
    [isControlled, value, uncontrolled],
  );
  const currentValueRef = useRef(currentValue);
  currentValueRef.current = currentValue;

  const setValue = useCallback(
    (next: T | ((prev: T) => T)) => {
      const resolved =
        typeof next === "function"
          ? (next as (prev: T) => T)(currentValueRef.current)
          : next;

      currentValueRef.current = resolved;
      if (!isControlled) {
        setUncontrolled(resolved);
      }

      onChange?.(resolved);
      return resolved;
    },
    [isControlled, onChange],
  );

  const setUncontrolledValue = useCallback((next: T) => {
    setUncontrolled(next);
  }, []);

  return {
    value: currentValue,
    isControlled,
    setValue,
    setUncontrolledValue,
  };
}
components/tool-ui/shared/use-signature-reset.ts
"use client";

import { useEffect, useRef } from "react";

export function useSignatureReset(
  signature: string,
  onSignatureChange: () => void,
) {
  const previousSignature = useRef(signature);

  useEffect(() => {
    if (previousSignature.current === signature) return;
    previousSignature.current = signature;
    onSignatureChange();
  }, [signature, onSignatureChange]);
}

More from tool-ui

Browse all components in this library →