Skip to content

feat(headless): add headless OTP input primitive#9079

Merged
alexcarpenter merged 6 commits into
mainfrom
headless-otp-input
Jul 2, 2026
Merged

feat(headless): add headless OTP input primitive#9079
alexcarpenter merged 6 commits into
mainfrom
headless-otp-input

Conversation

@alexcarpenter

@alexcarpenter alexcarpenter commented Jul 2, 2026

Copy link
Copy Markdown
Member

Description

Adds a new headless one-time-password / PIN primitive at @clerk/headless/otp, following the existing headless patterns (compound OTP.Root + OTP.Input parts, a useOTP hook, renderElement/mergeProps, useControllableState, and data-cl-* state attributes with zero styles). It uses one real <input> per character (base-ui OTPField inspired) so each slot is individually styleable, and handles typing/focus advance, Backspace/Delete, arrow/Home/End navigation, paste distribution, a pattern character filter, masking, disabled, onComplete, and optional <form> submission via name. Also adds Swingset docs (story + MDX overview, wired into the registry and docs viewer) and a primitive README. Test it via pnpm --filter @clerk/headless test (19 OTP tests incl. axe) or interactively in Swingset (pnpm dev:swingset).

Checklist

  • pnpm test runs as expected.
  • pnpm build runs as expected.
  • (If applicable) JSDoc comments have been added or updated for any package exports
  • (If applicable) Documentation has been updated

Type of change

  • 🐛 Bug fix
  • 🌟 New feature
  • 🔨 Breaking change
  • 📖 Refactoring / dependency upgrade / documentation
  • other:

Summary by CodeRabbit

  • New Features
    • Added a slot-based headless OTP (one-time passcode/PIN) primitive with roving focus, keyboard navigation (including RTL), paste distribution, optional masking/pattern validation, and optional form submission via a hidden field.
    • Exposed it via the new public import path: @clerk/headless/otp.
  • Documentation
    • Updated the headless README primitives list and added dedicated OTP primitive docs, including accessibility and styling guidance.
    • Added Storybook + MDX story content and registered it in the docs viewer.
  • Tests
    • Added a comprehensive test suite covering interaction, completion, controlled/disabled behavior, form integration, and accessibility.

@changeset-bot

changeset-bot Bot commented Jul 2, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: d340dce

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 0 packages

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel

vercel Bot commented Jul 2, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
clerk-js-sandbox Ready Ready Preview, Comment Jul 2, 2026 8:11pm
swingset Ready Ready Preview, Comment Jul 2, 2026 8:11pm

Request Review

@coderabbitai

coderabbitai Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository YAML (base), Repository UI (inherited)

Review profile: CHILL

Plan: Pro Plus

Run ID: 1905828e-9158-4e25-9938-09d1d07cc830

📥 Commits

Reviewing files that changed from the base of the PR and between 3baf295 and d340dce.

📒 Files selected for processing (2)
  • packages/headless/src/primitives/otp/otp-input.tsx
  • packages/headless/src/primitives/otp/otp.test.tsx
🚧 Files skipped from review as they are similar to previous changes (2)
  • packages/headless/src/primitives/otp/otp.test.tsx
  • packages/headless/src/primitives/otp/otp-input.tsx

📝 Walkthrough

Walkthrough

This PR adds a new headless OTP primitive, with its core components, public exports, build and docs wiring, and a test suite covering input, navigation, form, and accessibility behavior.

Changes

Headless OTP Primitive

Layer / File(s) Summary
OTP sanitization and string utilities
packages/headless/src/primitives/otp/otp-utils.ts
Adds OtpPattern, inputModeForPattern, sanitize, replaceAt, and removeAt.
OTP context and hooks
packages/headless/src/primitives/otp/otp-context.ts
Adds OtpSlot, OtpContextValue, OtpContext, useOtpContext, and useOtp().
OTPRoot component
packages/headless/src/primitives/otp/otp-root.tsx
Implements OTP state, slot memoization, completion handling, and optional hidden form submission.
OTPInput slot component
packages/headless/src/primitives/otp/otp-input.tsx
Implements per-slot input behavior, keyboard navigation, paste handling, masking, and slot state attributes.
Public exports and build wiring
packages/headless/src/primitives/otp/parts.ts, packages/headless/src/primitives/otp/index.ts, packages/headless/package.json, packages/headless/vite.config.ts, .changeset/headless-otp.md
Adds the OTP barrel exports, package subpath export, build entry, and changeset.
OTP behavior test suite
packages/headless/src/primitives/otp/otp.test.tsx
Adds coverage for typing, navigation, paste, completion, controlled/disabled modes, form integration, hooks, render props, and accessibility.
Documentation and Storybook wiring
packages/headless/README.md, packages/headless/src/primitives/otp/README.md, packages/swingset/src/stories/otp.mdx, packages/swingset/src/stories/otp.stories.tsx, packages/swingset/src/lib/registry.ts, packages/swingset/src/components/DocsViewer.tsx
Adds OTP docs, Storybook content, registry wiring, and docs viewer registration.

Estimated code review effort: 4 (Complex) | ~60 minutes

Suggested reviewers: wobsoriano

Poem

A rabbit hops from slot to slot, 🐇
The digits land, the blanks are not.
Backspace, paste, and arrows glide,
OTP springs to life with pride.
One neat code, all snug and bright!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: adding a new headless OTP input primitive to the headless package.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
.changeset/headless-otp.md (1)

1-3: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Add a non-empty changeset entry.

This PR adds a new OTP primitive, but the changeset is still just delimiters. If packages/headless is released through Changesets, this needs a summary/bump entry so the new API shows up in the release notes. Based on learnings, empty changesets are only acceptable for docs-only PRs.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.changeset/headless-otp.md around lines 1 - 3, The changeset file is empty
and needs a real release note entry for the new OTP primitive. Update the
changeset content so it includes a non-empty summary and the appropriate bump
for the headless package, making sure the new API is captured by Changesets and
appears in release notes.

Source: Learnings

🧹 Nitpick comments (3)
packages/headless/src/primitives/otp/otp.test.tsx (2)

44-370: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Missing coverage for mask and Delete key, both called out as supported features.

The PR summary states the primitive supports masking and Backspace/Delete, but this suite only exercises Backspace (Line 139, 144) and never sets mask. Consider adding:

  • A test asserting OTP.Input renders with type="password" (or equivalent) when mask is set.
  • A {Delete} keyboard test alongside the existing Backspace coverage (Lines 129-147).
🧪 Suggested additional tests
it('masks input values when mask is set', () => {
  render(<Harness length={4} mask />);
  for (const input of inputs()) {
    expect(input).toHaveAttribute('type', 'password');
  }
});

it('deletes the current character on Delete without moving focus', async () => {
  const user = userEvent.setup();
  render(<Harness length={4} defaultValue='12' />);

  await user.click(inputs()[0]);
  await user.keyboard('{Delete}');
  expect(inputs()[0]).toHaveValue('');
  expect(inputs()[0]).toHaveFocus();
});

As per coding guidelines, "Unit tests are required for all new functionality" and "Include tests for all new features."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/headless/src/primitives/otp/otp.test.tsx` around lines 44 - 370, The
OTP test suite is missing coverage for two supported features: masking and the
Delete key behavior. Add a test around OTP.Root/OTP.Input to verify that
enabling mask causes the rendered inputs to use the masked password type, and
extend the existing keyboard navigation coverage in the Backspace/Delete section
to assert that {Delete} clears the current slot without shifting focus. Use the
existing Harness, inputs(), and keyboard navigation tests as the place to add
these cases.

Source: Coding guidelines


46-46: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Prefer RTL screen queries over raw document.querySelector for consistency.

Several assertions query via document.querySelector('[data-cl-slot="..."]') while other tests in the same file correctly use screen.getByRole/getByTestId (Lines 319, 321, 352). Since these data-cl-slot values aren't naturally targetable by role/label, consider exposing data-testid or using screen.getByRole('group')/container.querySelector scoped to container for consistency, rather than global document.querySelector.

As per coding guidelines, "Use proper test queries in React Testing Library tests."

Also applies to: 53-53, 266-266, 282-282, 289-289

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/headless/src/primitives/otp/otp.test.tsx` at line 46, The OTP tests
are using global document.querySelector for several slot assertions instead of
React Testing Library queries. Update the affected assertions in the otp test
cases to use RTL-friendly queries consistently, such as screen.getByTestId or a
scoped container query, and if needed expose stable test ids for the slot
elements so they can be targeted without querying the global document. Keep the
existing patterns in the file aligned with the other screen-based assertions.

Source: Coding guidelines

packages/headless/src/primitives/otp/otp-input.tsx (1)

85-168: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicate insertion logic between onChange and onPaste.

Both handlers compute sanitizereplaceAtsanitizesetValuequeueFocus identically. Consider extracting a shared helper to avoid drift between the two paths.

♻️ Proposed refactor
+  const applyInsertion = useCallback(
+    (rawInsert: string) => {
+      const inserted = sanitize(rawInsert, pattern, length);
+      if (inserted === '') {
+        return false;
+      }
+      const next = sanitize(replaceAt(value, index, inserted), pattern, length);
+      setValue(next);
+      queueFocus(Math.min(index + inserted.length, length - 1), next);
+      return true;
+    },
+    [pattern, length, value, index, setValue, queueFocus],
+  );
+
   onChange: (event: React.ChangeEvent<HTMLInputElement>) => {
     if (disabled) {
       return;
     }
     const raw = event.currentTarget.value;
 
     if (raw === '') {
       setValue(removeAt(value, index));
       return;
     }
 
-    const inserted = sanitize(raw, pattern, length);
-    if (inserted === '') {
-      event.currentTarget.value = char;
-      return;
-    }
-
-    const next = sanitize(replaceAt(value, index, inserted), pattern, length);
-    setValue(next);
-    queueFocus(Math.min(index + inserted.length, length - 1), next);
+    if (!applyInsertion(raw)) {
+      event.currentTarget.value = char;
+    }
   },
   ...
   onPaste: (event: React.ClipboardEvent<HTMLInputElement>) => {
     if (disabled) {
       return;
     }
     event.preventDefault();
-    const inserted = sanitize(event.clipboardData?.getData('text') ?? '', pattern, length);
-    if (inserted === '') {
-      return;
-    }
-    const next = sanitize(replaceAt(value, index, inserted), pattern, length);
-    setValue(next);
-    queueFocus(Math.min(index + inserted.length, length - 1), next);
+    applyInsertion(event.clipboardData?.getData('text') ?? '');
   },
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/headless/src/primitives/otp/otp-input.tsx` around lines 85 - 168,
The OTP input handlers in `OtpInput` duplicate the same insert flow in both
`onChange` and `onPaste` (`sanitize` → `replaceAt` → `sanitize` → `setValue` →
`queueFocus`), so extract that shared logic into a helper used by both paths.
Keep the helper responsible for applying inserted text at the current `index`,
updating state, and moving focus consistently so future changes to insertion
behavior only need to be made in one place.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/headless/src/primitives/otp/otp-input.tsx`:
- Around line 15-44: Wrap OTPInput with React.forwardRef so the consumer ref is
received correctly in React 18 instead of being read from props; update the
OTPInput component signature to accept the forwarded ref, then merge that ref
with the internal registerInput-based setRef logic inside OTPInput while keeping
the existing useOTPContext behavior unchanged.

In `@packages/swingset/src/stories/otp.stories.tsx`:
- Around line 1-38: The OTP story is not matching the shared docs-story contract
and also triggers react/jsx-pascal-case because the imported component name is
capitalized as OTP. Alias the import in the OTP story module to a
PascalCase-safe name like Otp, then update the story export so Default accepts
the standard props signature used in other .stories.tsx files, including props:
Record<string, unknown> and knobsAsProps. Keep the existing OTP.Root and Slots
usage intact, but wire the story through the shared props pattern so the docs
build and knob plumbing remain consistent.

---

Outside diff comments:
In @.changeset/headless-otp.md:
- Around line 1-3: The changeset file is empty and needs a real release note
entry for the new OTP primitive. Update the changeset content so it includes a
non-empty summary and the appropriate bump for the headless package, making sure
the new API is captured by Changesets and appears in release notes.

---

Nitpick comments:
In `@packages/headless/src/primitives/otp/otp-input.tsx`:
- Around line 85-168: The OTP input handlers in `OtpInput` duplicate the same
insert flow in both `onChange` and `onPaste` (`sanitize` → `replaceAt` →
`sanitize` → `setValue` → `queueFocus`), so extract that shared logic into a
helper used by both paths. Keep the helper responsible for applying inserted
text at the current `index`, updating state, and moving focus consistently so
future changes to insertion behavior only need to be made in one place.

In `@packages/headless/src/primitives/otp/otp.test.tsx`:
- Around line 44-370: The OTP test suite is missing coverage for two supported
features: masking and the Delete key behavior. Add a test around
OTP.Root/OTP.Input to verify that enabling mask causes the rendered inputs to
use the masked password type, and extend the existing keyboard navigation
coverage in the Backspace/Delete section to assert that {Delete} clears the
current slot without shifting focus. Use the existing Harness, inputs(), and
keyboard navigation tests as the place to add these cases.
- Line 46: The OTP tests are using global document.querySelector for several
slot assertions instead of React Testing Library queries. Update the affected
assertions in the otp test cases to use RTL-friendly queries consistently, such
as screen.getByTestId or a scoped container query, and if needed expose stable
test ids for the slot elements so they can be targeted without querying the
global document. Keep the existing patterns in the file aligned with the other
screen-based assertions.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository YAML (base), Repository UI (inherited)

Review profile: CHILL

Plan: Pro Plus

Run ID: c99c4ca8-4420-441f-b328-139166867351

📥 Commits

Reviewing files that changed from the base of the PR and between 1efc7e5 and 9bb1258.

📒 Files selected for processing (16)
  • .changeset/headless-otp.md
  • packages/headless/README.md
  • packages/headless/package.json
  • packages/headless/src/primitives/otp/README.md
  • packages/headless/src/primitives/otp/index.ts
  • packages/headless/src/primitives/otp/otp-context.ts
  • packages/headless/src/primitives/otp/otp-input.tsx
  • packages/headless/src/primitives/otp/otp-root.tsx
  • packages/headless/src/primitives/otp/otp-utils.ts
  • packages/headless/src/primitives/otp/otp.test.tsx
  • packages/headless/src/primitives/otp/parts.ts
  • packages/headless/vite.config.ts
  • packages/swingset/src/components/DocsViewer.tsx
  • packages/swingset/src/lib/registry.ts
  • packages/swingset/src/stories/otp.mdx
  • packages/swingset/src/stories/otp.stories.tsx

Comment thread packages/headless/src/primitives/otp/otp-input.tsx Outdated
Comment thread packages/swingset/src/stories/otp.stories.tsx Outdated
@pkg-pr-new

pkg-pr-new Bot commented Jul 2, 2026

Copy link
Copy Markdown

Open in StackBlitz

@clerk/astro

npm i https://pkg.pr.new/@clerk/astro@9079

@clerk/backend

npm i https://pkg.pr.new/@clerk/backend@9079

@clerk/chrome-extension

npm i https://pkg.pr.new/@clerk/chrome-extension@9079

@clerk/clerk-js

npm i https://pkg.pr.new/@clerk/clerk-js@9079

@clerk/electron

npm i https://pkg.pr.new/@clerk/electron@9079

@clerk/electron-passkeys

npm i https://pkg.pr.new/@clerk/electron-passkeys@9079

@clerk/eslint-plugin

npm i https://pkg.pr.new/@clerk/eslint-plugin@9079

@clerk/expo

npm i https://pkg.pr.new/@clerk/expo@9079

@clerk/expo-passkeys

npm i https://pkg.pr.new/@clerk/expo-passkeys@9079

@clerk/express

npm i https://pkg.pr.new/@clerk/express@9079

@clerk/fastify

npm i https://pkg.pr.new/@clerk/fastify@9079

@clerk/hono

npm i https://pkg.pr.new/@clerk/hono@9079

@clerk/localizations

npm i https://pkg.pr.new/@clerk/localizations@9079

@clerk/nextjs

npm i https://pkg.pr.new/@clerk/nextjs@9079

@clerk/nuxt

npm i https://pkg.pr.new/@clerk/nuxt@9079

@clerk/react

npm i https://pkg.pr.new/@clerk/react@9079

@clerk/react-router

npm i https://pkg.pr.new/@clerk/react-router@9079

@clerk/shared

npm i https://pkg.pr.new/@clerk/shared@9079

@clerk/tanstack-react-start

npm i https://pkg.pr.new/@clerk/tanstack-react-start@9079

@clerk/testing

npm i https://pkg.pr.new/@clerk/testing@9079

@clerk/ui

npm i https://pkg.pr.new/@clerk/ui@9079

@clerk/upgrade

npm i https://pkg.pr.new/@clerk/upgrade@9079

@clerk/vue

npm i https://pkg.pr.new/@clerk/vue@9079

commit: d340dce

@alexcarpenter alexcarpenter merged commit 8089baa into main Jul 2, 2026
51 checks passed
@alexcarpenter alexcarpenter deleted the headless-otp-input branch July 2, 2026 22:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants