There is no theme to extend and no class names to override. The field is a row of elements you wrote, and the library's only job is telling you which of them is active, which has a character, and where a caret belongs.
The two class names#
This is the single most common source of confusion, so it's worth being blunt about it: containerClassName styles the wrapper you can see; className goes to the invisible input.
<OTPInput
// the container that wraps your slots
containerClassName="group flex items-center gap-2"
// the real, invisible input
className="focus-visible:ring-0"
/>Almost all of your styling belongs on the container. Reach for className only when you need to change the input itself — most often to cancel a global focus ring that your CSS reset applies to every input.
Anatomy of a slot#
A slot needs to answer four questions, and it gets all four handed to it:
- What's in me?
char ?? placeholderChar. - Am I the one being edited?
isActive— draw the ring, raise thez-indexso it isn't clipped by the next slot's border. - Should I blink?
hasFakeCaret, which is only true for an active slot with nothing in it. - Is the whole field off? Read it from the input with
has-[:disabled]on the container.
The fake caret#
The real caret is caret-color: transparent, because a native caret in a field with collapsed letter-spacing lands nowhere useful. So you draw one: an absolutely positioned bar that blinks.
/* the keyframe the docs' FakeCaret uses */
@keyframes caret-blink {
0%, 70%, 100% { opacity: 1 }
20%, 50% { opacity: 0 }
}Wrap the animation in motion-safe: — a blinking caret is exactly the kind of thing prefers-reduced-motion exists for.
Placeholders#
placeholderChar is only non-null while the value is empty, so the placeholder disappears as a unit on the first keystroke rather than dissolving one slot at a time.
/* Dim placeholder characters without threading a prop:
the attribute lives on the input, so read it from the container's group. */
<div className="group-has-[input[data-input-otp-placeholder-shown]]:text-muted-foreground/40">
{char ?? placeholderChar}
</div>Groups and separators#
slots is a plain array, so grouping is slice. Nothing in the library knows or cares that there is a dash in the middle — which also means a separator must be aria-hidden, since the value it sits inside has no dash in it.
Composition instead of a callback#
If you would rather write <Slot index={0} /> than map over an array — because your design system wants named parts, or because a wrapper component sits between the field and its slots — drop the render prop and read OTPInputContext.
Field-level state#
Focus rings usually want to sit on the container, not the slot. You can drive that from CSS with has-[:focus-visible], or from JS with the isFocused and isHovering render props:
<OTPInput
maxLength={6}
containerClassName="group flex items-center rounded-lg ring-offset-2 ring-offset-background
has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring"
render={({ slots, isFocused, isHovering }) => (
// isFocused / isHovering are there when you'd rather branch in JS
…
)}
/>isHovering is already false when the field is disabled, so you don't need to guard it.
Scaling with the field#
The container carries --root-height, kept in sync with the input's pixel height by a ResizeObserver. It exists so the library can size the invisible text, but nothing stops you using it:
/* The library writes the input's height here on every resize.
Use it if you want something to scale with the field. */
[data-input-otp-container] {
--root-height: 56px; /* written by a ResizeObserver */
}Two more looks#
Both of these use the exact same component and the exact same state — only the markup differs. More in Examples.
Underlined#
Keycaps#
What the library styles#
For completeness: the library appends one <style id="input-otp-style"> to head, once per page. Every rule in it is scoped to [data-input-otp] and exists to make the invisible input behave — transparent selection, neutralised autofill, iOS text metrics, and a pointer-events exception so a password manager badge stays clickable. None of it touches your slots. Edge cases walks through each rule and why it is there.