Headless <pin-input>
for the web

A framework-agnostic, accessible PIN input. Fully customizable via ::part(). Works anywhere HTML works.

npm install @javierortega95/pin-input

Usage

Import the component and use it with any framework — no wrappers, no adapters needed.

import '@javierortega95/pin-input'

<pin-input
  length="6"
  name="otp"
  autocomplete="one-time-code"
></pin-input>

const input = document.querySelector('pin-input')

input.addEventListener('pin-change', (e) => {
  console.log(e.detail.value)
})

input.addEventListener('pin-complete', (e) => {
  console.log(e.detail.value)
})
import '@javierortega95/pin-input'

export default function App() {
  return (
    <pin-input
      length="6"
      name="otp"
      autocomplete="one-time-code"
      onpin-change={(e) => console.log(e.detail.value)}
      onpin-complete={(e) => console.log(e.detail.value)}
    />
  )
}

Requires React 19. For React 18 and earlier, use useRef + addEventListener.

<script setup>
import '@javierortega95/pin-input'

function onPinChange(e) {
  console.log('change:', e.detail.value)
}

function onPinComplete(e) {
  console.log('complete:', e.detail.value)
}
</script>

<template>
  <pin-input
    length="6"
    name="otp"
    autocomplete="one-time-code"
    @pin-change="onPinChange"
    @pin-complete="onPinComplete"
  />
</template>
import '@javierortega95/pin-input'
import { Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'
import { bootstrapApplication } from '@angular/platform-browser'

@Component({
  selector: 'app-root',
  schemas: [CUSTOM_ELEMENTS_SCHEMA],
  template: `
    <pin-input
      length="6"
      name="otp"
      autocomplete="one-time-code"
      (pin-change)="onPinChange($event)"
      (pin-complete)="onPinComplete($event)"
    ></pin-input>
  `,
})
export class App {
  onPinChange(e: Event) {
    console.log('change:', (e as CustomEvent).detail.value)
  }

  onPinComplete(e: Event) {
    console.log('complete:', (e as CustomEvent).detail.value)
  }
}

bootstrapApplication(App)

Styling

Unstyled by default. Use ::part() to apply any CSS — no specificity battles, no overrides.

pin-input::part(wrapper) {
  display: flex;
  gap: 8px;
}

pin-input::part(slot) {
  width: 48px;
  height: 56px;
  border: 1.5px solid #d0d7de;
  border-radius: 8px;
  display: flex;
  align-items: center;
  justify-content: center;
}

pin-input::part(slot filled) {
  border-color: #94a3b8;
}

pin-input::part(slot active) {
  border-color: #58a6ff;
}

pin-input::part(cursor) {
  width: 1.5px;
  height: 22px;
  background: #58a6ff;
}