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 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 UnitFieldTypographyDemo() {
const [fontSize, setFontSize] = React.useState(24);
const [lineHeight, setLineHeight] = React.useState(150);
const [letterSpacing, setLetterSpacing] = React.useState(0);
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">04</span>
<h3 className="text-[14px] font-medium text-black">Typography</h3>
</div>
<p className="mt-1 text-[13px] text-gray-500">
Font size, line height, and letter spacing.
</p>
</header>
<div className="space-y-1.5">
<FieldRow label="Size">
<UnitField.Root
className={ROOT}
format={pxFormat}
parse={pxParse}
value={fontSize}
onValueChange={setFontSize}
min={8}
max={120}
>
<UnitField.DragArea className={DRAG_AREA}>Sz</UnitField.DragArea>
<UnitField.Input className={INPUT} />
</UnitField.Root>
</FieldRow>
<FieldRow label="Leading">
<UnitField.Root
className={ROOT}
format={pctFormat}
parse={pctParse}
value={lineHeight}
onValueChange={setLineHeight}
min={50}
max={300}
>
<UnitField.DragArea className={DRAG_AREA}>Ld</UnitField.DragArea>
<UnitField.Input className={INPUT} />
</UnitField.Root>
</FieldRow>
<FieldRow label="Tracking">
<UnitField.Root
className={ROOT}
format={pxFormat}
parse={(v) => {
const n = parseFloat(v.replace("px", ""));
return isNaN(n) ? null : n;
}}
value={letterSpacing}
onValueChange={setLetterSpacing}
min={-10}
max={20}
step={0.5}
>
<UnitField.DragArea className={DRAG_AREA}>Tk</UnitField.DragArea>
<UnitField.Input className={INPUT} />
</UnitField.Root>
</FieldRow>
</div>
<div className="mt-4 overflow-hidden border border-gray-200 p-4">
<p
className="font-medium text-black transition-all"
style={{
fontSize: `${fontSize}px`,
lineHeight: `${lineHeight}%`,
letterSpacing: `${letterSpacing}px`,
}}
>
Aa Bb Cc
</p>
</div>
</div>
);
}