PIN Lock
A lock screen PIN pad: dots fill as you tap, a wrong code shakes and counts tries, three misses lock it for a while.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/pin-lock.json'use client'
import { useEffect, useRef, useState } from 'react'
import './pin-lock.css'
export interface PinLockProps {
length?: number
onSubmit: (pin: string) => boolean | Promise<boolean>
tries?: number
lockSeconds?: number
title?: string
pad?: 'round' | 'flat'
fill?: 'dark' | 'light'
className?: string
}
/** Numeric PIN pad with tries and a timed lockout. */
export function PinLock({ length = 4, onSubmit, tries = 3, lockSeconds = 30, title = 'Enter passcode', pad = 'round', fill = 'dark', className }: PinLockProps) {
const [pin, setPin] = useState('')
const [left, setLeft] = useState(tries)
const [shake, setShake] = useState(0)
const [lock, setLock] = useState(0)
const [ok, setOk] = useState(false)
const [busy, setBusy] = useState(false)
const box = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!lock) return
const t = setTimeout(() => { setLock((l) => l - 1); if (lock === 1) setLeft(tries) }, 1000)
return () => clearTimeout(t)
}, [lock, tries])
const check = async (code: string) => {
setBusy(true)
const good = await onSubmit(code)
setBusy(false)
if (good) return setOk(true)
setShake((s) => s + 1)
setPin('')
const n = left - 1
if (n <= 0) { setLock(lockSeconds); setLeft(0) } else setLeft(n)
}
const press = (k: string) => {
if (ok || lock || busy) return
if (k === 'del') return setPin((p) => p.slice(0, -1))
if (pin.length >= length) return
const next = pin + k
setPin(next)
if (next.length === length) setTimeout(() => check(next), 140)
}
const msg = ok ? 'Unlocked' : lock ? `Too many tries. Try again in ${lock}s` : left < tries ? `Wrong passcode. ${left} ${left === 1 ? 'try' : 'tries'} left` : `${length}-digit passcode`
const keys = ['1', '2', '3', '4', '5', '6', '7', '8', '9', 'reset', '0', 'del']
return (
<div ref={box} tabIndex={0} className={['sd-pnl', className].filter(Boolean).join(' ')} data-fill={fill} data-pad={pad} data-ok={ok || undefined}
onKeyDown={(e) => { if (/^\d$/.test(e.key)) press(e.key); else if (e.key === 'Backspace') press('del') }}>
<div className="sd-pnl__lock" aria-hidden>
<svg viewBox="0 0 24 24"><rect x="5" y="11" width="14" height="10" rx="2.5" />{ok ? <path d="M8 11V8a4 4 0 0 1 7.6-1.7" /> : <path d="M8 11V8a4 4 0 0 1 8 0v3" />}</svg>
</div>
<p className="sd-pnl__t">{ok ? 'Welcome back' : title}</p>
<p className="sd-pnl__msg" aria-live="polite">{msg}</p>
<div key={shake} className="sd-pnl__dots" data-shake={shake ? '' : undefined} role="status" aria-label={`${pin.length} of ${length} digits entered`}>
{Array.from({ length }, (_, i) => <span key={i} className="sd-pnl__dot" data-on={i < pin.length || undefined} />)}
</div>
<div className="sd-pnl__pad">
{keys.map((k) => k === 'reset'
? <button key={k} type="button" className="sd-pnl__k" data-ghost="" onClick={() => { setOk(false); setPin('') }} disabled={!ok}>{ok ? 'Lock' : ''}</button>
: <button key={k} type="button" className="sd-pnl__k" data-ghost={k === 'del' || undefined} aria-label={k === 'del' ? 'Delete' : k} disabled={!!lock || ok} onClick={() => press(k)}>
{k === 'del' ? <svg viewBox="0 0 24 24" aria-hidden><path d="M9 6h11v12H9l-5-6z" /><path d="m12 10 4 4m0-4-4 4" /></svg> : k}
</button>)}
</div>
</div>
)
}.sd-pnl { --bg: #1c1c1c; --fg: #f6f6f6; --mute: #8e8e8e; --line: rgb(255 255 255 / 0.1); --soft: rgb(255 255 255 / 0.07); --strong: #fafafa; --on: #111;
color: var(--fg); font-size: 14px; line-height: 1.4; -webkit-tap-highlight-color: transparent; }
.sd-pnl[data-fill='light'] { --bg: #fff; --fg: #121212; --mute: #6f6f6f; --line: rgb(0 0 0 / 0.09); --soft: rgb(0 0 0 / 0.05); --strong: #111; --on: #fff; }
.sd-pnl :where(button) { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-pnl :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-pnl .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-pnl { width: min(100%, 290px); padding: 22px 18px 18px; border-radius: 26px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); text-align: center; outline: 0; }
.sd-pnl__lock { display: grid; place-items: center; width: 38px; height: 38px; margin: 0 auto 10px; border-radius: 99px; background: var(--soft); }
.sd-pnl__lock svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; }
.sd-pnl__t { margin: 0; font-size: 15px; font-weight: 600; }
.sd-pnl__msg { min-height: 18px; margin: 4px 0 14px; font-size: 12.5px; color: var(--mute); font-variant-numeric: tabular-nums; }
.sd-pnl__dots { display: flex; justify-content: center; gap: 14px; height: 14px; margin-bottom: 20px; }
.sd-pnl__dots[data-shake] { animation: sd-pnl-shake 420ms cubic-bezier(0.36, 0.07, 0.19, 0.97); }
.sd-pnl__dot { width: 12px; height: 12px; border-radius: 99px; box-shadow: inset 0 0 0 1.5px var(--mute); transition: background-color 120ms, transform 160ms cubic-bezier(0.3, 1.6, 0.4, 1), box-shadow 120ms; }
.sd-pnl__dot[data-on] { background: var(--fg); box-shadow: inset 0 0 0 1.5px var(--fg); transform: scale(1.12); }
.sd-pnl[data-ok] .sd-pnl__dot { background: var(--fg); box-shadow: none; }
.sd-pnl__pad { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px 14px; justify-items: center; }
.sd-pnl__k { display: grid; place-items: center; width: 62px; height: 62px; border-radius: 99px; background: var(--soft) !important; font-size: 24px; font-weight: 500; font-variant-numeric: tabular-nums; transition: transform 90ms, background-color 120ms; touch-action: manipulation; }
.sd-pnl[data-pad='flat'] .sd-pnl__k { border-radius: 14px; width: 100%; height: 52px; }
.sd-pnl__k:active { transform: scale(0.94); background: var(--line) !important; }
.sd-pnl__k[data-ghost] { background: none !important; font-size: 13px; color: var(--mute); }
.sd-pnl__k:disabled { opacity: 0.35; cursor: not-allowed; }
.sd-pnl__k svg { width: 22px; height: 22px; fill: none; stroke: currentColor; stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round; }
@keyframes sd-pnl-shake { 10%, 90% { transform: translateX(-2px); } 20%, 80% { transform: translateX(4px); } 30%, 50%, 70% { transform: translateX(-8px); } 40%, 60% { transform: translateX(8px); } }
@media (prefers-reduced-motion: reduce) { .sd-pnl *, .sd-pnl *::before, .sd-pnl *::after { animation: none !important; transition: none !important; } }Overview
Tap the keys or type on a keyboard. Each digit fills a dot; at full length the code is checked. A wrong code shakes the dots, clears them and shows the tries left.
After the last try the pad locks with a live countdown, then unlocks itself. A correct code shows a calm confirmation.
When to use
- Unlocking an app, confirming a payment, parental gates.
When not to use
- Account sign-in on the web: use a password or passkey.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/pin-lock.json$
npx shadcn@latest add https://sundaeui.dev/r/pin-lock.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/pin-lock.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/pin-lock.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy pin-lock.tsx and pin-lock.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { PinLock } from '@/components/sundae/pin-lock'
<PinLock />Props
| Prop | Type | Default |
|---|---|---|
lengthDigits in the PIN. | number | 4 |
onSubmit*Check the code; return true to unlock. | (pin: string) => boolean | Promise<boolean> | — |
triesAttempts before lockout. | number | 3 |
lockSecondsLockout duration. | number | 30 |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- The dots are a status with the count of digits entered, never the digits themselves.
- Keys are real buttons; digits and Backspace work from a keyboard.
- Errors and the lockout countdown are announced politely.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-pnl; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
- Demo PIN is 2580 (or 258025 at six digits).
Keyboard
- 0–9
- Enter a digit
- Backspace
- Delete the last digit
FAQ
- Is PIN Lock free?
- Yes. PIN Lock is free for personal and commercial projects, with full source on this page.
- How do I install PIN Lock?
- Run npx shadcn@latest add https://sundaeui.dev/r/pin-lock.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does PIN Lock support keyboard and screen readers?
- The dots are a status with the count of digits entered, never the digits themselves. Keys are real buttons; digits and Backspace work from a keyboard.
- Does PIN Lock respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All forms →- Pro
Amount Keypad
Forms · Refined Two-Factor Setup
Forms · RefinedOTP Field
Forms · Refined- Pro
Drop Zone
Forms · Refined - Pro
Onboarding Flow
Forms · Refined - Pro
Wheel Picker
Forms · Refined