Components
Headless, composable numeric input field with drag-to-scrub, keyboard stepping, format/parse controls, and committed-value events for building any unit field.

"use client";
import * as React from "react";
import { UnitField } from "@/components/ui/unit-field";
const INPUT =
"w-full bg-transparent pr-2.5 text-right font-mono text-sm text-gray-700 tabular-nums outline-none placeholder:text-gray-300";
const DRAG_AREA =
"flex aspect-square w-9 shrink-0 cursor-ew-resize items-center justify-center text-[12px] font-medium text-gray-400 select-none";
const ROOT =
"relative flex h-9 w-full items-stretch border border-gray-200 transition-colors data-[focused=true]:border-gray-400";
function pxFormat(v: number) {
return `${v}px`;
}
function pxParse(v: string) {
const n = parseInt(v.replace("px", ""), 10);
return isNaN(n) ? null : n;
}
function degFormat(v: number) {
return `${v}°`;
}
function degParse(v: string) {
const n = parseInt(v.replace("°", ""), 10);
return isNaN(n) ? null : n;
}
function pctFormat(v: number) {
return `${v}%`;
}
function pctParse(v: string) {
const n = parseInt(v.replace("%", ""), 10);
return isNaN(n) ? null : n;
}
function FieldRow(props: { label: string; children: React.ReactNode }) {
return (
<div className="grid grid-cols-[80px_1fr] items-center gap-3">
<span className="text-[13px] text-gray-400">{props.label}</span>
{props.children}
</div>
);
}
export default function UnitFieldPropertiesDemo() {
const [width, setWidth] = React.useState(200);
const [height, setHeight] = React.useState(120);
const [radius, setRadius] = React.useState(0);
const [rotation, setRotation] = React.useState(0);
const [opacity, setOpacity] = React.useState(100);
const [border, setBorder] = React.useState(1);
return (
<div className="mx-auto w-full max-w-sm bg-white p-6 text-gray-700">
<header className="mb-3">
<div className="flex items-center gap-2">
<span className="font-mono text-[11px] text-gray-400">01</span>
<h3 className="text-[14px] font-medium text-black">Properties</h3>
</div>
<p className="mt-1 text-[13px] text-gray-500">
Drag the labels to scrub. Arrow keys for fine control, shift for large
steps.
</p>
</header>
<div className="space-y-1.5">
<FieldRow label="Width">
<UnitField.Root
className={ROOT}
format={pxFormat}
parse={pxParse}
value={width}
onValueChange={setWidth}
min={0}
max={1000}
>
<UnitField.DragArea className={DRAG_AREA}>W</UnitField.DragArea>
<UnitField.Input className={INPUT} />
</UnitField.Root>
</FieldRow>
<FieldRow label="Height">
<UnitField.Root
className={ROOT}
format={pxFormat}
parse={pxParse}
value={height}
onValueChange={setHeight}
min={0}
max={1000}
>
<UnitField.DragArea className={DRAG_AREA}>H</UnitField.DragArea>
<UnitField.Input className={INPUT} />
</UnitField.Root>
</FieldRow>
<FieldRow label="Radius">
<UnitField.Root
className={ROOT}
format={pxFormat}
parse={pxParse}
value={radius}
onValueChange={setRadius}
min={0}
max={999}
>
<UnitField.DragArea className={DRAG_AREA}>R</UnitField.DragArea>
<UnitField.Input className={INPUT} />
</UnitField.Root>
</FieldRow>
<FieldRow label="Rotation">
<UnitField.Root
className={ROOT}
format={degFormat}
parse={degParse}
value={rotation}
onValueChange={setRotation}
min={0}
max={360}
>
<UnitField.DragArea className={DRAG_AREA}>°</UnitField.DragArea>
<UnitField.Input className={INPUT} />
</UnitField.Root>
</FieldRow>
<FieldRow label="Opacity">
<UnitField.Root
className={ROOT}
format={pctFormat}
parse={pctParse}
value={opacity}
onValueChange={setOpacity}
min={0}
max={100}
>
<UnitField.DragArea className={DRAG_AREA}>%</UnitField.DragArea>
<UnitField.Input className={INPUT} />
</UnitField.Root>
</FieldRow>
<FieldRow label="Border">
<UnitField.Root
className={ROOT}
format={pxFormat}
parse={pxParse}
value={border}
onValueChange={setBorder}
min={0}
max={20}
>
<UnitField.DragArea className={DRAG_AREA}>B</UnitField.DragArea>
<UnitField.Input className={INPUT} />
</UnitField.Root>
</FieldRow>
</div>
<div className="mt-4 flex h-32 items-center justify-center overflow-hidden border border-gray-200">
<div
className="bg-gray-800"
style={{
width: Math.min(width, 160),
height: Math.min(height, 80),
borderRadius: radius,
transform: `rotate(${rotation}deg)`,
opacity: opacity / 100,
borderWidth: border,
borderColor: "#9ca3af",
borderStyle: "solid",
transition: "all 150ms ease",
}}
/>
</div>
</div>
);
}