EmpireUI
Get Pro
← Blog8 min read#neumorphism#keyboard#css

Neumorphism Keyboard UI: Soft Key Caps and Press Animation in CSS

Build a tactile neumorphic keyboard UI in pure CSS — soft key caps, convincing press animations, and the exact shadow values that make the illusion work.

Soft white mechanical keyboard with neumorphic raised key caps

Why Keyboards Are the Perfect Neumorphism Showcase

Neumorphism works best when the UI element it's mimicking already exists in the physical world with a raised, pressable surface. Keys are the obvious candidate. A physical keycap sits slightly proud of the board, catches light from above-left, casts a soft shadow below-right, and then depresses about 2–4 mm when you press it. That's a mechanical description, but it's also a CSS recipe.

Honestly, most neumorphism demos online are cards and toggles — fine, but boring. A keyboard pushes the technique into something interactive and immediately legible. When a user presses a key and the shadow inverts, they *feel* the click even without haptic feedback. That perceptual trick is what makes the style worth building.

Worth noting: neumorphism landed as a design trend around 2020, championed heavily by Michal Malewicz's Dribbble post that got 100k+ views. Since then it's been refined significantly — the early implementations were accessibility disasters, but with the right contrast ratios and state handling you can ship it production-ready. You can browse ready-made neumorphic patterns in the Empire UI neumorphism hub to see what production-quality actually looks like.

This guide goes deep on the shadow math, the animation timing, and the keyboard layout structure. We're building this in pure CSS first so you understand what's happening, then wrapping it in a React component you'd actually use.

The Shadow Math Behind a Raised Key Cap

Neumorphism is basically a two-shadow trick. One light shadow in the upper-left direction simulates a light source hitting the raised surface. One dark shadow in the lower-right direction simulates the object's cast shadow on the recessed base. The surface color sits exactly between the two shadow colors — that's the constraint that makes the math work.

For a base color of #e0e5ec, your light shadow is roughly #ffffff and your dark shadow is something like #a3b1c6. The spread matters as much as the color. Use values around 6px 6px 12px for the dark and -6px -6px 12px for the light on a standard key cap sized 48 × 48 px. Go bigger on both and the keys look puffy; go smaller and they look flat.

.key {
  background: #e0e5ec;
  border-radius: 8px;
  width: 48px;
  height: 48px;
  box-shadow:
    6px 6px 12px #a3b1c6,
    -6px -6px 12px #ffffff;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 13px;
  font-weight: 600;
  color: #6b7a99;
  cursor: pointer;
  user-select: none;
  transition: box-shadow 80ms ease, transform 80ms ease;
}

The transition on box-shadow with an 80ms ease is critical. Too slow (200ms+) and the press feels laggy — real keys respond in under 50ms mechanically, so you want the animation to feel snappy. Too fast and you won't perceive it at all. 80ms is the sweet spot I've landed on after testing with users.

The Press State: Inverting the Shadow

The press animation is where neumorphism earns its keep. When a key is active, you invert the shadow direction — the element appears to sink *into* the surface rather than sit above it. This is an inset box-shadow, and you combine it with a tiny translateY(1px) to reinforce the physical metaphor.

.key:active,
.key.pressed {
  box-shadow:
    inset 3px 3px 7px #a3b1c6,
    inset -3px -3px 7px #ffffff;
  transform: translateY(1px);
}

Quick aside: the .pressed class is there for keyboard-driven interactions. If you're building an actual virtual keyboard (for an on-screen input, a music instrument, a shortcut visualizer), you'll programmatically add that class via JavaScript when a physical key event fires. Using :active alone only handles mouse/touch clicks, not keydown events.

You can also add a subtle scale(0.97) to the transform instead of translateY if your keys are arranged on a surface with perspective — the scale reads more naturally when you're looking at the keyboard at an angle. In practice, translateY(1px) looks better for the flat, top-down view that most keyboard UIs use.

/* Optional: focus ring for accessibility */
.key:focus-visible {
  outline: 2px solid #7c8cf8;
  outline-offset: 2px;
}

Building the Keyboard Layout in React

The shadow math is done. Now let's structure an actual keyboard. A standard QWERTY row-based layout with variable key widths is straightforward to build as data-driven React. You define the key rows as arrays and let the component handle rendering.

// NeuKey.tsx
import { useState, useEffect } from 'react';

interface NeuKeyProps {
  label: string;
  code?: string;       // e.g. 'KeyA', 'Space'
  width?: number;      // multiplier — 1 = 48px
}

export function NeuKey({ label, code, width = 1 }: NeuKeyProps) {
  const [pressed, setPressed] = useState(false);

  useEffect(() => {
    if (!code) return;
    const down = (e: KeyboardEvent) => e.code === code && setPressed(true);
    const up   = (e: KeyboardEvent) => e.code === code && setPressed(false);
    window.addEventListener('keydown', down);
    window.addEventListener('keyup', up);
    return () => {
      window.removeEventListener('keydown', down);
      window.removeEventListener('keyup', up);
    };
  }, [code]);

  return (
    <div
      className={`neu-key ${pressed ? 'pressed' : ''}`}
      style={{ width: `${width * 48 + (width - 1) * 6}px` }}
      onMouseDown={() => setPressed(true)}
      onMouseUp={() => setPressed(false)}
      onMouseLeave={() => setPressed(false)}
    >
      {label}
    </div>
  );
}

The width multiplier handles wide keys like Shift (width=2.25), Backspace (width=2), and Space (width=6.25). The pixel formula width * 48 + (width - 1) * 6 accounts for the 6px gap between keys so wide keys align correctly with the grid.

// NeuKeyboard.tsx
import { NeuKey } from './NeuKey';

const ROWS = [
  [
    { label: 'Q', code: 'KeyQ' },
    { label: 'W', code: 'KeyW' },
    { label: 'E', code: 'KeyE' },
    { label: 'R', code: 'KeyR' },
    { label: 'T', code: 'KeyT' },
    { label: 'Y', code: 'KeyY' },
    { label: 'U', code: 'KeyU' },
    { label: 'I', code: 'KeyI' },
    { label: 'O', code: 'KeyO' },
    { label: 'P', code: 'KeyP' },
  ],
  // ... add A-L and Z-M rows
];

export function NeuKeyboard() {
  return (
    <div className="neu-keyboard">
      {ROWS.map((row, i) => (
        <div key={i} className="neu-keyboard__row">
          {row.map((key) => (
            <NeuKey key={key.code} {...key} />
          ))}
        </div>
      ))}
    </div>
  );
}

That useEffect cleanup in NeuKey is important. Without removing the event listeners, you'd leak them every time the component unmounts — especially relevant if you're conditionally rendering the keyboard (a modal shortcut visualizer, for instance). Don't skip that return function.

Color Themes and Dark Mode

The default #e0e5ec palette is light-mode only. Neumorphism on dark surfaces uses a different math: your base is a dark gray like #1e1e2e, the light shadow becomes a slightly lighter shade (#2a2a3e), and the dark shadow goes nearly black (#12121e). The contrast difference between the two shadows is smaller on dark surfaces, so you need to compensate with slightly stronger spread values — try 8px instead of 6px.

@media (prefers-color-scheme: dark) {
  .neu-keyboard {
    --neu-base:  #1e1e2e;
    --neu-light: #2a2a3e;
    --neu-dark:  #12121e;
    --neu-text:  #7c7ca8;
    background: var(--neu-base);
  }

  .key {
    background: var(--neu-base);
    color: var(--neu-text);
    box-shadow:
      8px 8px 16px var(--neu-dark),
      -8px -8px 16px var(--neu-light);
  }

  .key:active,
  .key.pressed {
    box-shadow:
      inset 4px 4px 8px var(--neu-dark),
      inset -4px -4px 8px var(--neu-light);
  }
}

Look, dark neumorphism is genuinely tricky to get right. The shadows need enough contrast to be perceptible but not so much that they look like embossing. I'd recommend using the Empire UI box shadow generator to dial in the values interactively rather than guessing — you can preview both light and dark base colors in real time.

One more thing — if you want to add color accent keys (like a highlighted modifier key or a lit-up hotkey), keep the shadow formula the same but tint the base color. A blue accent key at #3b5bdb with shadows derived from #2f4ab0 (dark) and #4a70ff (light) will look consistent with the rest of the board without breaking the neumorphic illusion.

Animating Key Sequences and Macros

A static neumorphic keyboard is pretty. An animated one demonstrating a keyboard shortcut is genuinely useful for documentation, onboarding flows, and interactive tutorials. You can drive a press sequence with a simple async function that sets pressed state on each key with a delay.

// useKeySequence.ts
import { useState, useCallback } from 'react';

export function useKeySequence() {
  const [activeKeys, setActiveKeys] = useState<Set<string>>(new Set());

  const playSequence = useCallback(async (
    steps: Array<string[]>,  // e.g. [['MetaLeft'], ['KeyZ']]
    holdMs = 120,
    gapMs = 80
  ) => {
    for (const chord of steps) {
      setActiveKeys(new Set(chord));
      await new Promise(r => setTimeout(r, holdMs));
      setActiveKeys(new Set());
      await new Promise(r => setTimeout(r, gapMs));
    }
  }, []);

  return { activeKeys, playSequence };
}

Pass activeKeys down to your NeuKey components and check activeKeys.has(code) to drive the .pressed class. This decouples the animation driver from the key rendering — you can trigger sequences from a button, an IntersectionObserver scroll trigger, or a guided tour library without touching the key components themselves.

That said, don't go overboard with simultaneous key presses. Neumorphism's press animation depends on individual shadow inversion — when you press more than 3–4 keys at once visually, the board starts to look chaotic rather than tactile. Keep macro sequences to 2-key chords at most when using this for documentation demos.

Performance and Accessibility Checklist

Box-shadow animations are GPU-composited in modern browsers, so 80ms shadow transitions won't tank your performance. What *will* tank performance is rendering 104 individual DOM elements (full keyboard) each with their own event listeners. The useKeySequence hook above handles this correctly by keeping state at the keyboard level, not per-key. Still, if you're rendering multiple keyboards on the same page, profile with Chrome DevTools first.

Accessibility deserves a section of its own. Neumorphism's biggest real-world problem is contrast — the grey-on-grey palette frequently fails WCAG AA's 4.5:1 ratio for text. For key labels, you need at least color: #4a5568 on the #e0e5ec base, which gives you about 4.6:1. Don't go lighter than that. And make every key focusable with tabIndex={0} so keyboard-only users can actually interact with your keyboard UI (yes, the irony of a keyboard UI that's inaccessible to keyboard users is real).

<div
  role="button"
  tabIndex={0}
  aria-label={`Key ${label}`}
  aria-pressed={pressed}
  onKeyDown={(e) => e.key === 'Enter' && setPressed(true)}
  onKeyUp={(e) => e.key === 'Enter' && setPressed(false)}
  className={`neu-key ${pressed ? 'pressed' : ''}`}
>
  {label}
</div>

For users who've set prefers-reduced-motion: reduce, skip the transform: translateY(1px) — the shadow inversion still communicates the press state without any movement. That's a one-liner fix that keeps your component respectful of system preferences.

If you want to see how neumorphism pairs with other tactile styles — and when you'd reach for one versus the other — the Empire UI neumorphism section breaks down the full component library, including form elements, sliders, and toggle switches that follow the same shadow conventions as the keyboard keys here.

FAQ

Does neumorphism work on dark backgrounds?

Yes, but the shadow contrast needs to be higher to stay visible. Use a base around #1e1e2e, derive your light shadow 10–15% lighter, and your dark shadow 10–15% darker. Spread values of 8px work better than the 6px you'd use on light surfaces.

Why does my neumorphic key look flat instead of raised?

Almost always a surface color mismatch. Your background and your key's background must be the same color — the shadows only create the raised illusion when the surface behind the element matches. Also check that your light shadow is actually lighter than the base and your dark shadow is actually darker.

Can I use neumorphism in a production keyboard shortcut visualizer?

Yes, with two caveats. Pass aria-pressed and role="button" on every key so screen readers can follow along, and verify your key label contrast ratio hits WCAG AA. The shadow animations are GPU-composited so performance isn't the concern.

What's the right border-radius for key caps?

For a standard flat key, 8px looks natural. For a more rounded, almost-clay look, try 12px. Go above 16px and keys start to look like pills rather than keys — it breaks the physical metaphor. Match the radius to the overall UI density you're working with.

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

Read next

Neumorphism Progress Ring: Soft UI Circular Indicator in CSSWhat Is Neumorphism? Soft UI Explained with Free React CodeCSS Loading Spinners: 12 Variants From Dots to Rings to BarsCSS Hover Effects Gallery: 10 Patterns Beyond color and opacity