Skip to content

feat(VOtpInput): groups, a11y improvements, emoji#22803

Merged
J-Sek merged 34 commits into
vuetifyjs:devfrom
Yanis-Riani:feat/22659-headless-otp-input
May 20, 2026
Merged

feat(VOtpInput): groups, a11y improvements, emoji#22803
J-Sek merged 34 commits into
vuetifyjs:devfrom
Yanis-Riani:feat/22659-headless-otp-input

Conversation

@Yanis-Riani

@Yanis-Riani Yanis-Riani commented Apr 17, 2026

Copy link
Copy Markdown
Contributor

resolves #22659

resolves #18427

Full refactoring of the VOtpInput

  • freedom to put any HTML structure within or use groups to achieve any layout
  • add keyboard navigation with arrows, selecting with [Shift] - to make it feel like enhanced text field
  • new headless composable under the hood
  • improved support for emojis
  • improved support for IME handling
  • a single hidden input instead of individual inputs per slot
  • fixes copy/paste, RTL support, and improves screen reader support
  • backward-compatible

This component can be manipulated to other used case then an OTP such as a PIN code, Bank codes (BSB, IBAN, ...) and maybe other but it may need changes on the pattern side to check the whole input and not each character

New Sub Components

  • VOtpField
    • props:
      • index (Number) - Index of slot
  • VOtpGroup
    • props:
      • merged (Boolean | null) default = null - Merge child VOtpField , fallback to root component merged
  • VOtpSeparator
    • slots:
      • default - to create custom separator (replace text)

Root Component

  • new props
    • pattern (’numeric’ | ‘alpha’ | ‘alphanumeric’ | RegExp) default = undefined - filter accepted character, fallback to type = number if it’s the case then pattern = 'numeric' for backward-compatibility reason
    • merged (Boolean) default = false - define VOtpGroup default behavior, when merge no divider can be displayed
  • new slots
    • fields - to custom slots layout with sub components: VOtpField , VOtpGroup , VOtpSeparator
    • divider - Slot for custom divider content, Receives { index } with the divider position

Markup:

<template>
  <v-app theme="dark">
    <v-defaults-provider :defaults="{ VOtpInput: { type: 'text', hideDetails: true, density: 'comfortable' } }">
      <v-container max-width="700">
        <div class="d-flex align-center justify-space-between ga-4 mb-2">
          <span>VTextField (for reference)</span>
          <v-text-field v-model="value" density="comfortable" max-width="320" variant="solo" />
        </div>

        <div class="d-flex align-center justify-space-between ga-4 mb-2">
          <span>VMaskInput</span>
          <v-mask-input v-model="value" density="comfortable" mask="N · N · N · N" max-width="320" variant="solo" />
        </div>

        <div class="d-flex align-center justify-space-between ga-4 mb-2">
          <span>Default (auto-layout)</span>
          <v-otp-input v-model="value" divider="" />
        </div>

        <div class="d-flex align-center justify-space-between ga-4 mb-2">
          <span>Divider template with icon</span>
          <v-otp-input v-model="value" variant="outlined">
            <template #divider="{ index }">
              <v-icon v-if="index === 2" icon="mdi-minus" />
              <v-icon v-else icon="mdi-chevron-right" />
            </template>

          </v-otp-input>
        </div>

        <div class="d-flex align-center justify-space-between ga-4 mb-2">
          <span>Default merged (root merge prop)</span>
          <v-otp-input
            v-model="value"
            variant="solo"
            merged
          />
        </div>

        <div class="d-flex align-center justify-space-between ga-4 mb-2">
          <span>Custom layout — style[3]-[3]</span>
          <v-otp-input v-model="value" variant="solo-inverted" merged>
            <template #fields>
              <v-otp-group :merged="false">
                <v-otp-field :index="0" />
                <v-otp-field :index="1" />
                <v-otp-field :index="2" />
              </v-otp-group>
              <v-otp-separator class="custom-separator" />
              <v-otp-group>
                <v-otp-field :index="3" />
                <v-otp-field :index="4" />
                <v-otp-field :index="5" />
              </v-otp-group>
            </template>
          </v-otp-input>
        </div>

        <div class="d-flex align-center justify-space-between ga-4 mb-2">
          <span>Custom layout — [2]-[4] no merge</span>
          <v-otp-input v-model="value" variant="filled">
            <template #fields>
              <v-otp-group>
                <v-otp-field :index="0" />
                <v-otp-field :index="1" />
              </v-otp-group>
              <v-otp-separator>
                <v-icon icon="mdi-minus" />
              </v-otp-separator>
              <v-otp-group>
                <v-otp-field :index="2" />
                <v-otp-field :index="3" />
                <v-otp-field :index="4" />
                <v-otp-field :index="5" />
              </v-otp-group>
            </template>
          </v-otp-input>
        </div>

        <div class="d-flex align-center justify-space-between ga-4 mb-2">
          <span>RTL</span>
          <v-locale-provider rtl>
            <v-otp-input v-model="value" variant="plain" />
          </v-locale-provider>
        </div>
      </v-container>
    </v-defaults-provider>
  </v-app>
</template>

<script setup lang="ts">
  import { ref } from 'vue'

  const value = ref<string>('1🇨🇦34🚀')
</script>

<style scoped>
.custom-separator {
  width: 0;
  height: 0;
  border-top: 6px solid transparent;
  border-bottom: 6px solid transparent;
  border-left: 8px solid currentColor;
  opacity: 0.4;
  transition: opacity 0.2s, transform 0.2s;
}

.custom-separator:hover {
  opacity: 1;
  transform: scale(1.4);
}
</style>

@Yanis-Riani Yanis-Riani changed the title Refactor(VOtpInput): single overlay input refactor(VOtpInput): single overlay input Apr 17, 2026
@Yanis-Riani

Copy link
Copy Markdown
Contributor Author

I have a question about the local translation, since one aria label has been touch did i need to clear all the translation ?
also did i need to update the doc by myself ?

@J-Sek

J-Sek commented Apr 17, 2026

Copy link
Copy Markdown
Contributor

yes to both

  • docs & examples will need some updates
  • all other translations need to be updated - I can do it for you if you don't have any AI tool handy, it is quite tedious to change by hand

additionally

  • include new props in new-in.json and their descriptions in VOtpInput.json
  • list new sub-components in page-to-api.json
  • new components should get their *.json files with props/slots/emits descriptions

btw. I am not sure if it counts as breaking or not.. would be a shame to push it to v5

@J-Sek J-Sek added T: feature A new feature C: VOtpInput a11y Accessibility issue labels Apr 17, 2026

@J-Sek J-Sek 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.

just a quick feedback

Comment thread packages/vuetify/src/components/VOtpInput/VOtpSeparator.tsx Outdated
Comment thread packages/vuetify/src/components/VOtpInput/VOtpInput.tsx Outdated
@Yanis-Riani

Copy link
Copy Markdown
Contributor Author

Ok thank i will fix that tomorrow, i can do the French translation since im a native and for the doc i will see if i could updated it properly, i will probably need some feedback about it.

@J-Sek

J-Sek commented Apr 17, 2026

Copy link
Copy Markdown
Contributor

I am curious if we could make it all backward compatible. This would mean we need to preserve the original "otp" key in translations and add a new one "otpInput" or sth.

@Yanis-Riani Yanis-Riani force-pushed the feat/22659-headless-otp-input branch 2 times, most recently from d7d3ac3 to 5351f5c Compare April 18, 2026 05:57
@Yanis-Riani

Copy link
Copy Markdown
Contributor Author

I am curious if we could make it all backward compatible. This would mean we need to preserve the original "otp" key in translations and add a new one "otpInput" or sth.

I’m confused about what the use case would be for keeping the old key. isn't just dead code ? the only part of the component that needs an aria-label is the hidden input, we can add is an aria-description, but the old pattern with index no longer makes much sense here.

@Yanis-Riani

Copy link
Copy Markdown
Contributor Author

Also, I figured out that once the input is selected, it intercepts the hover CSS of VField. The only workaround I found to this is to add an hover prop to VField and add onmousemove to the hidden input. It works well, but I wanted your opinion on that since it needs to change VField and it's probably is out of scope for now.

Replace the N separate <input> elements with a single hidden <input>
overlaying all visual slots, inspired by the input-otp library.

- Single real input improves a11y, native autofill (autocomplete="one-time-code"),
  and mobile keyboard support
- Forced 1-char range selection ([i, i+1]) drives the active-slot highlight
  via onSelectionChange; mirrors stored in renderSelectionStart/End refs
- Visual slots are now <div> + <span> inside VField (no more input elements)
- Fake caret rendered on active empty slot with CSS blink animation
- deleteContentForward (Delete key) intercepted in beforeinput to keep
  selection at slot i instead of drifting backward
- Modifier + Backspace/Delete intercepted in keydown (before browser
  converts to deleteContentBackward due to forced range selection)
- Android IME fallback: word/line delete inputTypes handled in beforeinput
- renderSelectionStart/End updated directly after setSelectionRange to avoid
  missed selectionchange on empty inputs in Chromium
- Tests adapted: single input selector, active slot via .v-field--focused,
  clickSlot helper for selection-based slot targeting
- Add VOtpField, VOtpGroup, VOtpSeparator sub-components for custom layouts
- Add merged prop to VOtpInput and VOtpGroup for merged-border style
- Propagate merged/divider from root via provide/inject context
- Auto-layout uses sub-components internally
- VOtpGroup uses display:contents (non-merged) for equal field sizing
- VOtpGroup uses flex:N (merged, N=field count) for proportional sizing
- Merge mode collapses adjacent borders via margin-inline-start:-1px
- Restore focused field border via z-index and border-inline-start-width
Add focusAt(index) to context for programmatic slot selection.
VOtpField emits data-otp-index + onClick for pre-focus slot clicks.
Input uses onMousedown + elementsFromPoint for post-focus detection.
Pointer-events toggle (none/all via :focus-within) enables native
hover before focus and right-click paste after focus.
Fix hover borders in merged groups with z-index layering.
Clamp focusAt index to input.value.length for empty slot clicks.
Remove individual field box-shadows in merged groups to prevent
overlapping artifacts. Re-apply elevation on the group container
via :has() for solo variants. Add overlay on focused solo/plain
fields. Adapt tests to pointer-events toggle and add coverage for
click-to-select, empty slot redirect, merged layout and custom
sub-component layouts.
@Yanis-Riani Yanis-Riani force-pushed the feat/22659-headless-otp-input branch from 5351f5c to 27c8eec Compare April 23, 2026 02:40
- old class name for separator
- new slot name, so #default appends below the fields
- restored IME support (e.g. autocomplete when typing symbols - korean/chinese/etc.)
@J-Sek

J-Sek commented May 18, 2026

Copy link
Copy Markdown
Contributor

This is a very welcome set of changes, but in order to ship it in v4.1.0, we have to eliminate breaking changes. I covered the obvious: default slot behavior, separator class name, IME

I would call the IME support "under review". Probably won't have time to test it thoroughly, so here's notes:

  • PC setup for IME keyboard (e.g. Japanese) behavior depends on the OS - quite a hassle if when doing for the first time, I don't remember details but can be pretty annoying - might easier to verify on the phone
  • once setup for IME testing, verify how alpha and alphanumeric behave and whether looks like intended behavior
  • not sure if it's just Android, but the old implementation was stuck on single cell trying to auto-suggestion some text making it easy to frustrate users when type="text". If confirmed/reproduced, I'd confirm the new implementation also suffers from [Bug Report][3.9.5] VOtpInput: when typing one character in the VOtpInput, two identical characters appear. #21918 when onComposition* handlers are missing. Weird that input-otp a very popular component, does not have any trace of such event handlers in the sources..

If it can be tested quickly (today), it would increase the chances for PR to be merged and shipped in v4.1.0

J-Sek and others added 3 commits May 19, 2026 01:43
- discriminate real IME composition (CJK/Bopomofo) from predictive-text
  keyboards (Samsung, Chrome OTP autofill) and Latin dead keys
- render in-progress IME characters as an italic overlay on the active
  slot via a separate compositionText ref, without advancing model.value
- freeze the active slot and guard the `finish` emit while composing
- add `unicode-alpha` and `unicode-alphanumeric` pattern presets for
  non-Latin scripts
mention `unicode-alpha` and `unicode-alphanumeric` in the API description
and on the pattern section of the component page.
@Yanis-Riani Yanis-Riani force-pushed the feat/22659-headless-otp-input branch from abc80fb to e7d340a Compare May 19, 2026 03:41
@Yanis-Riani

Copy link
Copy Markdown
Contributor Author

Normally the IME support works well. There were a few issues specific to the Samsung keyboard, which always triggers isComposing even on latin keyboard

for CJK users, an overlay shows what they're typing, the OTP wont register intermediate characters as real input, lets them temporarily type past the max length, and avoids triggering finish prematurely

the only keyboard that doesn't follow this flow is Microsoft SwiftKey (maybe others), since it never emits a composition event, I haven't found a clean way to solve that case.

@J-Sek

J-Sek commented May 19, 2026

Copy link
Copy Markdown
Contributor

Thanks for handling it so quickly. I will only check for regression against that closed #21918 (later today) and we should be ready to merge

@J-Sek

J-Sek commented May 20, 2026

Copy link
Copy Markdown
Contributor

I was not happy with input being "overlay", so it is hidden and unclickable. Afaik, it only exists in input-otp for password managers, which VOtpInput never triggers because of hardcoded type="text". I don't feel like it is crucial anyway and would be a maintenance burden.

Emoji support is not crucial - just fun, marketable and gives us a slight edge.

Before changes, v-otp-input__field was <input> and expanded each field. I was debugging it long to try to make <div> work the same, but no amount of CSS could force it and in the layout from updated playground (label and the VOtpInput inside .d-flex.justify-space-between), the sub-fields were being shrunk to 14px.

Functionally 99% done. Pending some code cleanup:

  • unnecessary _val rename
  • unnecessary abbreviations (e.g. sep)
  • comments are probably too long/cryptic
  • otp-input.md should split examples for slots and props for dividers
  • pre-fill values in prop-pattern.vue and prop-merged.vue
  • VOtpSeparator.json should only have "description", "tag" is already covered by generic.json
    • cover API description for other sub-components as well
  • minor bug when using arrows, then trying to shift-select in opposite direction

@J-Sek J-Sek self-assigned this May 20, 2026
@J-Sek J-Sek linked an issue May 20, 2026 that may be closed by this pull request
@J-Sek J-Sek changed the title refactor(VOtpInput): single overlay input feat(VOtpInput): groups, a11y improvements, emoji May 20, 2026
Shift+ArrowLeft from a single-slot selection used to drop the anchor
slot because the native selection collapsed to a caret and the
single-slot synthesis in syncSelection slid the range by one. Track
anchor/focus explicitly in grapheme space and extend the selection
ourselves so the originally-selected slot stays included.
@Yanis-Riani

Copy link
Copy Markdown
Contributor Author

I was not happy with input being "overlay", so it is hidden and unclickable. Afaik, it only exists in input-otp for password managers, which VOtpInput never triggers because of hardcoded type="text". I don't feel like it is crucial anyway and would be a maintenance burden.

I understand your point, but the main reason was to bring back right-click → copy / paste / select all. A lot of users (especially older people) rely on the context menu rather than keyboard shortcuts. I tried alternatives like a custom context menu, but couldn't match the native UX, the overlay was the cleanest solution: yes it requires some JS to keep VField's hover working and handle selection, but after that the browser does the rest for us.

Anyway, the Shift+Arrow bug should be fixed now, and I renamed some variables. I'll let you handle the docs and remaining comment cleanups, let me know if you need something else :)

@J-Sek

J-Sek commented May 20, 2026

Copy link
Copy Markdown
Contributor

We could have input-mode="overlay" to make it opt-in or add #input slot around it for something like:

<template #input="{ props }">
  <input v-bind="{ props }" class="opacity-0 absolute inset-0" />
</template>

Might be good to experiment a bit in a follow-up PR. This one packs a ton of improvements and we went through a lot to make it backward compatible as well. I barely have any HP to spare 😆.

Some OTP inputs in real-life use plain text field - GitHub, GitLab, Plausible, Netlify, which does not look that bad actually is the most cost-effective way to achieve fully native experience. I suppose those platforms had their fair share of support tickets and decided that fancy split view is not worth it.

@Yanis-Riani

Copy link
Copy Markdown
Contributor Author

I share the vibe i never thought a otp would so picky to make, an opt-in would be good for the v4.x but maybe we can make that the default on v5.x, anyway lets close this PR and see what people think of it

@J-Sek J-Sek merged commit 825bf43 into vuetifyjs:dev May 20, 2026
16 of 17 checks passed
@Yanis-Riani Yanis-Riani deleted the feat/22659-headless-otp-input branch May 21, 2026 07:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

a11y Accessibility issue C: VOtpInput T: feature A new feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature Request] VOtpInput: Improve accessibilty and keyboard controls [Feature Request] v-otp-input divider after n inputs

2 participants