OTP Field
Six identical surfaces that cost one, because the map cache is keyed on size and optics.
Installation
npx shadcn@latest add https://liqui.design/r/otp-field.jsonInstalling more than one? Register the namespace once in components.json and
drop the URLs:
{
"registries": {
"@liqui-design": "https://liqui.design/r/{name}.json"
}
}npx shadcn@latest add @liqui-design/otp-fieldUsage
import { OTPField, OTPFieldSlots } from '@/components/ui/otp-field';<OTPField id={id} length={6} className="flex items-center gap-2">
<OTPFieldSlots length={6} />
</OTPField>length is required on the root — Base UI needs it to clamp values and detect
completion before the slots have hydrated. Label it with a native
<label htmlFor>: the root's id lands on the first slot, and the rest derive
theirs from it.
Grouped
<OTPField length={6} mask>
<OTPFieldSlot />
<OTPFieldSlot className="ml-2" />
<OTPFieldSlot className="ml-2" />
<OTPFieldSeparator />
<OTPFieldSlot />
…
</OTPField>Notes
Six surfaces, one map
The displacement map cache is keyed on the surface's size and its optics, and every slot here is the same box at the same numbers. So a six-digit code generates one map and reuses it six times; a twelve-digit one still generates one.
This is the cheapest row of glass in the library and it looks like the most
expensive, which is worth knowing because the instinct — "that is a lot of
lenses, soften them" — is exactly backwards. If you want to spend something here,
spend it on refraction; you are paying for one map either way.
The one way to break it is to make the slots differ. A wider last slot, a
glass override on the focused one, a first slot with a different radius: each
distinct size is a distinct map, and a 48-entry cache spent on one input is a
cache the rest of the page has lost.
A filled slot retints
'has-[input[data-filled]]:[--lq-tint:color-mix(in_srgb,var(--lq-accent)_22%,var(--lq-tint))]';Rather than painting a background, which is one of the rules that are specific to glass: a background covers the refraction, while overriding the token keeps the lens working through the colour. The result is a row of lit windows where the entered digits are, each one still bending the backdrop behind it.
It is Checkbox's move at a much larger size, and the accent is deliberately weaker — 22% mixed into the existing tint rather than 88% replacing it. A checkbox is either on or off and the fill is the state; a filled OTP slot already shows its state as a digit, and a full-strength accent would say "selected" about something that is merely typed in.
The input cannot be the surface
Each slot is a wrapper around a transparent <input>, not an input wearing
glass, because an <input> is a replaced element with nowhere to put the
backdrop, tint and specular layers. That is
Input's constraint,
six times over — and it is why focus and filled state are read with has-*: the
element with the state is a child of the element with the material.
The separator is cut short
'mx-1 h-4 w-px self-center';A full-height rule between two slots reads as a seventh slot with nothing in it. Shortening it to the height of a slot's middle makes it a gap marker instead — Separator's incision, dim wall and lit wall, scaled to the job.