Styling

Slots, fake carets, placeholders, groups and separators — plus the data attributes that let CSS do the work.

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 the z-index so 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.

0
0
0
0
0
0
/* 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.