Skip to content

[otp field] Add OTP Field component#4365

Open
atomiks wants to merge 46 commits intomui:masterfrom
atomiks:codex/otp-field
Open

[otp field] Add OTP Field component#4365
atomiks wants to merge 46 commits intomui:masterfrom
atomiks:codex/otp-field

Conversation

@atomiks
Copy link
Copy Markdown
Contributor

@atomiks atomiks commented Mar 18, 2026

Closes #75

Preview: https://deploy-preview-4365--base-ui.netlify.app/react/components/otp-field

Summary

This adds a preview OTP Field component with Root and Input parts, along with docs, hero demos, tests, and generated API reference.

API note: length is required on OTPField.Root as the source of truth for the intended OTP size. The component uses it for validation, hidden-input form integration, completion state, and deterministic SSR or initial render behavior, while CompositeList is used only for slot order and indexing.

@atomiks atomiks added docs Improvements or additions to the documentation. type: new feature Expand the scope of the product to solve a new problem. component: otp field labels Mar 18, 2026
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new bot commented Mar 18, 2026

commit: 5587c70

@mui-bot
Copy link
Copy Markdown

mui-bot commented Mar 18, 2026

Bundle size report

Bundle Parsed size Gzip size
@base-ui/react 🔺+7.79KB(+1.71%) 🔺+2.67KB(+1.84%)

Details of bundle changes


Check out the code infra dashboard for more information about this PR.

@netlify
Copy link
Copy Markdown

netlify bot commented Mar 18, 2026

Deploy Preview for base-ui ready!

Name Link
🔨 Latest commit 5587c70
🔍 Latest deploy log https://app.netlify.com/projects/base-ui/deploys/69d77c5eb0250600087f283c
😎 Deploy Preview https://deploy-preview-4365--base-ui.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@atomiks atomiks removed the docs Improvements or additions to the documentation. label Mar 18, 2026
@atomiks atomiks force-pushed the codex/otp-field branch 8 times, most recently from d335946 to d913c3c Compare March 21, 2026 03:01
@github-actions github-actions bot added the PR: out-of-date The pull request has merge conflicts and can't be merged. label Mar 23, 2026
@atomiks
Copy link
Copy Markdown
Contributor Author

atomiks commented Mar 24, 2026

Codex Review (GPT-5.4)

This remains a new-component PR with secondary tags public-api, docs, tests, and a11y. I re-reviewed the full branch against a freshly fetched upstream/master and reran pnpm test:jsdom OTPField --no-watch, pnpm test:chromium OTPField --no-watch, pnpm typescript, and pnpm eslint on the current head.

1. Remaining Bugs / Issues (None)

I do not currently see any remaining actionable correctness or API issues on the branch head.

2. Public API & Composition Assessment

The public shape is now much cleaner than the original branch state. Making OTPField.Root the grouping element, keeping length as the single source of truth, exposing a single-string value, and leaving forwarded aria-* on the root gives the component a more coherent long-term contract than the earlier Root + Group split.

3. Accessibility and Interaction Assessment

The interaction model looks solid at this point: canceled changes no longer move focus, forward delete reselects the shifted value, invalid rejected input preserves selection, readonly slots still participate in focus tracking and keyboard navigation, and the root/label wiring is internally consistent. For a preview component, the keyboard and focus behavior now looks well covered.

4. Previous Review Issues Status

Prior review item Status Notes
Canceled changes still moved focus Resolved Focus/completion side effects now flush only against committed values
Custom validation needed better flexibility Resolved inputMode and onValueInvalid are both present and documented
Group made the public structure heavier than necessary Resolved Root now owns the group semantics and the docs/tests match that model
Generated OTP reference JSON was unnecessary noise Resolved The OTP docs now rely on types.md only
Readonly slots lost Arrow/Home/End navigation Resolved Navigation keys now still work while readonly editing remains blocked

Merge Confidence Score

Overall merge confidence is 5/5. I do not see remaining blockers, and the branch now has strong coverage for the keyboard, focus, validation, docs, and form-integration paths that carry most of the risk for this component.

@github-actions github-actions bot removed the PR: out-of-date The pull request has merge conflicts and can't be merged. label Mar 24, 2026
@atomiks atomiks marked this pull request as ready for review March 24, 2026 23:20
Copy link
Copy Markdown
Member

@flaviendelangle flaviendelangle left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll look at the DX more in depth later

Code Review: [otp field] Add OTP Field component (#4365)

Author: atomiks | +4,109 / -1 | 44 files


Overview

This PR adds a new OTPField component (marked as Preview) with Root, Group, and Input parts. It includes full documentation (hero, alphanumeric, password, custom-sanitize demos), comprehensive tests (~1,240 lines), type-safety spec tests, SSR support, hidden validation input for native form integration, and Field/Form compatibility. The length prop on Root is the source of truth, while CompositeList handles slot ordering.


Strengths

  • Excellent test coverage - 912 lines of root tests + 328 lines of input tests covering value handling, controlled/uncontrolled modes, async state reconciliation, cancel semantics, keyboard interactions, pasting, SSR, Field integration, form submission, and data attributes.
  • Well-designed API - Discriminated union event details (ChangeEventDetails, InvalidEventDetails, CompleteEventDetails) with proper TypeScript narrowing, verified by spec tests.
  • Solid controlled mode handling - The pendingFocusRef / pendingCompleteValueRef pattern correctly handles async controlled updates (e.g., onValueChange with setTimeout), with tests for stale vs. accepted completions.
  • Good accessibility - role="group", aria-labelledby / aria-describedby propagation through Field and native labels, per-slot aria-label, enterKeyHint based on slot position.
  • Follows project conventions - Uses existing utilities (useStableCallback, useControlled, CompositeList, useField, useRenderElement), proper data attributes, standard file structure, error code registered.

Issues & Suggestions

1. ReadOnly fields skip focus tracking (bug)

In OTPFieldInput.tsx:221-223, onFocus returns early when readOnly:

onFocus(event) {
  if (event.defaultPrevented || disabled || readOnly) {
    return;
  }

This means data-focused is never set on readonly fields, and handleInputFocus never runs. Readonly fields should still be focusable and trackable - only editing should be blocked. The disabled guard is correct (disabled inputs can't focus natively), but readOnly should be removed here. Same for onMouseDown - the event.preventDefault() and focusInput call should still work for readonly (they just select the text).

2. Native contains instead of shadow DOM-safe utility

In OTPFieldRoot.tsx:283-284:

if (nextTarget instanceof Node && rootRef.current?.contains(nextTarget)) {

Per CLAUDE.md, the shadow DOM-safe contains utility should be used instead of the native .contains() method.

3. Double clipboard read in onPaste

In OTPFieldInput.tsx:259-264, event.clipboardData.getData('text/plain') is called twice. Store it in a variable:

const rawValue = event.clipboardData.getData('text/plain');
const nextDigits = normalizeOTPValue(rawValue, length, validationType, sanitizeValue);
const didSanitize = stripOTPWhitespace(rawValue).length > nextDigits.length;

4. Demo useInvalidFeedback uses raw setTimeout

useInvalidFeedback.ts:42-47 uses setTimeout / clearTimeout directly. CLAUDE.md recommends useTimeout from @base-ui/utils/useTimeout. This is demo code so it's less critical, but it's shown to users as a reference.

5. Missing test coverage for alpha validation on typing

The alpha validationType is tested via defaultValue splitting but there's no test for typing/pasting into an alpha-mode field.


Minor Observations

  • Significant CSS duplication across demo CSS modules (hero, alphanumeric, password share ~80% identical styles) - fine for demo independence.
  • {1} quantifier in slot patterns (e.g. \\d{1}) is technically redundant but keeps consistency with the root pattern.
  • The onValueChange JSDoc in types.md is missing line breaks between the bullet points for reason descriptions (renders as a run-on sentence in the generated table).

Rating: 4/5

This is a high-quality, well-architected component addition with excellent test coverage and thoughtful API design. The readonly focus tracking bug (#1) and missing shadow DOM-safe contains (#2) are the only issues that should be addressed before merge. Everything else is polish.

Copy link
Copy Markdown
Member

@aarongarciah aarongarciah left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did a quick pass, leaving some feedback/questions before going deeper:

  • I'm not quite sure I follow the logic that decides when the text is selected or not. Can you explain the reasoning behind when it should be selected or not? Is it useful to auto-select text on inputs? I see other OTP fields in the wild that don't do this and maybe they feel more natural. But I need to play more with them before having a strong opinion.

    Kapture.2026-03-26.at.16.39.52.mp4
  • In the video above, there's a moment when I press numbers inside an input but nothing happens. Does it happen because the text inside the input is not selected? It felt surprising.

  • What's the use case for OTPField.Group? Is there a scenario where several of them would be used? If that's the case, a demo would be nice.

@atomiks
Copy link
Copy Markdown
Contributor Author

atomiks commented Mar 26, 2026

I'm not quite sure I follow the logic that decides when the text is selected or not

It should always be selected if you navigate via keyboard or click into an already-filled input. I actually can't get into the state you got in in the recording when pressing Backspace - it's always selected in that case 🤔

In the video above, there's a moment when I press numbers inside an input but nothing happens. Does it happen because the text inside the input is not selected? It felt surprising.

I can't get into the state where this happens, do you have the exact steps/keys?

What's the use case for OTPField.Group? Is there a scenario where several of them would be used? If that's the case, a demo would be nice.

It's mainly to match NumberField which has Root and Group parts. It's possibly not as necessary as NumberField since there's no ScrubArea part, so the Root itself could be role="group". This would likely be less confusing regarding how ARIA roles get forwarded as well.

@aarongarciah
Copy link
Copy Markdown
Member

It should always be selected if you navigate via keyboard or click into an already-filled input.

So is it expected that the last input gets selected when filling it?

Kapture.2026-03-26.at.22.57.41.mp4

I actually can't get into the state you got in in the recording when pressing Backspace - it's always selected in that case
I can't get into the state where this happens, do you have the exact steps/keys?

You can see the exact keys being pressed in the video, but I can reproduce it consistently with this sequence:

1, 2, 3, 4, 5, 6, ⬅️, Del: at this point the selection is lost and pressing any number doesn't have an effect.

It's mainly to match NumberField which has Root and Group parts. It's possibly not as necessary as NumberField since there's no ScrubArea part, so the Root itself could be role="group". This would likely be less confusing regarding how ARIA roles get forwarded as well.

Sounds like it's worth considering removing it.

@atomiks
Copy link
Copy Markdown
Contributor Author

atomiks commented Apr 9, 2026

  1. Silent clipboard paste failure — bare catch block

Fixed

  1. autoSubmit silently does nothing when no form is found

This one too noisy, if you want autoSubmit unconditionally specified in a wrapped component API, even if it's not in a form

Test Coverage Gaps

Fixed

Comment Improvements

Fixed

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

component: otp field type: new feature Expand the scope of the product to solve a new problem.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[otp] Add a OtpInput or PinInput component

8 participants