Skip to content
Components, switch product

One-time code

<gui-otp> is a one-time code field, for the digits of a verification code sent by text message or email. It draws one box per digit, with its label, hint and errors.

Code of the example above
OtpBasic.tsx
import { GuiOtp } from '@golemui/gui-components/react';
import '@golemui/gui-components/index.css';
export function OtpBasic() {
return <GuiOtp label="Verification code" />;
}

The boxes only draw the code. Under them is one native text input, so typing, pasting, autofill, password managers and screen readers work as with any text field. The input has autocomplete="one-time-code", so the browser can offer a code it received by text message or email.

The digits fill in order. The caret stays after the last digit, wherever the user clicks, and Backspace removes the last digit. Select the whole code to replace or delete it at once. Anything that isn’t a digit is dropped, so a pasted 123-456 becomes 123456, and digits past the code’s length are dropped too. The code runs left to right, like a phone number, also in a right-to-left page.

The element’s entry point is @golemui/gui-components/otp, which registers <gui-otp> and nothing else. To install the package and load the stylesheet, see Installation.

The @golemui/gui-components/react components have typed props and an on* prop for each event. React 18 or later.

CodeField.tsx
import { useState } from 'react';
import { GuiOtp } from '@golemui/gui-components/react';
import '@golemui/gui-components/index.css';
export function CodeField() {
const [code, setCode] = useState('');
return (
<GuiOtp
label="Verification code"
name="code"
value={code}
onGuiChange={(event) => setCode(event.detail.value ?? '')}
/>
);
}

The value is the digits entered so far, as text, so a code can start with a zero. gui-input fires on every digit, and gui-change once the code is complete.

Properties

Property Type Default Description
value string – The digits entered so far. Complete when it has length of them.
length number 6 How many digits the code has. Defaults to 6.
groups number[] – Splits the boxes into groups with a gap between them, such as [3, 3] for 123 456. The sizes must add up to length. It only changes how the code looks, never the value.
incompleteMessage attribute incomplete-message string – The error for a code with fewer than length digits. Defaults to "Enter all N digits."
uid string – The id of the element's control. The ids of its label, hint and errors derive from it. Optional: when it is not set, the element generates one from its tag name and a page-wide counter, e.g. gui-textinput-3. Generated ids depend on creation order, so a server-rendered element must set uid or its ids will not match after hydration.

Form control properties

Every field has these, from the base class of the form controls.

Property Type Default Description
name string – The name the value is submitted under. Without it the element stays out of its form. Reflected, like a native control's: the form reads a single value's name from the attribute.
label string – The visible label, which also names the control for assistive technology. Without one the control has no accessible name; the date, time, range, tags and file fields warn about it in development builds.
hint string – Help text shown under the label and announced as the control's description.
errors string[] [] Error messages to show, typically from your own validation. They show as soon as they are set, unless touched is false.
touched boolean – Whether the user has interacted with the control. Leave it unset unless you validate on interaction: false holds errors back until it becomes true.
required boolean false The control needs a value: it is marked as required and, with a name, blocks submission.
readOnly attribute readonly boolean – The value can be read and focused but not changed.
disabled boolean – Disabled by its own attribute or by a disabled <fieldset> around it. Setting the property sets the attribute, like a native control's.
form read-only, no attribute HTMLFormElement | null – The <form> the element belongs to, if any.
validity read-only, no attribute ValidityState – The element's validity, like a native control's.
validationMessage read-only, no attribute string – The message the browser shows for an invalid value.

Methods

Method Description
checkValidity(): boolean –
reportValidity(): boolean –

Events

Every event bubbles and is composed, so you can listen on any ancestor.

Event Description
gui-input The user typed, pasted or removed a digit. detail.value is the code so far.
gui-change The code is complete, or focus left after a change. detail.value is the code.
gui-blur Focus left the control.

length sets the number of digits, 6 by default. groups splits the boxes into groups with a short line between them: groups="3,3" draws a 6-digit code as 3 and 3. From JavaScript, set it to an array such as [3, 3].

Code of the example above
OtpLengthGroups.tsx
import { GuiOtp } from '@golemui/gui-components/react';
import '@golemui/gui-components/index.css';
export function OtpLengthGroups() {
return (
<div className="gui-grid gui-grid--row">
<div className="gui-grid__cell">
<GuiOtp label="PIN" length={4} />
</div>
<div className="gui-grid__cell">
<GuiOtp label="Verification code" length={6} groups={[3, 3]} />
</div>
</div>
);
}

Groups only change how the code looks, never the value: the code above is still 123456. Sizes that don’t add up to length are ignored, and the boxes stay in one group, with a warning in the console of a development build. A length that isn’t a positive whole number falls back to 6.

gui-input fires on every digit the user types, pastes or removes, and event.detail.value is the code so far. gui-change fires once the code is complete, so you can check it without a submit button. Type the six digits below.

Enter the 6 digits.

Code of the example above
OtpEvents.tsx
import { useState } from 'react';
import { GuiOtp } from '@golemui/gui-components/react';
import '@golemui/gui-components/index.css';
export function OtpEvents() {
const [status, setStatus] = useState('Enter the 6 digits.');
return (
<>
<GuiOtp
id="sms-code"
label="Code from the text message"
onGuiInput={(event) => setStatus(`${event.detail.value?.length ?? 0} of 6 digits.`)}
onGuiChange={(event) => {
const value = event.detail.value ?? '';
if (value.length === 6) setStatus(`Checking ${value}…`);
}}
/>
<p id="sms-status" role="status">
{status}
</p>
</>
);
}

gui-change also fires when focus leaves after a change, or on Enter, with the code as it is, so check its length before you send it. It reports a complete code once: if the user removes the last digit and types the same one again, it doesn’t fire again.

A disabled code can’t be focused or changed, and a form doesn’t submit it. A readonly code can be focused but not changed, and a form submits it.

Code of the example above
OtpDisabledReadonly.tsx
import { GuiOtp } from '@golemui/gui-components/react';
import '@golemui/gui-components/index.css';
export function OtpDisabledReadonly() {
return (
<div className="gui-grid gui-grid--row">
<div className="gui-grid__cell">
<GuiOtp label="Verification code" value="482913" disabled />
</div>
<div className="gui-grid__cell">
<GuiOtp label="Recovery code" value="730148" readOnly />
</div>
</div>
);
}

With a name, <gui-otp> submits its digits as text under that name, leading zeros included. An empty code submits nothing. These block a submit:

  • required, while the code is empty;
  • a code with fewer than length digits, with or without required, with the message “Enter all 6 digits.”;
  • a value set from your code with anything but digits, or with more than length of them, such as "12a456" or "1234567", with the message “Enter a 6-digit code.”. The element keeps the value as it is, and the boxes show its first digits. Typing and pasting can’t make such a code.

incomplete-message replaces the first message on one element. To translate the messages for every element, use the keys incompleteCode and invalidCode, where {length} stands for the number of digits; see i18n. Submit the form below empty, then with three digits, then with all six.

Code of the example above
OtpNativeForm.tsx
import { useState, type SubmitEvent } from 'react';
import { GuiButton, GuiOtp } from '@golemui/gui-components/react';
import '@golemui/gui-components/index.css';
export function OtpNativeForm() {
const [result, setResult] = useState('');
const onSubmit = (event: SubmitEvent<HTMLFormElement>) => {
event.preventDefault();
const data = Object.fromEntries(new FormData(event.currentTarget));
setResult(`Submitted ${JSON.stringify(data)}`);
};
return (
<form id="verify" onSubmit={onSubmit}>
<div className="gui-grid gui-grid--row">
<div className="gui-grid__cell gui-grid__cell--span-3">
<GuiOtp
label="Verification code"
name="code"
required
incompleteMessage="Enter the 6-digit code from the email."
/>
</div>
<div className="gui-grid__cell">
<GuiButton type="submit" label="Verify" />
</div>
</div>
<p id="verify-result" role="status">
{result}
</p>
</form>
);
}

Enter in the code submits the form, as in any text field. How the elements submit, validate and reset is in Native forms.

<gui-otp> renders into the light DOM, so you can style every part with plain CSS. The boxes and the input share one wrapper, and the input lies over the boxes:

gui-otp
<gui-otp class="gui-field">
<label class="gui-label" for="code" id="code_label">
<span class="gui-label__text"
>Verification code<span aria-hidden="true"> *</span></span
>
<div class="gui-widget-hint" id="code_hint" aria-hidden="true">
We sent it to your phone.
</div>
</label>
<div class="gui-widget">
<div class="gui-otp__control" dir="ltr">
<div class="gui-otp__boxes" aria-hidden="true">
<span class="gui-otp__group">
<span class="gui-otp__box gui-otp__box--filled">1</span>
<span class="gui-otp__box gui-otp__box--filled">2</span>
<span class="gui-otp__box gui-otp__box--active"></span>
</span>
<span class="gui-otp__separator"></span>
<span class="gui-otp__group">
<span class="gui-otp__box"></span>
<span class="gui-otp__box"></span>
<span class="gui-otp__box"></span>
</span>
</div>
<input
type="text"
class="gui-otp__input"
inputmode="numeric"
autocomplete="one-time-code"
autocapitalize="off"
autocorrect="off"
spellcheck="false"
id="code"
aria-describedby="code_hint"
aria-invalid="true"
aria-errormessage="code_errors"
aria-required="true"
/>
</div>
</div>
<div class="gui-validator" id="code_errors" role="alert">
<div class="gui-validator__error">That code has expired.</div>
</div>
</gui-otp>
ClassPart
gui-fieldThe element itself. Lines its parts up with the fields next to it in a gui-grid row.
gui-labelThe label, with the text and the hint.
gui-label__textThe label text and the required marker.
gui-widget-hintThe hint.
gui-widgetThe box around the control.
gui-otp__controlThe boxes and the input. It is always left to right, and as wide as its boxes.
gui-otp__boxesThe boxes, hidden from assistive technology.
gui-otp__groupOne group of boxes. Without groups, all the boxes are in one group.
gui-otp__separatorThe line between two groups.
gui-otp__boxOne box, with its digit.
gui-otp__box--filledOn a box that has a digit.
gui-otp__box--activeOn the box the next digit goes in, or the last box when the code is complete. It shows the focus ring.
gui-otp__inputThe native <input>, over the boxes. Its text, caret and selection are transparent.
gui-validatorThe errors container. It is always there, so the alert role announces new errors.
gui-validator--emptyOn the errors container while there are no errors.
gui-validator__errorOne error message.

While the input has focus, the active box shows a blinking caret until it has a digit. The ids derive from uid, or from a generated id such as gui-otp-3 when you don’t set one. Select by class or by tag, not by id.

The field reads these design tokens. Set them on :root to change every code, or on any element to change the codes inside it:

TokenWhat it styles
--gui-otp-box-sizeThe width of each box. Unset by default, so the boxes are as wide as a field is tall.
--gui-widget-sizeThe height of the boxes, and their width without --gui-otp-box-size.
--gui-radius-mdThe corner radius of the boxes.
--gui-bg-surfaceThe boxes’ background.
--gui-border-strongThe boxes’ border, and the line between groups.
--gui-text-defaultThe digits, the caret and the label.
--gui-font-lgThe size of the digits.
--gui-font-familyThe font.
--gui-font-smThe text size of the label.
--gui-font-xsThe text size of the hint and the errors.
--gui-text-secondaryThe hint.
--gui-space-2The space between the boxes, and the length of the line between groups.
--gui-border-focusThe border of the active box while focused.
--gui-shadow-focusThe focus ring of the active box.
--gui-intent-errorThe boxes’ border and the messages when there are errors.
--gui-shadow-focus-errorThe focus ring when there are errors.
--gui-bg-disabledThe boxes’ background when disabled.
--gui-text-disabledThe digits when disabled.
--gui-transition-normalThe speed of the border and focus ring changes.
--gui-space-1The space between the label, hint and errors.

In a narrow container the boxes shrink, down to 1.75rem each.

styles.css
/* Wider boxes for every code. */
:root {
--gui-otp-box-size: 3rem;
}

To restyle beyond the tokens, see Overriding styles; for dark mode and the Clay theme, see Themes and dark mode.

  • One field. Screen readers meet one text input that holds the whole code, not a field per digit. The boxes are hidden from assistive technology.
  • Name. The label names the input. Without one the field has no accessible name.
  • Description. The hint is the field’s description, through aria-describedby.
  • Errors. While errors show, the input has aria-invalid="true" and points at them with aria-errormessage, and screen readers announce new errors as they appear. An incomplete code doesn’t set it: only errors does.
  • Required and read-only. required sets aria-required and readonly sets aria-readonly. A read-only code stays in the tab order.
  • Keyboard on phones. inputmode="numeric" opens a number keypad.
  • Motion. With reduced motion, the caret doesn’t blink and the borders change without a transition.
KeyAction
TabMoves focus to the code, and away from it.
Number keysType the next digit and fire gui-input. The last digit also fires gui-change.
BackspaceRemoves the last digit, or the whole code when it is selected.
←, →, ↑, ↓, Home, EndDo nothing: the caret stays after the last digit.
EnterCommits the code, as leaving the field does, and fires gui-change. In a form, it also submits the form.

What every element shares, such as translating the browser messages, is in Accessibility.