npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

input-otp-zero

v0.0.4

Published

input-otp-zero - One invisible input, any UI you can imagine. A zero-dependency one-time-password custom element, themed with CSS variables.

Downloads

38

Readme

input-otp-zero

One invisible input, any UI you can imagine.

The accessible, themeable, zero-dependency one-time-password element for plain HTML — no framework, no build step required.

A vanilla port of input-otp by Guilherme Rodz: same engine, no React. Ships as a custom element and a CSS-variable theme.

Why

HTML has no one-time-password control. There is no <input type="otp">, so most products build one out of six separate inputs wired together with keydown handlers that shuffle focus between them — and quietly lose SMS autofill, screen reader support, partial paste, undo, and half the keyboard along the way.

input-otp-zero renders exactly one real text input, paints it invisible, and draws slots on top of it. Everything the browser gives a text field keeps working, because there is still a text field.

  • SMS autofill — autocomplete="one-time-code" only means something on a single field
  • Screen readers — one control, one name, one value, one caret, one tab stop
  • Every keybinding you didn't implement — select-all, word-delete, shift-arrow ranges, undo, the iOS long-press menu
  • Real paste — including a partial paste into the middle of a half-filled code
  • Form semantics — one name, one entry in FormData, a real <label> that focuses it
  • Progressive enhancement — put a plain <input> inside and it stays usable if the script never runs
  • Themeable — every colour, size and timing is a CSS custom property
  • Zero dependencies — ~4 KB brotli, no framework

Add it to a page

Two tags. No build step, no bundler, nothing to install.

<link rel="stylesheet" href="https://esm.sh/input-otp-zero/dist/input-otp-zero.css">
<script type="module" src="https://esm.sh/input-otp-zero"></script>

Pin a version for production — https://esm.sh/[email protected] — so a release can't change under you.

The script defines <input-otp> and injects the rules that make one text field behave like six boxes — that part is machinery, and it is never optional. The theme is the stylesheet: link it, paste it into a <style> tag, or fold it into your own. It is plain CSS.

The machinery's <style> tag goes first in <head>, so both the theme and your own CSS come later in the cascade and win a specificity tie — you never have to out-specify the defaults to change them.

Usage

maxlength is the number of slots. That's the whole contract.

<label for="code">Verification code</label>

<input-otp maxlength="6" pattern="digits">
  <input id="code" name="code">
</input-otp>

The inner <input> is optional. Provide one when you want to control its id/name from markup or keep the field usable before the script loads; otherwise one is created for you.

Composing your own slots

Write the slot markup yourself and it is adopted as-is — grouped, separated, with whatever classes you like. Every <input-otp-slot> inside the component becomes a slot, and the slot count comes from the markup:

<input-otp pattern="digits">
  <input-otp-group>
    <input-otp-slot></input-otp-slot>
    <input-otp-slot></input-otp-slot>
    <input-otp-slot></input-otp-slot>
  </input-otp-group>

  <input-otp-separator></input-otp-separator>

  <input-otp-group>
    <input-otp-slot></input-otp-slot>
    <input-otp-slot></input-otp-slot>
    <input-otp-slot></input-otp-slot>
  </input-otp-group>
</input-otp>

A slot fills in its own two children and paints its state onto itself:

<input-otp-slot data-active data-filled>
  <input-otp-char>4</input-otp-char>
  <input-otp-caret data-visible></input-otp-caret>
</input-otp-slot>

The split is deliberate: attributes you write are bare (maxlength, pattern, appearance), and anything the script writes is data-*. So a selector always tells you whether you are matching configuration or state.

Because the parts are elements rather than attributes, styling them costs the lowest specificity there is — input-otp-slot is 0,0,1, where [data-input-otp-slot] would have been 0,1,0. A single class of your own beats the whole theme.

Reacting to the value

const otp = document.querySelector("input-otp")

otp.addEventListener("input-otp:complete", event => {
  event.target.closest("form").requestSubmit()
})

otp.addEventListener("input-otp:change", event => {
  console.log(event.detail.value)
})

Native input and change events fire from the inner input too, so anything that already listens for those keeps working.

Theming

Every visual decision is a custom property. Set them anywhere — on :root, on a container, or on a single element:

:root {
  --input-otp-slot-width: 3rem;
  --input-otp-border-color-active: #635bff;
  --input-otp-ring-color: rgb(99 91 255 / 0.4);
  --input-otp-caret-color: #635bff;
}

| Property | Default | | --- | --- | | --input-otp-gap | 0.5rem | | --input-otp-group-gap | 0.75rem | | --input-otp-slot-width | 2.75rem | | --input-otp-slot-height | 3.5rem | | --input-otp-slot-radius | 0.5rem | | --input-otp-slot-background | #ffffff | | --input-otp-slot-background-active | #ffffff | | --input-otp-border-width | 1px | | --input-otp-border-color | #d4d4d8 | | --input-otp-border-color-active | #18181b | | --input-otp-ring-width | 2px | | --input-otp-ring-color | rgb(24 24 27 / 0.75) | | --input-otp-ring-offset | 0px | | --input-otp-color | #18181b | | --input-otp-placeholder-color | #a1a1aa | | --input-otp-font-family | ui-monospace, … | | --input-otp-font-size | 1.375rem | | --input-otp-font-weight | 500 | | --input-otp-caret-color | #18181b | | --input-otp-caret-width | 1px | | --input-otp-caret-height | 2rem | | --input-otp-caret-blink-duration | 1s | | --input-otp-separator-color | #a1a1aa | | --input-otp-separator-width | 1.5rem | | --input-otp-separator-thickness | 2px | | --input-otp-transition-duration | 150ms | | --input-otp-transition-easing | ease-out | | --input-otp-disabled-opacity | 0.5 |

The theme ships one set of colours and does not react to prefers-color-scheme. Redefine the properties yourself for a dark palette.

appearance="seamless" collapses the gaps into one continuous box with dividers between the slots.

What it handles for you

The value is the list of things that go wrong when one invisible input has to behave like six boxes — and the fix for each:

| | | | --- | --- | | A collapsed caret has no slot | The selection is rewritten into a one-character range on every selectionchange — except at the append position, where a bare caret is meaningful | | ArrowLeft appears to skip a slot | Direction is inferred from the previous selection, with a guard for leaving insert mode | | Deleting doesn't fire selectionchange | The event is dispatched by hand when the value shrinks | | Password manager badges cover the last slot | A badge is detected by known extension markers, then by probing the field's top-right corner; the input widens 40px behind a clip-path — no visible layout shift | | iOS won't paste into an invisible input | The field keeps opacity: 1 and hides itself with transparent colours; paste is handled manually | | iOS shows a selection artifact | The text is parked offscreen and revealed only while a pointer gesture is in flight, so the native edit menu can still anchor | | Autofill paints its own background | :autofill is neutralised, and the state is shaken off with a synthetic input event | | Chrome translates the slots and breaks them | The component is marked translate="no" — a one-time code is never worth translating | | No JavaScript means no visible field | The inner <input> you provide is styled as a plain visible field until the element upgrades |

API

Attributes

| | | | --- | --- | | maxlength | number of slots, default 6. Ignored when you provide your own slot markup | | value | initial value | | pattern | digits, chars, digits-and-chars, or any regular expression source. Gates every change; no default | | placeholder | per-slot placeholder characters, shown until the first character is typed | | name | submitted under this name | | inputmode | default numeric | | autocomplete | default one-time-code | | text-align | left (default), center, right | | appearance | seamless for one continuous box | | disabled, required, autofocus | forwarded to the input | | push-password-manager-strategy | increase-width (default) or none | | nonce | applied to the injected <style> tag, for CSP style-src |

Properties

| | | | --- | --- | | value | get or set the current value; setting it fires the events below | | maxLength, pattern, placeholder, disabled | mirror the attributes | | isComplete | whether every slot is filled | | slots | the current slot state — { index, char, placeholderChar, isActive, hasFakeCaret }[] | | input | the real <input> element | | pasteTransformer | (pasted: string) => string, run over the clipboard text before it is inserted | | renderSlot | (element, state) => void, replaces the default per-slot painting |

Methods

focus(), blur(), select(), clear().

Events

| | | | --- | --- | | input-otp:change | detail: { value } — bubbles, on every change | | input-otp:complete | detail: { value } — bubbles, once on the transition to full | | input-otp:render | detail: { slots } — after each repaint |

Elements

| | | | --- | --- | | <input-otp> | the component | | <input-otp-group> | a row of slots; generated for you when you don't write one | | <input-otp-slot> | one slot | | <input-otp-char> | the character inside a slot, filled in for you | | <input-otp-caret> | the fake caret inside a slot, filled in for you | | <input-otp-separator> | the dash between groups | | <input data-input-otp> | the real field — a native input, so it stays an attribute |

State attributes

Written by the script, never by you.

On the component: data-focused, data-hovering, data-disabled, data-complete, data-empty.

On a slot: data-active, data-filled, data-placeholder. On its caret: data-visible.

Development

npm install
npm run build            # dist/ — esm, minified, gzip and brotli, plus the CSS
npm run lint             # eslint, JS and CSS
npm test                 # vitest, jsdom
npm run test:browser     # playwright, chromium + firefox + webkit
npm run playground       # the fixture pages, as a live playground

Tests come in two layers. Vitest (test/unit/) covers the pure logic and the element's own contract in jsdom. Playwright (test/browser/) drives real browsers for everything that only a browser can tell you: typing, arrow keys, selection, paste, focus, layout and theming.

Credits

A port of input-otp by Guilherme Rodz — every edge case here was found and solved there first.

License

MIT