Mediquo documentation home

OTP input

The field a patient types a WhatsApp, SMS, or email verification code into.

mq-otp-input renders one box per character of a one-time code. It ships from @mediquo/ui as a custom element and as a React component.

Install

// Custom element — the import registers <mq-otp-input>
import "@mediquo/ui/components/otp-input";
// React
import { OtpInput } from "@mediquo/ui/react/otp-input";

It renders the control and nothing else

No label, no hint, no error text. Those belong to the field wrapper the integrator already has — in the patient portal, FieldLabel, FieldDescription and FieldError from features/shared/ui/components/field.

That split is deliberate. If every form primitive rendered its own label and error text, each new one would grow another copy of the same markup and the same spacing decisions. invalid here is a state, not a message: it turns the boxes red and sets aria-invalid, and the integrator says what went wrong.

<Field>
  <FieldLabel htmlFor="code">Código de verificación</FieldLabel>
  <OtpInput label="Código de verificación" invalid={isCodeError} />
  {isCodeError ? <FieldError>El código no es correcto.</FieldError> : null}
</Field>

label is the one string it does take, because the real input lives in a shadow root where an outer <label for> cannot reach it. It sets aria-label and renders no text.

How it is built

The visible boxes are decoration. Underneath them sits a single real <input> carrying the whole code, transparent and stretched over the boxes.

That is a deliberate choice, not an implementation detail:

  • One input per digit announces as six unrelated text fields. One input announces as one labelled field, which is what it is.
  • autocomplete="one-time-code" only autofills a field that holds the whole code, so iOS and Android can offer the code straight from the SMS.
  • Pasting, caret movement, Backspace, Home, and End are the browser's native text-box behaviour rather than reimplemented key handling.

Accessibility

  • label is the accessible name and there is no visible <label>, so it is effectively required — the component warns in the console when it is missing.
  • The boxes are aria-hidden; only the real input is exposed.
  • invalid sets aria-invalid. Pair it with your field wrapper's error text so the failure is never communicated by colour alone.
  • required sets aria-required.
  • The caret blink and the box transitions are dropped under prefers-reduced-motion: reduce.

Sizing

Sizes and look follow the patient portal's verify-code screen: no border, a filled box, a 1px ring for the active and invalid states, and boxes that stretch to fill their container and grow at the md breakpoint (60px tall below it, 80px from it).

Because the boxes stretch, the width comes from the parent — give it the width you want the row to have.

Styling

The component uses --mq-* tokens with literal fallbacks, so it renders correctly with or without @mediquo/ui/tokens.css loaded. These custom properties are the supported override points:

mq-otp-input {
  --mq-otp-slot-height: 3.75rem;
  --mq-otp-slot-height-md: 5rem;
  --mq-otp-slot-max-width: 2.75rem;
  --mq-otp-slot-max-width-md: 65px;
  --mq-otp-slot-radius: 0.75rem;
  --mq-otp-slot-background: #f8f7fa;
  --mq-otp-slot-active-ring-color: #6500cc;
  --mq-otp-slot-invalid-ring-color: #ff014d;
  --mq-otp-slot-selected-background: #f5e5ff;
  --mq-otp-slot-font-size: 1.25rem;
  --mq-otp-slot-font-size-md: 1.5rem;
  --mq-otp-gap: 0.25rem;
  --mq-otp-separator-width: 0.5rem;
  --mq-otp-separator-color: #d1ccdc;
  --mq-otp-caret-color: #0a0a23;
  --mq-otp-caret-height: 1.75rem;
}

part is exposed for root, input, group, slot, separator, and caret; filled and active boxes also carry slot-filled and slot-active.

Behaviour worth knowing

  • Paste replaces the whole code. A pasted string is the code, so it does not splice in at the caret. Separators and spaces are stripped.
  • Typing over a character replaces it instead of pushing the rest along and dropping the last one.
  • Clicking pins the caret after the last character, the way a phone's native code field does, because the real glyphs are collapsed and a click cannot land the caret anywhere meaningful.
  • mask is visual only. The value is still readable by assistive tech, which is correct for a verification code — it is not a password.