PIN Pad
The PIN entry pad is used for customer authentication in the customer-app. It features a 3x4 grid layout with haptic feedback and visual progress dots.
Masukkan PIN Anda
1
2
3
4
5
6
7
8
9
0
Lupa PIN?
Anatomy
+-------------------------------------------+
| |
| Masukkan PIN Anda |
| |
| ( ) ( ) ( ) ( ) ( ) ( ) |
| |
| +-----+ +-----+ +-----+ |
| | 1 | | 2 | | 3 | |
| +-----+ +-----+ +-----+ |
| +-----+ +-----+ +-----+ |
| | 4 | | 5 | | 6 | |
| +-----+ +-----+ +-----+ |
| +-----+ +-----+ +-----+ |
| | 7 | | 8 | | 9 | |
| +-----+ +-----+ +-----+ |
| +-----+ +-----+ +-----+ |
| | Del | | 0 | | OK | |
| +-----+ +-----+ +-----+ |
| |
| Lupa PIN? |
+-------------------------------------------+Visual Specifications
| Element | Style |
|---|---|
| Title | 18px, font-semibold, text-1, centered |
| Progress dots | 12px circles, 16px gap between |
| Empty dot | 2px border border-strong, transparent fill |
| Filled dot | Filled #D4910A (amber), scale-up animation on fill |
| Number buttons | 64x64px, rounded-2xl, bg-inset, text-1 |
| Number text | 24px, font-semibold |
| Button press | bg-hover, scale 0.95, 100ms transition |
| Delete button | Lucide Delete icon, 24px, text-2 |
| Submit button | Lucide Check icon, 24px, text-brand |
| "Lupa PIN?" link | 14px, text-brand, centered below pad |
Amber Theme
The PIN pad uses an amber accent instead of the default green to distinguish authentication from primary actions:
| Token | Value |
|---|---|
| Dot filled | #D4910A |
| Error shake | #C4463A |
| Success fill | #2D6B4A |
States
| State | Behavior |
|---|---|
| Entry | Dots fill left-to-right as digits are entered |
| Error | All dots turn red, shake animation (translateX +/- 8px, 3 cycles, 300ms), then reset |
| Success | All dots turn green, brief pause (300ms), then navigate |
| Locked | After 5 failed attempts, show lockout message with countdown timer |
React Component
tsx
interface PinPadProps {
length?: number
onComplete: (pin: string) => Promise<boolean>
onForgotPin?: () => void
}
export function PinPad({ length = 6, onComplete, onForgotPin }: PinPadProps) {
const [pin, setPin] = useState('')
const [state, setState] = useState<'idle' | 'error' | 'success'>('idle')
const [attempts, setAttempts] = useState(0)
const handleDigit = async (digit: string) => {
if (state !== 'idle' || pin.length >= length) return
// Haptic feedback
if ('vibrate' in navigator) navigator.vibrate(10)
const next = pin + digit
setPin(next)
if (next.length === length) {
const valid = await onComplete(next)
if (valid) {
setState('success')
} else {
setState('error')
setAttempts(a => a + 1)
setTimeout(() => {
setState('idle')
setPin('')
}, 600)
}
}
}
const handleDelete = () => {
if (state !== 'idle') return
if ('vibrate' in navigator) navigator.vibrate(10)
setPin(p => p.slice(0, -1))
}
const buttons = [
['1', '2', '3'],
['4', '5', '6'],
['7', '8', '9'],
['del', '0', 'ok'],
]
return (
<div className="flex flex-col items-center px-8">
<h2 className="text-lg font-semibold text-1 mb-6">Masukkan PIN Anda</h2>
{/* Dots */}
<div
className={cn('flex gap-4 mb-8', state === 'error' && 'animate-shake')}
>
{Array.from({ length }).map((_, i) => (
<div
key={i}
className={cn(
'w-3 h-3 rounded-full transition-all duration-150',
i < pin.length
? state === 'error' ? 'bg-danger scale-110'
: state === 'success' ? 'bg-success scale-110'
: 'bg-amber-500 scale-110'
: 'border-2 border-border-strong'
)}
/>
))}
</div>
{/* Button Grid */}
<div className="grid grid-cols-3 gap-3">
{buttons.flat().map(key => (
<button
key={key}
onClick={() => {
if (key === 'del') handleDelete()
else if (key === 'ok') { /* no-op, auto-submit on length */ }
else handleDigit(key)
}}
className={cn(
'w-16 h-16 rounded-2xl flex items-center justify-center transition-all duration-100',
'active:scale-95 active:bg-hover',
key === 'del' || key === 'ok' ? 'bg-transparent' : 'bg-inset',
)}
>
{key === 'del' ? (
<Delete className="w-6 h-6 text-2" />
) : key === 'ok' ? (
<Check className="w-6 h-6 text-brand" />
) : (
<span className="text-2xl font-semibold text-1">{key}</span>
)}
</button>
))}
</div>
{/* Forgot PIN */}
{onForgotPin && (
<button onClick={onForgotPin} className="mt-6 text-sm text-brand">
Lupa PIN?
</button>
)}
{/* Lockout */}
{attempts >= 5 && (
<p className="mt-4 text-sm text-danger text-center">
Terlalu banyak percobaan. Coba lagi dalam 5 menit.
</p>
)}
</div>
)
}Haptic Feedback
| Action | Vibration | iOS Equivalent |
|---|---|---|
| Digit press | 10ms light | UIImpactFeedbackGenerator(.light) |
| Delete press | 10ms light | UIImpactFeedbackGenerator(.light) |
| PIN complete (success) | 50ms medium | UINotificationFeedbackGenerator(.success) |
| PIN error | 100ms, 50ms pause, 100ms | UINotificationFeedbackGenerator(.error) |
CSS Shake Animation
css
@keyframes shake {
0%, 100% { transform: translateX(0); }
20%, 60% { transform: translateX(-8px); }
40%, 80% { transform: translateX(8px); }
}
.animate-shake {
animation: shake 0.3s ease-in-out;
}Accessibility
Provide a "Show PIN" toggle for users who need visual confirmation. When active, show digits in the dots instead of filled circles.
Don't
- Don't store the PIN in plain text --- always hash before transmitting
- Don't log PIN entry attempts with the actual digits
- Don't disable haptic feedback --- it confirms key registration for the user