Unit Input
A number field with a unit switcher that converts the value when you change units (px ↔ rem ↔ %, kg ↔ lb).
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/unit-input.json'use client'
import { useId, useState } from 'react'
import './unit-input.css'
export interface UnitDef { id: string; label: string; toBase: (n: number) => number; fromBase: (n: number) => number }
export interface UnitInputProps {
units: UnitDef[]
label?: string
defaultValue?: number
step?: number
onValueChange?: (value: number, unit: string) => void
fill?: 'dark' | 'light'
className?: string
}
const round = (n: number) => Math.round(n * 1000) / 1000
/** Number + unit with conversion on unit change. */
export function UnitInput({ units, label = 'Value', defaultValue = 16, step = 1, onValueChange, fill = 'dark', className }: UnitInputProps) {
const [u, setU] = useState(units[0].id)
const [v, setV] = useState(String(defaultValue))
const [flash, setFlash] = useState(0)
const [note, setNote] = useState('')
const uid = useId()
const unit = units.find((x) => x.id === u)!
const set = (s: string) => { setV(s); const n = Number(s); if (!Number.isNaN(n)) onValueChange?.(n, u) }
const change = (id: string) => {
const nu = units.find((x) => x.id === id)!, n = Number(v)
if (!Number.isNaN(n)) { const c = round(nu.fromBase(unit.toBase(n))); setV(String(c)); setNote(`${n} ${unit.label} = ${c} ${nu.label}`); onValueChange?.(c, id); setFlash((f) => f + 1) }
setU(id)
}
return (
<div className={['sd-uni', className].filter(Boolean).join(' ')} data-fill={fill}>
<label className="sd-uni__lab" htmlFor={uid}>{label}</label>
<div className="sd-uni__box">
<input key={flash} id={uid} className="sd-uni__in" data-flash={flash ? '' : undefined} role="spinbutton" inputMode="decimal" aria-valuenow={Number(v) || 0} value={v} onChange={(e) => set(e.target.value.replace(/[^0-9.-]/g, ''))}
onKeyDown={(e) => { if (e.key === 'ArrowUp' || e.key === 'ArrowDown') { e.preventDefault(); set(String(round((Number(v) || 0) + (e.key === 'ArrowUp' ? 1 : -1) * step * (e.shiftKey ? 10 : 1)))) } }} />
<select className="sd-uni__sel" aria-label="Unit" value={u} onChange={(e) => change(e.target.value)}>{units.map((x) => <option key={x.id} value={x.id}>{x.label}</option>)}</select>
</div>
<p className="sd-uni__note" aria-live="polite">{note}</p>
</div>
)
}.sd-uni { --bg: #1c1c1c; --fg: #f6f6f6; --mute: #8e8e8e; --line: rgb(255 255 255 / 0.1); --soft: rgb(255 255 255 / 0.07); --strong: #fafafa; --on: #111;
color: var(--fg); font-size: 14px; line-height: 1.4; -webkit-tap-highlight-color: transparent; }
.sd-uni[data-fill='light'] { --bg: #fff; --fg: #121212; --mute: #6f6f6f; --line: rgb(0 0 0 / 0.09); --soft: rgb(0 0 0 / 0.05); --strong: #111; --on: #fff; }
.sd-uni button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-uni :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-uni .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-uni { width: min(100%, 280px); }
.sd-uni__lab { display: block; font-size: 12.5px; color: var(--mute); margin: 0 0 6px 2px; }
.sd-uni__box { display: flex; align-items: center; height: 46px; border-radius: 13px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); }
.sd-uni__box:focus-within { box-shadow: inset 0 0 0 1.5px var(--fg); }
.sd-uni__in { flex: 1; min-width: 0; height: 100%; padding: 0 12px; border: 0; outline: 0; background: none; color: var(--fg); font: 500 16px/1 ui-sans-serif, system-ui; font-variant-numeric: tabular-nums; }
.sd-uni__in[data-flash] { animation: sd-uni-flash 400ms ease; }
@keyframes sd-uni-flash { from { opacity: 0.2; transform: translateY(-3px); } }
.sd-uni__sel { height: 34px; margin-right: 6px; padding: 0 6px; border: 0; border-radius: 9px; background: var(--soft); color: var(--fg); font: 600 13px/1 ui-sans-serif, system-ui; }
.sd-uni__note { margin: 8px 0 0 2px; font-size: 12px; color: var(--mute); min-height: 16px; }
@media (prefers-reduced-motion: reduce) { .sd-uni *, .sd-uni *::before, .sd-uni *::after { animation: none !important; transition: none !important; } }Overview
Type a number, pick a unit from the trailing menu, and the value is converted rather than reinterpreted, with sensible rounding. Arrow keys step, Shift steps by ten.
Unit groups are data, so the same field handles lengths, weights, temperatures or currencies with fixed rates.
When to use
- Design tools, recipe apps, shipping forms, health inputs.
When not to use
- Fixed-unit fields.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/unit-input.json$
npx shadcn@latest add https://sundaeui.dev/r/unit-input.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/unit-input.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/unit-input.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy unit-input.tsx and unit-input.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { UnitInput } from '@/components/sundae/unit-input'
<UnitInput />Props
| Prop | Type | Default |
|---|---|---|
units*Units in a group. | { id: string; label: string; toBase: (n) => number; fromBase: (n) => number }[] | — |
defaultValueInitial value in the first unit. | number | — |
onValueChangeCalled on change. | (value: number, unit: string) => void | — |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- The value is a spinbutton and the unit a native select, both labelled.
- Conversions are announced.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-uni; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
Keyboard
- ↑ / ↓
- Step
- Shift + ↑ / ↓
- Step ×10
FAQ
- Is Unit Input free?
- Yes. Unit Input is free for personal and commercial projects, with full source on this page.
- How do I install Unit Input?
- Run npx shadcn@latest add https://sundaeui.dev/r/unit-input.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Unit Input support keyboard and screen readers?
- The value is a spinbutton and the unit a native select, both labelled. Conversions are announced.
- Does Unit Input respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All forms →OTP Field
Forms · RefinedRolling Counter
Forms · Playful- Pro
Wheel Picker
Forms · Refined Password Strength
Forms · Refined- Pro
Number Scrubber
Forms · Refined Phone Input
Forms · Refined