PIN Input
Allows users to input a sequence of one-character alphanumeric inputs.
<script lang="ts">
import { PinInput, type PinInputRootSnippetProps } from "bits-ui";
import { toast } from "svelte-sonner";
import { cn } from "$lib/utils/styles.js";
let value = $state("");
type CellProps = PinInputRootSnippetProps["cells"][0];
function onComplete() {
toast.success(`Completed with value ${value}`);
value = "";
}
</script>
<PinInput.Root
bind:value
class="group/pininput flex items-center text-foreground has-[:disabled]:opacity-30"
maxlength={6}
{onComplete}
>
{#snippet children({ cells })}
<div class="flex">
{#each cells.slice(0, 3) as cell}
{@render Cell(cell)}
{/each}
</div>
<div class="flex w-10 items-center justify-center">
<div class="h-1 w-3 rounded-full bg-border"></div>
</div>
<div class="flex">
{#each cells.slice(3, 6) as cell}
{@render Cell(cell)}
{/each}
</div>
{/snippet}
</PinInput.Root>
{#snippet Cell(cell: CellProps)}
<PinInput.Cell
{cell}
class={cn(
// Custom class to override global focus styles
"focus-override",
"relative h-14 w-10 text-[2rem]",
"flex items-center justify-center",
"transition-all duration-75",
"border-y border-r border-foreground/20 first:rounded-l-md first:border-l last:rounded-r-md",
"text-foreground group-focus-within/pininput:border-foreground/40 group-hover/pininput:border-foreground/40",
"outline outline-0",
"data-[active]:outline-1 data-[active]:outline-white"
)}
>
{#if cell.char !== null}
<div>
{cell.char}
</div>
{/if}
{#if cell.hasFakeCaret}
<div
class="pointer-events-none absolute inset-0 flex animate-caret-blink items-center justify-center"
>
<div class="h-8 w-px bg-white"></div>
</div>
{/if}
</PinInput.Cell>
{/snippet}
import typography from "@tailwindcss/typography";
import animate from "tailwindcss-animate";
import { fontFamily } from "tailwindcss/defaultTheme";
/** @type {import('tailwindcss').Config} */
export default {
darkMode: "class",
content: ["./src/**/*.{html,js,svelte,ts}"],
theme: {
container: {
center: true,
screens: {
"2xl": "1440px",
},
},
extend: {
colors: {
border: {
DEFAULT: "hsl(var(--border-card))",
input: "hsl(var(--border-input))",
"input-hover": "hsl(var(--border-input-hover))",
},
background: {
DEFAULT: "hsl(var(--background) / <alpha-value>)",
alt: "hsl(var(--background-alt) / <alpha-value>)",
},
foreground: {
DEFAULT: "hsl(var(--foreground) / <alpha-value>)",
alt: "hsl(var(--foreground-alt) / <alpha-value>)",
},
muted: {
DEFAULT: "hsl(var(--muted) / <alpha-value>)",
foreground: "hsl(var(--muted-foreground))",
},
dark: {
DEFAULT: "hsl(var(--dark) / <alpha-value>)",
4: "hsl(var(--dark-04))",
10: "hsl(var(--dark-10))",
40: "hsl(var(--dark-40))",
},
accent: {
DEFAULT: "hsl(var(--accent) / <alpha-value>)",
foreground: "hsl(var(--accent-foreground) / <alpha-value>)",
},
destructive: {
DEFAULT: "hsl(var(--destructive) / <alpha-value>)",
},
contrast: {
DEFAULT: "hsl(var(--contrast) / <alpha-value>)",
},
},
fontFamily: {
sans: ["Inter", ...fontFamily.sans],
mono: ["Source Code Pro", ...fontFamily.mono],
alt: ["Courier", ...fontFamily.sans],
},
fontSize: {
xxs: "10px",
},
borderWidth: {
6: "6px",
},
borderRadius: {
card: "16px",
"card-lg": "20px",
"card-sm": "10px",
input: "9px",
button: "5px",
"5px": "5px",
"9px": "9px",
"10px": "10px",
"15px": "15px",
},
height: {
input: "3rem",
"input-sm": "2.5rem",
},
boxShadow: {
mini: "var(--shadow-mini)",
"mini-inset": "var(--shadow-mini-inset)",
popover: "var(--shadow-popover)",
kbd: "var(--shadow-kbd)",
btn: "var(--shadow-btn)",
card: "var(--shadow-card)",
"date-field-focus": "var(--shadow-date-field-focus)",
},
opacity: {
8: "0.08",
},
scale: {
80: ".80",
98: ".98",
99: ".99",
},
},
keyframes: {
"accordion-down": {
from: { height: "0" },
to: { height: "var(--bits-accordion-content-height)" },
},
"accordion-up": {
from: { height: "var(--bits-accordion-content-height)" },
to: { height: "0" },
},
"caret-blink": {
"0%,70%,100%": { opacity: "1" },
"20%,50%": { opacity: "0" },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
"caret-blink": "caret-blink 1.25s ease-out infinite",
},
},
plugins: [typography, animate],
};
@import url("https://fonts.googleapis.com/css2?family=Source+Code+Pro:ital,wght@0,400;0,500;0,600;0,700;1,400;1,500;1,600;1,700&display=swap");
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap");
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
/* Colors */
--background: 0 0% 100%;
--background-alt: 0 0% 100%;
--foreground: 0 0% 9%;
--foreground-alt: 0 0% 32%;
--muted: 240 5% 96%;
--muted-foreground: 0 0% 9% / 0.4;
--border: 240 6% 10%;
--border-input: 240 6% 10% / 0.17;
--border-input-hover: 240 6% 10% / 0.4;
--border-card: 240 6% 10% / 0.1;
--dark: 240 6% 10%;
--dark-10: 240 6% 10% / 0.1;
--dark-40: 240 6% 10% / 0.4;
--dark-04: 240 6% 10% / 0.04;
--accent: 204 94% 94%;
--accent-foreground: 204 80% 16%;
--destructive: 347 77% 50%;
/* black */
--constrast: 0 0% 0%;
/* Shadows */
--shadow-mini: 0px 1px 0px 1px rgba(0, 0, 0, 0.04);
--shadow-mini-inset: 0px 1px 0px 0px rgba(0, 0, 0, 0.04) inset;
--shadow-popover: 0px 7px 12px 3px hsla(var(--dark-10));
--shadow-kbd: 0px 2px 0px 0px rgba(0, 0, 0, 0.07);
--shadow-btn: 0px 1px 0px 1px rgba(0, 0, 0, 0.03);
--shadow-card: 0px 2px 0px 1px rgba(0, 0, 0, 0.04);
--shadow-date-field-focus: 0px 0px 0px 3px rgba(24, 24, 27, 0.17);
}
.dark {
/* Colors */
--background: 0 0% 5%;
--background-alt: 0 0% 8%;
--foreground: 0 0% 95%;
--foreground-alt: 0 0% 70%;
--muted: 240 4% 16%;
--muted-foreground: 0 0% 100% / 0.4;
--border: 0 0% 96%;
--border-input: 0 0% 96% / 0.17;
--border-input-hover: 0 0% 96% / 0.4;
--border-card: 0 0% 96% / 0.1;
--dark: 0 0% 96%;
--dark-40: 0 0% 96% / 0.4;
--dark-10: 0 0% 96% / 0.1;
--dark-04: 0 0% 96% / 0.04;
--accent: 204 90 90%;
--accent-foreground: 204 94% 94%;
--destructive: 350 89% 60%;
/* white */
--constrast: 0 0% 100%;
/* Shadows */
--shadow-mini: 0px 1px 0px 1px rgba(0, 0, 0, 0.3);
--shadow-mini-inset: 0px 1px 0px 0px rgba(0, 0, 0, 0.5) inset;
--shadow-popover: 0px 7px 12px 3px hsla(0deg 0% 0% / 30%);
--shadow-kbd: 0px 2px 0px 0px rgba(255, 255, 255, 0.07);
--shadow-btn: 0px 1px 0px 1px rgba(0, 0, 0, 0.2);
--shadow-card: 0px 2px 0px 1px rgba(0, 0, 0, 0.4);
--shadow-date-field-focus: 0px 0px 0px 3px rgba(244, 244, 245, 0.1);
}
}
@layer base {
* {
@apply border-border;
}
html {
-webkit-text-size-adjust: 100%;
font-variation-settings: normal;
}
body {
@apply bg-background text-foreground;
font-feature-settings:
"rlig" 1,
"calt" 1;
}
/* Mobile tap highlight */
/* https://developer.mozilla.org/en-US/docs/Web/CSS/-webkit-tap-highlight-color */
html {
-webkit-tap-highlight-color: rgba(128, 128, 128, 0.5);
}
::selection {
background: #fdffa4;
color: black;
}
/* === Scrollbars === */
::-webkit-scrollbar {
@apply w-2;
@apply h-2;
}
::-webkit-scrollbar-track {
@apply !bg-transparent;
}
::-webkit-scrollbar-thumb {
@apply rounded-card-lg !bg-dark-10;
}
::-webkit-scrollbar-corner {
background: rgba(0, 0, 0, 0);
}
/* Firefox */
/* https://developer.mozilla.org/en-US/docs/Web/CSS/scrollbar-color#browser_compatibility */
html {
scrollbar-color: var(--bg-muted);
}
.antialised {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
}
@layer utilities {
.step {
counter-increment: step;
}
.step:before {
@apply absolute inline-flex h-9 w-9 items-center justify-center rounded-full border-4 border-background bg-muted text-center -indent-px font-mono text-base font-medium;
@apply ml-[-50px] mt-[-4px];
content: counter(step);
}
}
@layer components {
*:not(body):not(.focus-override) {
outline: none !important;
&:focus-visible {
@apply focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-foreground focus-visible:ring-offset-2 focus-visible:ring-offset-background;
}
}
.link {
@apply inline-flex items-center gap-1 rounded-sm font-medium underline underline-offset-4 hover:text-foreground/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-foreground focus-visible:ring-offset-2 focus-visible:ring-offset-background;
}
input::-webkit-outer-spin-button,
input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
/* Firefox */
input[type="number"] {
-moz-appearance: textfield;
}
}
Overview
The PIN Input component provides a customizable solution for One-Time Password (OTP), Two-Factor Authentication (2FA), or Multi-Factor Authentication (MFA) input fields. Due to the lack of a native HTML element for these purposes, developers often resort to either basic input fields or custom implementations. This component offers a robust, accessible, and flexible alternative.
Credits
This component is derived from and would not have been possible without the work done by Guilherme Rodz with Input OTP.
Key Features
- Invisible Input Technique: Utilizes an invisible input element for seamless integration with form submissions and browser autofill functionality.
- Customizable Appearance: Allows for custom designs while maintaining core functionality.
- Accessibility: Ensures keyboard navigation and screen reader compatibility.
- Flexible Configuration: Supports various PIN lengths and input types (numeric, alphanumeric).
Architecture
- Root Container: A relatively positioned root element that encapsulates the entire component.
- Invisible Input: A hidden input field that manages the actual value and interacts with the browser's built-in features.
- Visual Cells: Customizable elements representing each character of the PIN, rendered as siblings to the invisible input.
This structure allows for a seamless user experience while providing developers with full control over the visual representation.
Structure
<script lang="ts">
import { PinInput } from "bits-ui";
</script>
<PinInput.Root maxlength={6}>
{#snippet children({ cells })}
{#each cells as cell}
<PinInput.Cell {cell} />
{/each}
{/snippet}
</PinInput.Root>
Managing Value State
Bits UI offers several approaches to manage and synchronize the component's value state, catering to different levels of control and integration needs.
1. Two-Way Binding
For seamless state synchronization, use Svelte's bind:value
directive. This method automatically keeps your local state in sync with the component's internal state.
<script lang="ts">
import { PinInput } from "bits-ui";
let myValue = $state("");
</script>
<button onclick={() => (myValue = "123456")}> Set value to 123456 </button>
<PinInput.Root bind:value={myValue}>
<!-- -->
</PinInput.Root>
Key Benefits
- Simplifies state management
- Automatically updates
myValue
when the internal state changes (e.g., user typing in the input) - Allows external control (e.g., switching tabs via a separate button)
2. Change Handler
For more granular control or to perform additional logic on state changes, use the onValueChange
prop. This approach is useful when you need to execute custom logic alongside state updates.
<script lang="ts">
import { PinInput } from "bits-ui";
let myValue = $state("");
</script>
<PinInput.Root
value={myValue}
onValueChange={(v) => {
myValue = v;
// additional logic here.
}}
>
<!-- ... -->
</PinInput.Root>
Use Cases
- Implementing custom behaviors on value change
- Integrating with external state management solutions
- Triggering side effects (e.g., logging, data fetching)
3. Fully Controlled
For complete control over the component's value state, use the controlledValue
prop. This approach requires you to manually manage the value state, giving you full control over when and how the component responds to value change events.
To implement controlled state:
- Set the
controlledValue
prop totrue
on thePinInput.Root
component. - Provide a
value
prop toPinInput.Root
, which should be a variable holding the current state. - Implement an
onValueChange
handler to update the state when the internal state changes.
<script lang="ts">
import { PinInput } from "bits-ui";
let myValue = $state("");
</script>
<PinInput.Root controlledValue value={myValue} onValueChange={(v) => (myValue = v)}>
<!-- ... -->
</PinInput.Root>
When to Use
- Implementing complex logic
- Coordinating multiple UI elements
- Debugging state-related issues
Note
While powerful, fully controlled state should be used judiciously as it increases complexity and can cause unexpected behaviors if not handled carefully.
For more in-depth information on controlled components and advanced state management techniques, refer to our Controlled State documentation.
Paste Handling
The onPaste
prop allows you to sanitize pasted text. This can be useful for cleaning up pasted text, like removing hyphens or other characters that should not make it into the input. This function should return the sanitized text.
<script lang="ts">
import { PinInput } from "bits-ui";
</script>
<PinInput.Root onPaste={(text) => text.replace(/-/g, "")}>
<!-- ... -->
</PinInput.Root>
HTML Forms
The PinInput.Root
component is designed to work seamlessly with HTML forms. Simply pass the name
prop to the PinInput.Root
component and the input will be submitted with the form.
Submit On Complete
To submit the form when the input is complete, you can use the onComplete
prop.
<script lang="ts">
import { PinInput } from "bits-ui";
let form = $state<HTMLFormElement>(null!);
</script>
<form method="POST" bind:this={form}>
<PinInput.Root name="mfaCode" onComplete={() => form.submit()}>
<!-- ... -->
</PinInput.Root>
</form>
Patterns
You can use the pattern
prop to restrict the characters that can be entered or pasted into the input.
Note!
Bits UI exports a few common patterns that you can import and use in your application.
REGEXP_ONLY_DIGITS
- Only allow digits to be entered.REGEXP_ONLY_CHARS
- Only allow characters to be entered.REGEXP_ONLY_DIGITS_AND_CHARS
- Only allow digits and characters to be entered.
<script lang="ts">
import { PinInput, REGEXP_ONLY_DIGITS } from "bits-ui";
</script>
<PinInput.Root pattern={REGEXP_ONLY_DIGITS}>
<!-- ... -->
</PinInput.Root>
API Reference
The pin input container component.
Property | Type | Description |
---|---|---|
value $bindable | string | The value of the input. Default: undefined |
onValueChange | function | A callback function that is called when the value of the input changes. Default: undefined |
controlledValue | boolean | Whether or not the Default: false |
disabled | boolean | Whether or not the pin input is disabled. Default: false |
textalign | enum | Where is the text located within the input. Affects click-holding or long-press behavior Default: 'left' |
maxlength | number | The maximum length of the pin input. Default: 6 |
onComplete | function | A callback function that is called when the input is completely filled. Default: undefined |
onPaste | function | A callback function that is called when the user pastes text into the input. It receives the pasted text as an argument and should return the sanitized text. Useful for cleaning up pasted text, like removing hyphens or other characters that should not make it into the input. Default: undefined |
inputId | string | Optionally provide an ID to apply to the hidden input element. Default: undefined |
pushPasswordManagerStrategy | enum | Enabled by default, it's an optional strategy for detecting Password Managers in the page and then shifting their badges to the right side, outside the input. Default: undefined |
ref $bindable | HTMLDivElement | The underlying DOM element being rendered. You can bind to this to get a reference to the element. Default: undefined |
children | Snippet | The children content to render. Default: undefined |
child | Snippet | Use render delegation to render your own element. See Child Snippet docs for more information. Default: undefined |
Data Attribute | Value | Description |
---|---|---|
data-pin-input-root | '' | Present on the root element. |
A single cell of the pin input.
Property | Type | Description |
---|---|---|
cell | object | The cell object provided by the Default: undefined |
ref $bindable | HTMLDivElement | The underlying DOM element being rendered. You can bind to this to get a reference to the element. Default: undefined |
children | Snippet | The children content to render. Default: undefined |
child | Snippet | Use render delegation to render your own element. See Child Snippet docs for more information. Default: undefined |
Data Attribute | Value | Description |
---|---|---|
data-active | '' | Present when the cell is active. |
data-inactive | '' | Present when the cell is inactive. |
data-pin-input-cell | '' | Present on the cell element. |