EmpireUI
Get Pro
← Blog8 min read#virtual keyboard#react#numpad

Virtual Keyboard in React: Numpad, PIN Entry, Custom Layouts

Build virtual keyboards in React - numpad, PIN entry, and fully custom layouts. Covers state, accessibility, styling, and real component code.

Developer typing on a laptop keyboard with code on screen

Why You'd Build a Virtual Keyboard at All

Most devs reach for a virtual keyboard in two situations: kiosk-style apps where a physical keyboard doesn't exist, and PIN or payment flows where you want to prevent OS-level keyloggers from capturing numeric input. Both are legitimate. Neither is simple if you try to wing it.

The native <input type='number'> or <input inputMode='numeric'> approach works fine on mobile - it pops up the device's number pad. But on desktop kiosks, embedded devices, or touch-screen POS systems running Electron, you're on your own. You need to render the keyboard yourself.

Honestly, it's also a UX choice. Some fintech apps deliberately block the system keyboard on PIN entry screens and render their own numpad. The keys are randomized on each render so shoulder-surfers can't memorize tap positions. That's a real security pattern, not paranoia.

Worth noting: building a virtual keyboard that's actually accessible - screen-reader-friendly, keyboard-navigable when a physical keyboard *is* present - is where most implementations fall short. We'll cover that too.

The Core Hook: Managing Input State

Before you render a single button, you need a clear state model. A PIN entry and a full numpad share the same fundamental shape: a string of characters that grows on key press and shrinks on backspace. Simple.

import { useState, useCallback } from 'react';

type UseVirtualInputOptions = {
  maxLength?: number;
  onChange?: (value: string) => void;
  onSubmit?: (value: string) => void;
};

export function useVirtualInput({
  maxLength = Infinity,
  onChange,
  onSubmit,
}: UseVirtualInputOptions = {}) {
  const [value, setValue] = useState('');

  const press = useCallback((key: string) => {
    if (key === 'backspace') {
      setValue(prev => {
        const next = prev.slice(0, -1);
        onChange?.(next);
        return next;
      });
    } else if (key === 'submit') {
      onSubmit?.(value);
    } else if (value.length < maxLength) {
      setValue(prev => {
        const next = prev + key;
        onChange?.(next);
        return next;
      });
    }
  }, [value, maxLength, onChange, onSubmit]);

  const clear = useCallback(() => {
    setValue('');
    onChange?.('');
  }, [onChange]);

  return { value, press, clear };
}

The press function is the single entry point for all key interactions. It handles three cases: backspace, submit, and everything else. You can pass it directly to button onClick handlers. Clean.

One more thing - if you're building a PIN entry specifically, you'll want maxLength set to 4 or 6 and you probably want onSubmit to fire automatically when the user hits the limit, not wait for a separate confirm button. Add that to press: after appending a character, check if next.length === maxLength and call onSubmit?.(next). That's the pattern you see in every banking app since at least 2019.

Building the Numpad Component

A numpad is a 3×4 grid: digits 1–9 on top, then 0 in the bottom center flanked by a clear and a backspace. The layout varies slightly by context - ATM-style puts 0 at the bottom, phone-style puts 1 at the top-left, but the grid structure is always the same.

const NUMPAD_KEYS = [
  '1', '2', '3',
  '4', '5', '6',
  '7', '8', '9',
  'clear', '0', 'backspace',
];

type NumpadProps = {
  onKey: (key: string) => void;
  disabled?: boolean;
};

export function Numpad({ onKey, disabled }: NumpadProps) {
  return (
    <div
      role="group"
      aria-label="Numeric keypad"
      className="grid grid-cols-3 gap-2 w-fit"
    >
      {NUMPAD_KEYS.map((key) => (
        <button
          key={key}
          type="button"
          disabled={disabled}
          aria-label={
            key === 'backspace' ? 'Delete last digit'
            : key === 'clear' ? 'Clear all'
            : key
          }
          onClick={() => onKey(key)}
          className="
            w-16 h-16 rounded-xl text-lg font-semibold
            bg-white/10 hover:bg-white/20 active:scale-95
            transition-all duration-100 border border-white/20
          "
        >
          {key === 'backspace' ? '⌫' : key === 'clear' ? 'C' : key}
        </button>
      ))}
    </div>
  );
}

A few things to pay attention to here. The role="group" with aria-label tells screen readers this is a logical collection of related controls. Individual buttons get explicit aria-label values - '⌫' means nothing to a screen reader, but 'Delete last digit' does. This is the detail most implementations skip.

The styling uses Tailwind with bg-white/10 - that 10% white overlay on a dark background is a classic glassmorphism pattern. If you're already using glassmorphism components in your project, this slots right in. The active:scale-95 gives you that tactile press feedback at essentially 0 cost.

In practice, the 64×64px (w-16 h-16) button size is the minimum you'd want for touch targets. Apple's HIG says 44pt minimum, Google's Material says 48dp minimum. 64px gives you comfortable margin. Go smaller and you'll get mis-taps on larger fingers.

PIN Entry: Display, Masking, and Security Patterns

The PIN display is usually a row of dots or circles - filled when a digit is entered, empty when not. You don't want to render the actual digits since someone might glance at the screen. The display is purely decorative from a data perspective.

type PinDisplayProps = {
  length: number;
  filledCount: number;
  shake?: boolean;
};

export function PinDisplay({ length, filledCount, shake }: PinDisplayProps) {
  return (
    <div
      className={`flex gap-3 justify-center ${
        shake ? 'animate-[shake_0.4s_ease-in-out]' : ''
      }`}
      aria-label={`${filledCount} of ${length} digits entered`}
      aria-live="polite"
    >
      {Array.from({ length }).map((_, i) => (
        <div
          key={i}
          className={`
            w-4 h-4 rounded-full border-2 transition-all duration-150
            ${
              i < filledCount
                ? 'bg-white border-white scale-110'
                : 'bg-transparent border-white/40'
            }
          `}
        />
      ))}
    </div>
  );
}

The aria-live="polite" on the container means screen readers announce digit count changes without interrupting other speech. The aria-label gives a full status at focus time. That shake animation - triggered on wrong PIN - needs a CSS keyframe. Add this to your global CSS: ``css @keyframes shake { 0%, 100% { transform: translateX(0); } 20% { transform: translateX(-8px); } 40% { transform: translateX(8px); } 60% { transform: translateX(-6px); } 80% { transform: translateX(6px); } } ``

For the randomized-layout security pattern, shuffle NUMPAD_KEYS on each render using useMemo with no dependency. The keys re-randomize each time the component mounts - so every new PIN entry session shows a different layout. This is the pattern Revolut and N26 have used since around 2021.

Quick aside: never store the PIN value in component state longer than necessary. Pass it to your verification function immediately on completion, then call clear(). Don't hold it in context or Redux. The PIN should live for milliseconds.

Custom Layouts: Beyond the Numpad

Sometimes you need more than digits. A calculator layout, a currency pad with a decimal key, or a full alphanumeric layout for kiosk search - these all follow the same pattern but need a flexible key definition system.

type KeyDef = {
  label: string;      // what to display
  value: string;      // what gets appended (or 'backspace'/'submit')
  ariaLabel?: string;
  span?: number;      // grid column span
  variant?: 'default' | 'action' | 'danger';
};

type CustomKeyboardProps = {
  keys: KeyDef[][];
  onKey: (value: string) => void;
  disabled?: boolean;
};

export function CustomKeyboard({ keys, onKey, disabled }: CustomKeyboardProps) {
  return (
    <div role="group" aria-label="Virtual keyboard" className="flex flex-col gap-1.5">
      {keys.map((row, rowIdx) => (
        <div key={rowIdx} className="flex gap-1.5">
          {row.map((key) => (
            <button
              key={key.value}
              type="button"
              disabled={disabled}
              aria-label={key.ariaLabel ?? key.label}
              onClick={() => onKey(key.value)}
              style={key.span ? { flex: key.span } : { flex: 1 }}
              className={`
                h-14 rounded-lg font-medium text-sm transition-all
                active:scale-95 select-none
                ${
                  key.variant === 'action'
                    ? 'bg-blue-500 hover:bg-blue-400 text-white'
                    : key.variant === 'danger'
                    ? 'bg-red-500/80 hover:bg-red-400 text-white'
                    : 'bg-white/10 hover:bg-white/20 text-white'
                }
              `}
            >
              {key.label}
            </button>
          ))}
        </div>
      ))}
    </div>
  );
}

The span property maps to flex: N - a key with span: 2 takes twice the width of a normal key. That's how you get the wide zero button on a calculator, or a wide submit button at the bottom. No CSS grid gymnastics needed.

Here's how you'd define a calculator layout: ``tsx const CALC_KEYS: KeyDef[][] = [ [ { label: 'C', value: 'clear', variant: 'danger' }, { label: '±', value: 'negate', variant: 'action' }, { label: '%', value: '%', variant: 'action' }, { label: '÷', value: '/', variant: 'action' }, ], [ { label: '7', value: '7' }, { label: '8', value: '8' }, { label: '9', value: '9' }, { label: '×', value: '*', variant: 'action' }, ], [ { label: '4', value: '4' }, { label: '5', value: '5' }, { label: '6', value: '6' }, { label: '−', value: '-', variant: 'action' }, ], [ { label: '1', value: '1' }, { label: '2', value: '2' }, { label: '3', value: '3' }, { label: '+', value: '+', variant: 'action' }, ], [ { label: '0', value: '0', span: 2 }, { label: '.', value: '.' }, { label: '=', value: 'submit', variant: 'action' }, ], ]; ``

Look, this declarative approach scales. You want a hex input pad? Define 0–9 plus A–F. You want a currency pad with preset amounts? Add rows with values like '5.00', '10.00', '20.00'. The rendering component doesn't care - it just maps keys to buttons.

Styling Options: Glassmorphism, Flat, Neobrutalism

The layout and logic are style-agnostic, which means you can skin these components however your design system needs. Three common approaches worth considering.

Glassmorphism is the default choice for dark-themed kiosks and payment UIs - frosted glass, subtle borders, depth without shadows. Combine backdrop-blur-md, bg-white/10, and border border-white/15 on each key. Pair it with the glassmorphism generator to dial in the backdrop blur and background opacity before committing to values in code.

Flat/Material is cleaner for enterprise kiosk UIs where you want maximum legibility under fluorescent lighting. Solid fill colors, high-contrast text, no blur effects. Use bg-gray-800 and text-white with a hover:bg-gray-700 state. Sometimes boring is right.

Neobrutalism works surprisingly well for consumer-facing POS interfaces or fun checkout experiences - thick borders, hard drop shadows offset by 3–4px, bold fonts. Check the neobrutalism style hub if you want to see how that aesthetic translates to component design. Something like border-2 border-black shadow-[3px_3px_0px_black] on each button with a warm background color makes a numpad feel punchy and tactile.

Whatever you pick, keep the active/press state obvious. active:scale-95 with a 100ms transition is the lowest-effort way to make keys feel physically responsive. On touch devices, that press feedback is the difference between the keyboard feeling native and feeling broken.

Putting It Together: A Complete PIN Entry Flow

Here's the full composition - hook, display, numpad - wired into a single component you can drop into any form:

import { useState } from 'react';
import { useVirtualInput } from './useVirtualInput';
import { PinDisplay } from './PinDisplay';
import { Numpad } from './Numpad';

const PIN_LENGTH = 6;

export function PinEntry({ onSuccess }: { onSuccess: (pin: string) => void }) {
  const [shake, setShake] = useState(false);
  const [error, setError] = useState<string | null>(null);

  const { value, press, clear } = useVirtualInput({
    maxLength: PIN_LENGTH,
    onSubmit: async (pin) => {
      const ok = await verifyPin(pin); // your API call
      if (!ok) {
        setShake(true);
        setError('Incorrect PIN. Try again.');
        setTimeout(() => {
          setShake(false);
          clear();
          setError(null);
        }, 600);
      } else {
        onSuccess(pin);
      }
    },
  });

  return (
    <div className="flex flex-col items-center gap-6 p-8">
      <h2 className="text-xl font-semibold text-white">Enter your PIN</h2>

      <PinDisplay length={PIN_LENGTH} filledCount={value.length} shake={shake} />

      {error && (
        <p role="alert" className="text-red-400 text-sm">
          {error}
        </p>
      )}

      <Numpad
        onKey={press}
        disabled={shake} // prevent rapid re-entry during shake animation
      />
    </div>
  );
}

The disabled={shake} trick prevents a user from hammering keys during the error animation. Small thing, but it stops double submissions and makes the validation feel more deliberate.

The role="alert" on the error paragraph makes screen readers announce it immediately on render - no need for aria-live here since the element appears conditionally. This is the pattern the WAI-ARIA spec recommends for error messages since ARIA 1.1.

That said, you'll want to add an attempt counter and lockout logic on the server side. Client-side rate limiting (disabling the numpad after 3 attempts) is UX, not security. Never rely on it alone. Your backend should enforce lockouts after failed attempts regardless of what the client does.

FAQ

Can I use a virtual keyboard with a regular HTML input element?

Yes - just pass the value from your hook to the input's value prop and set readOnly on the input so the system keyboard doesn't open. The virtual keyboard drives all input through your state.

How do I prevent the mobile keyboard from appearing when the input is focused?

Add readOnly to the input element, or use inputMode='none'. Either tells mobile browsers not to show the system keyboard. readOnly is more widely supported across older Android WebViews.

Is it possible to randomize the key order on each render for security?

Yes - wrap your keys array in useMemo with an empty dependency array and shuffle it inside. Since useMemo runs on mount, you get a new order each time the component mounts but stability within the same session.

What's the best way to handle decimal input on a virtual numpad?

Add a '.' key to your layout and reject it if the current value already contains one: if (key === '.' && value.includes('.')) return. Keep the check inside your press handler or the hook itself.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Date Picker in React: react-day-picker v9 and Custom ApproachOTP Input in React: 6-Digit Code Entry With Auto-Focus and PasteGlassmorphism Form Design: Login, Signup and Contact FormsReact Hook Form + Zod: The Form Stack That Finally Makes Sense