Unit Input
Inputs & Forms

Unit Input

A number and unit select fused into one field: swapping px/rem/% or kg/lb converts the figure in place with direction-aware digit rolls, backed by a canonical base value so round-trips never drift.

Install

npx shadcn@latest add @paragon/unit-input

unit-input.tsx

"use client";

import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Check, ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";

const EASE_OUT: [number, number, number, number] = [0.22, 1, 0.36, 1];
const EASE_EXIT: [number, number, number, number] = [0.4, 0, 1, 1];

export interface UnitDef {
  /** Unit code shown in the field, e.g. "px", "kg". */
  value: string;
  /** Longer name for the dropdown row. */
  label?: string;
  /** Multiplier to the base unit: base = displayed × factor. */
  factor: number;
  /** Fraction digits shown for this unit. */
  precision?: number;
  /** Arrow-key step in this unit's terms. */
  step?: number;
}

function clamp(v: number, min?: number, max?: number) {
  if (min !== undefined && v < min) return min;
  if (max !== undefined && v > max) return max;
  return v;
}

function trimmed(v: number, precision: number) {
  return String(Number(v.toFixed(precision)));
}

/** One glyph cell: old char rolls out while the new rolls in. */
function DigitCell({
  char,
  direction,
  reduced,
}: {
  char: string;
  direction: 1 | -1;
  reduced: boolean;
}) {
  return (
    <span className="relative inline-flex justify-center overflow-hidden">
      <AnimatePresence mode="popLayout" initial={false}>
        <motion.span
          key={char}
          initial={
            reduced
              ? { opacity: 0 }
              : { y: direction > 0 ? "100%" : "-100%", opacity: 0.25 }
          }
          animate={{ y: "0%", opacity: 1 }}
          exit={
            reduced
              ? { opacity: 0, transition: { duration: 0.1 } }
              : {
                  y: direction > 0 ? "-100%" : "100%",
                  opacity: 0.25,
                  transition: { duration: 0.15, ease: EASE_EXIT },
                }
          }
          transition={{ duration: 0.15, ease: EASE_OUT }}
          className="inline-block"
        >
          {char}
        </motion.span>
      </AnimatePresence>
    </span>
  );
}

export interface UnitInputProps
  extends Omit<React.ComponentProps<"div">, "onChange" | "defaultValue"> {
  /** Available units. The first is the default. */
  units: UnitDef[];
  /** Controlled value, always in the base unit. */
  value?: number;
  /** Initial base-unit value when uncontrolled. */
  defaultValue?: number;
  /** Fires with the base-unit value. */
  onValueChange?: (baseValue: number) => void;
  /** Controlled unit code. */
  unit?: string;
  defaultUnit?: string;
  onUnitChange?: (unit: string) => void;
  /** Bounds in base units. */
  min?: number;
  max?: number;
  /** Form field name; submits the displayed value plus `<name>Unit`. */
  name?: string;
  disabled?: boolean;
  /** Disables the conversion digit roll. */
  static?: boolean;
  "aria-label"?: string;
}

/**
 * A number and a unit select fused into one field. Swapping the unit converts
 * the figure in place — digits roll direction-aware to the new magnitude —
 * while a canonical base value guarantees round-trips never drift. Click or
 * type to edit; arrows step in the current unit's increments.
 */
export function UnitInput({
  units,
  value: valueProp,
  defaultValue = 0,
  onValueChange,
  unit: unitProp,
  defaultUnit,
  onUnitChange,
  min,
  max,
  name,
  disabled = false,
  static: isStatic = false,
  className,
  "aria-label": ariaLabel,
  ...props
}: UnitInputProps) {
  const reduced = useReducedMotion() ?? false;
  const [uncontrolledValue, setUncontrolledValue] = React.useState(
    clamp(defaultValue, min, max),
  );
  const [uncontrolledUnit, setUncontrolledUnit] = React.useState(
    defaultUnit ?? units[0]?.value ?? "",
  );
  const base = valueProp ?? uncontrolledValue;
  const unitCode = unitProp ?? uncontrolledUnit;
  const unit = units.find((u) => u.value === unitCode) ?? units[0];

  const precision = unit?.precision ?? 2;
  const display = unit ? trimmed(base / unit.factor, precision) : "0";

  const [editing, setEditing] = React.useState(false);
  const [draft, setDraft] = React.useState("");
  const directionRef = React.useRef<1 | -1>(1);
  const baseRef = React.useRef(base);
  baseRef.current = base;
  const spinRef = React.useRef<HTMLDivElement>(null);
  const inputRef = React.useRef<HTMLInputElement>(null);

  const setBase = React.useCallback(
    (next: number) => {
      const clamped = clamp(next, min, max);
      if (clamped === baseRef.current) return;
      baseRef.current = clamped;
      if (valueProp === undefined) setUncontrolledValue(clamped);
      onValueChange?.(clamped);
    },
    [min, max, valueProp, onValueChange],
  );

  const changeUnit = (nextCode: string) => {
    const next = units.find((u) => u.value === nextCode);
    if (!next || !unit || nextCode === unit.value) return;
    if (editing) commitDraft();
    // Roll direction follows the magnitude of the *displayed* figure.
    const before = baseRef.current / unit.factor;
    const after = baseRef.current / next.factor;
    directionRef.current = after >= before ? 1 : -1;
    if (unitProp === undefined) setUncontrolledUnit(nextCode);
    onUnitChange?.(nextCode);
  };

  const startEdit = (seed?: string) => {
    if (disabled) return;
    setDraft(seed ?? display);
    setEditing(true);
  };

  React.useEffect(() => {
    if (!editing) return;
    const input = inputRef.current;
    if (!input) return;
    input.focus();
    input.select();
  }, [editing]);

  const commitDraft = () => {
    const parsed = Number.parseFloat(draft);
    if (!Number.isNaN(parsed) && unit) {
      directionRef.current =
        parsed >= base / unit.factor ? 1 : -1;
      setBase(parsed * unit.factor);
    }
    setEditing(false);
  };

  const stepBy = (dir: 1 | -1, mult = 1) => {
    if (!unit) return;
    directionRef.current = dir;
    setBase(baseRef.current + dir * (unit.step ?? 1) * mult * unit.factor);
  };

  const chars = display.split("");

  return (
    <div
      data-slot="unit-input"
      className={cn(
        "inline-flex h-9 w-full min-w-0 items-stretch rounded-lg border border-input bg-transparent",
        "transition-[border-color,box-shadow] duration-150 ease-out",
        "focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/25",
        disabled && "pointer-events-none opacity-50",
        className,
      )}
      {...props}
    >
      {editing ? (
        <input
          ref={inputRef}
          type="text"
          inputMode="decimal"
          aria-label={ariaLabel}
          value={draft}
          onChange={(event) =>
            setDraft(event.target.value.replace(/[^0-9.\-]/g, ""))
          }
          onBlur={commitDraft}
          onKeyDown={(event) => {
            if (event.key === "Enter") {
              event.preventDefault();
              commitDraft();
              spinRef.current?.focus();
            } else if (event.key === "Escape") {
              event.preventDefault();
              setEditing(false);
              spinRef.current?.focus();
            }
          }}
          className="h-full min-w-0 flex-1 bg-transparent px-3 text-sm font-medium text-foreground tabular-nums outline-none"
        />
      ) : (
        <div
          ref={spinRef}
          role="spinbutton"
          tabIndex={disabled ? -1 : 0}
          aria-valuenow={Number(display)}
          aria-valuemin={min !== undefined && unit ? min / unit.factor : undefined}
          aria-valuemax={max !== undefined && unit ? max / unit.factor : undefined}
          aria-valuetext={`${display} ${unitCode}`}
          aria-label={ariaLabel}
          onClick={() => startEdit()}
          onKeyDown={(event) => {
            if (disabled) return;
            if (event.key === "ArrowUp") {
              event.preventDefault();
              stepBy(1, event.shiftKey ? 10 : 1);
            } else if (event.key === "ArrowDown") {
              event.preventDefault();
              stepBy(-1, event.shiftKey ? 10 : 1);
            } else if (event.key === "Enter") {
              event.preventDefault();
              startEdit();
            } else if (
              /^[0-9.\-]$/.test(event.key) &&
              !event.metaKey &&
              !event.ctrlKey
            ) {
              event.preventDefault();
              startEdit(event.key);
            }
          }}
          className="flex min-w-0 flex-1 cursor-text items-center px-3 text-sm font-medium text-foreground tabular-nums outline-none"
        >
          <span aria-hidden className="flex">
            {chars.map((char, i) =>
              /\d/.test(char) ? (
                <DigitCell
                  key={`d-${chars.length}-${i}`}
                  char={char}
                  direction={directionRef.current}
                  reduced={reduced || isStatic}
                />
              ) : (
                <span key={`c-${chars.length}-${i}`} className="inline-block">
                  {char}
                </span>
              ),
            )}
          </span>
        </div>
      )}

      <SelectPrimitive.Root
        value={unitCode}
        onValueChange={changeUnit}
        disabled={disabled}
      >
        <SelectPrimitive.Trigger
          aria-label="Unit"
          className={cn(
            "flex shrink-0 items-center gap-1 rounded-r-[7px] border-l border-input px-2.5 text-xs font-medium text-muted-foreground select-none",
            "transition-[background-color,color] duration-150 ease-out",
            "hover:bg-accent hover:text-foreground",
            "outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset",
          )}
        >
          <SelectPrimitive.Value />
          <ChevronDown aria-hidden className="size-3 opacity-60" />
        </SelectPrimitive.Trigger>
        <style href="paragon-unit-input" precedence="paragon">{`
          @keyframes paragon-unit-in {
            from { opacity: 0; scale: 0.97; translate: 0 -2px; }
          }
          @keyframes paragon-unit-out {
            to { opacity: 0; }
          }
          @media (prefers-reduced-motion: reduce) {
            @keyframes paragon-unit-in { from { opacity: 0; } }
          }
        `}</style>
        <SelectPrimitive.Portal>
          <SelectPrimitive.Content
            position="popper"
            sideOffset={4}
            align="end"
            className={cn(
              "z-50 min-w-32 origin-(--radix-select-content-transform-origin) rounded-lg bg-popover p-1 text-popover-foreground shadow-overlay",
              "data-[state=open]:animate-[paragon-unit-in_150ms_var(--ease-out)]",
              "data-[state=closed]:animate-[paragon-unit-out_75ms_var(--ease-exit)_forwards]",
            )}
          >
            <SelectPrimitive.Viewport>
              {units.map((u) => (
                <SelectPrimitive.Item
                  key={u.value}
                  value={u.value}
                  className={cn(
                    "flex cursor-default items-center justify-between gap-3 rounded-md px-2 py-1.5 text-xs outline-none select-none",
                    "data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground",
                  )}
                >
                  <SelectPrimitive.ItemText>
                    <span className="font-medium">{u.value}</span>
                    {u.label && (
                      <span className="ml-1.5 text-muted-foreground">
                        {u.label}
                      </span>
                    )}
                  </SelectPrimitive.ItemText>
                  <SelectPrimitive.ItemIndicator>
                    <Check aria-hidden className="size-3" />
                  </SelectPrimitive.ItemIndicator>
                </SelectPrimitive.Item>
              ))}
            </SelectPrimitive.Viewport>
          </SelectPrimitive.Content>
        </SelectPrimitive.Portal>
      </SelectPrimitive.Root>

      {name && (
        <>
          <input type="hidden" name={name} value={display} />
          <input type="hidden" name={`${name}Unit`} value={unitCode} />
        </>
      )}
    </div>
  );
}