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.
npx shadcn@latest add @paragon/unit-input"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>
);
}