Password managers

How input-otp detects 1Password, LastPass, Dashlane and Bitwarden badges and moves them out of your last slot. With a live simulator.

Password managers decorate anything that smells like a credential field with a small badge, anchored to its top-right corner. On an ordinary text input that badge sits harmlessly in the padding. On an OTP field, the top-right corner is the last slot — so the badge lands squarely on top of the sixth character.

You cannot move it: it belongs to a browser extension, in a different stacking context, positioned from the input's own box. What you can do is change the box.

Try it without installing anything#

The badges below are simulations — but not pictures. Each one carries the exact DOM marker its real extension leaves behind, which means the library's own detection code finds them and responds for real. The readout underneath is measured off the live input, not written by hand.

Installed extension
pushPasswordManagerStrategy
4
8
2
querySelectorAll(…).length
no vendor matched — falls back to the probe
elementFromPoint(x, y)
input.style.width
input.style.clipPath
rendered widths
container px · input px
verdict
no gutter reserved — badge overlaps the last slot

Switch the strategy to none with an extension installed and you can see the problem the feature exists to solve: the badge parks itself over the last slot.

How the accommodation works#

The trick is that the badge is positioned relative to the input, while the slots you can see belong to the container. Those are two different boxes. So the input is made 40px wider than the container — which drags the badge 40px to the right, clear of the last slot — and then those same 40px are clipped away, so nothing about the field's appearance or hit area changes.

// The invisible input grows by 40px …
width: willPushPWMBadge ? `calc(100% + ${PWM_BADGE_SPACE_WIDTH})` : '100%'
 
// … and immediately clips those 40px back off, so nothing moves on screen.
clipPath: willPushPWMBadge ? `inset(0 ${PWM_BADGE_SPACE_WIDTH} 0 0)` : undefined
  • No layout shift. The container never resizes; the growth happens on an absolutely positioned child and is clipped in the same frame.
  • No lost clicks. clip-path clips hit-testing too, so the clickable area stays exactly the visible field.
  • The badge stays usable. One CSS rule hands pointer events back to whatever the extension injects next to the input:
/* The container is pointer-events: none, and a badge is injected as the
   input's next sibling — so give that sibling its clicks back. */
[data-input-otp] + * { pointer-events: all !important; }

How detection works#

Two passes, cheapest first.

1. Look for the extension by name#

Four vendors leave a stable, identifiable mark on the page. Bitwarden has no useful attribute, so it is fingerprinted by the maximum-z-index inline style it stamps on its overlay:

const PASSWORD_MANAGERS_SELECTORS = [
  '[data-lastpass-icon-root]',            // LastPass
  'com-1password-button',                 // 1Password
  '[data-dashlanecreated]',               // Dashlane
  '[style$="2147483647 !important;"]',    // Bitwarden — fingerprinted by z-index
].join(',')

Turn on Show the detection probe point in the simulator and switch vendors — the querySelectorAll(…).length row is this query running against the fake badge.

2. Otherwise, probe the corner#

For anything not on that list, the library asks the browser what is actually painted at the badge's usual position:

// The top-right of the container, 18px in, vertically centred —
// where password managers put their badge.
const x = container.getBoundingClientRect().left + container.offsetWidth - 18
const y = container.getBoundingClientRect().top + container.offsetHeight / 2
 
if (document.querySelectorAll(PASSWORD_MANAGERS_SELECTORS).length === 0) {
  const maybeBadgeEl = document.elementFromPoint(x, y)
  if (maybeBadgeEl === container) {
    return // never true in practice — see the callout below
  }
}
 
setHasPWMBadge(true)

Timing, and knowing when to stop#

An extension injects its badge on its own schedule — sometimes before the page settles, sometimes seconds after a field is focused. So the check is retried, then abandoned:

// Extensions inject their badge whenever they get around to it, so the
// check runs several times and then gives up.
setTimeout(trackPWMBadge, 0)
setTimeout(trackPWMBadge, 2000)
setTimeout(trackPWMBadge, 5000)
setTimeout(() => setDone(true), 6000)   // latch: stop looking

The latch matters. Without it the library would keep measuring the corner of a field forever, and any DOM the user happens to hover over the input would re-trigger the accommodation.

Is there even room?#

Reserving space to the right is pointless if it doesn't fit. Against the edge of the viewport the badge would be clamped inside regardless; inside a scroll container the overhang becomes scrollable overflow — a horizontal scrollbar that shifts the layout — and some extensions refuse to render a badge whose anchor sits in a clipped region. So the free space up to the nearest overflow-constraining ancestor is measured, and re-measured once a second:

// Re-checked every second, and once more synchronously before committing:
// does the 40px gutter fit inside the nearest box that constrains
// horizontal overflow? (any overflow-x other than visible — a scroll
// container, an overflow-hidden card, the container itself — with the
// viewport's clientWidth as the fallback)
setHasPWMBadgeSpace(availableBadgeSpace(container) >= 40)

Both conditions have to hold: a badge was detected and the full 40px gutter fits. Otherwise the width stays at 100% and the badge simply stays over the last slot — the same rendering as pushPasswordManagerStrategy="none".

Opting out#

The accommodation is on by default. If you would rather it weren't — because your field sits in a tight layout, or you have your own arrangement with the extensions — turn it off:

<OTPInput
  maxLength={6}
  // Take the 40px reservation off the table entirely.
  pushPasswordManagerStrategy="none"
/>

This also stops the detection work entirely: no probing, no interval.

Blocking the badge altogether#

A stronger position: tell the extensions not to decorate the field at all. Every major password manager honours an opt-out attribute, and since the component forwards unknown props to the input, you can just add them.

<OTPInput
  maxLength={6}
  // 1. turn off the built-in accommodation …
  pushPasswordManagerStrategy="none"
  // 2. … then tell each extension to stay away.
  data-lpignore="true"   // LastPass
  data-1p-ignore="true"  // 1Password
  data-form-type="other" // Dashlane
  data-bwignore="true"   // Bitwarden
/>

Debugging a badge in your own app#

  1. Focus the field. Detection is focus-gated, so nothing happens until you do.
  2. Read input.style.width in devtools. calc(100% + 40px) means the accommodation fired; 100% means it didn't.
  3. If it fired but the badge still overlaps, the badge is more than 40px wide or anchored further in than 18px. There is no prop for this — override the input's width with your own CSS on [data-input-otp].
  4. If it didn't fire, check the distance from the field to the right edge of the viewport (not the container) — under 40px and the reservation is skipped by design.
  5. If the badge is visible but unclickable, something in your CSS is beating [data-input-otp] + * { pointer-events: all }.

One more platform surface to go: Mobile & platforms.