Laptop Keyboard That Lights Up Keys as It Types

A full laptop keyboard drawn with plain elements and Tailwind classes, under a command field. The demo types three commands one after another: each letter appears in the field while its key dips and turns blue, then the return key fires and the line is confirmed underneath. Press keys on your own keyboard and the drawn keys follow you instead. Keys are sized in units, so the board scales from desktop down to a phone, where the small word labels are hidden and only the symbols stay.

Code

// Adapted from Cult UI (https://cult-ui.com/docs/components/apple-keyboard)
// Copyright (c) 2023 Jordan-Gilliam. MIT License: https://we.inc/components/licenses
import { useEffect, useState } from "react";
import { Search } from "lucide-react";

/** One key: an id, what is printed on it, an optional small word under it and its width in key units. */
type K = { id: string; top: string; word?: string; u?: number };

const letters = (row: string): K[] => row.split("").map((c) => ({ id: c, top: c.toUpperCase() }));

const ROWS: K[][] = [
  [{ id: "esc", top: "esc", u: 1.5 }, ...Array.from({ length: 12 }, (_, i) => ({ id: `f${i + 1}`, top: `F${i + 1}` })), { id: "lock", top: "⏻" }],
  [...letters("`1234567890-="), { id: "delete", top: "⌫", word: "delete", u: 1.5 }],
  [{ id: "tab", top: "⇥", word: "tab", u: 1.5 }, ...letters("qwertyuiop[]\\")],
  [{ id: "caps", top: "⇪", word: "caps lock", u: 1.75 }, ...letters("asdfghjkl;'"), { id: "return", top: "⏎", word: "return", u: 1.75 }],
  [{ id: "shift-l", top: "⇧", word: "shift", u: 2.25 }, ...letters("zxcvbnm,./"), { id: "shift-r", top: "⇧", word: "shift", u: 2.25 }],
  [
    { id: "fn", top: "fn" },
    { id: "control", top: "⌃", word: "control" },
    { id: "option-l", top: "⌥", word: "option" },
    { id: "command-l", top: "⌘", word: "command", u: 1.25 },
    { id: " ", top: "", u: 5 },
    { id: "command-r", top: "⌘", word: "command", u: 1.25 },
    { id: "option-r", top: "⌥", word: "option" },
    { id: "left", top: "◀" },
    { id: "down", top: "▼" },
    { id: "right", top: "▶" },
  ],
];

/** What the demo types, one line after another. Each ends with the return key. */
const LINES = ["open invoices from march", "remind ana about the deposit", "export q3 as a spreadsheet"];

/** Milliseconds between key presses, and how long a key stays down. */
const STEP_MS = 115;
const HOLD_MS = 95;

/** Names the browser reports for keys that are not a single character. */
const CODE_TO_ID: Record<string, string> = {
  Escape: "esc", Backspace: "delete", Tab: "tab", CapsLock: "caps", Enter: "return", ShiftLeft: "shift-l", ShiftRight: "shift-r",
  ControlLeft: "control", AltLeft: "option-l", AltRight: "option-r", MetaLeft: "command-l", MetaRight: "command-r", Space: " ",
  ArrowLeft: "left", ArrowDown: "down", ArrowRight: "right", ArrowUp: "down",
};

export default function LaptopKeyboardThatLightsUpKeysAsItTypes() {
  const [down, setDown] = useState<string[]>([]);
  const [text, setText] = useState("");
  const [sent, setSent] = useState<string | null>(null);
  const [auto, setAuto] = useState(true);

  const tap = (id: string) => {
    setDown((d) => (d.includes(id) ? d : [...d, id]));
    window.setTimeout(() => setDown((d) => d.filter((k) => k !== id)), HOLD_MS);
  };

  // The self-typing demo. It stops for good once a real key is pressed.
  useEffect(() => {
    if (!auto) return;
    if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
      setText(LINES[0]);
      return;
    }
    let line = 0;
    let i = 0;
    let timer = 0;
    const next = () => {
      const target = LINES[line];
      if (i < target.length) {
        tap(target[i]);
        setText(target.slice(0, i + 1));
        i += 1;
        timer = window.setTimeout(next, STEP_MS + (target[i - 1] === " " ? 60 : 0));
      } else {
        tap("return");
        setSent(target);
        timer = window.setTimeout(() => {
          setText("");
          line = (line + 1) % LINES.length;
          i = 0;
          timer = window.setTimeout(next, 500);
        }, 1100);
      }
    };
    timer = window.setTimeout(next, 150);
    return () => window.clearTimeout(timer);
  }, [auto]);

  // Real keyboard: light the matching key and type into the field.
  useEffect(() => {
    const onDown = (e: KeyboardEvent) => {
      if (e.target instanceof HTMLElement && /^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName)) return;
      const id = CODE_TO_ID[e.code] ?? e.key.toLowerCase();
      if (!ROWS.some((row) => row.some((k) => k.id === id))) return;
      if (auto) {
        setAuto(false);
        setText("");
      }
      setDown((d) => (d.includes(id) ? d : [...d, id]));
      if (id === "delete") setText((t) => t.slice(0, -1));
      else if (id === "return") setText((t) => (setSent(t || null), ""));
      else if (e.key.length === 1 && !e.metaKey && !e.ctrlKey) setText((t) => (t + e.key).slice(-38));
    };
    const onUp = (e: KeyboardEvent) => {
      const id = CODE_TO_ID[e.code] ?? e.key.toLowerCase();
      setDown((d) => d.filter((k) => k !== id));
    };
    window.addEventListener("keydown", onDown);
    window.addEventListener("keyup", onUp);
    return () => {
      window.removeEventListener("keydown", onDown);
      window.removeEventListener("keyup", onUp);
    };
  }, [auto]);

  return (
    <section className="bg-slate-100 px-3 py-16 sm:px-6 sm:py-20">
      <style>{`
        @keyframes laptop-keyboard-that-lights-up-keys-as-it-types-caret { 0%, 49% { opacity: 1; } 50%, 100% { opacity: 0; } }
        .laptop-keyboard-that-lights-up-keys-as-it-types-caret { animation: laptop-keyboard-that-lights-up-keys-as-it-types-caret 1s steps(1) infinite; }
        @media (prefers-reduced-motion: reduce) { .laptop-keyboard-that-lights-up-keys-as-it-types-caret { animation: none; } }
      `}</style>
      <div className="mx-auto max-w-3xl">
        <div className="text-center">
          <h2 className="text-3xl font-semibold tracking-tight text-slate-900 sm:text-4xl">Run the whole back office from the keyboard.</h2>
          <p className="mx-auto mt-3 max-w-lg text-base leading-7 text-slate-600">Say what you need in plain words and press return. Try it: the keys below follow your own keyboard too.</p>
        </div>

        <div className="mx-auto mt-10 max-w-xl">
          <div className="flex h-12 items-center gap-3 rounded-2xl border border-slate-200 bg-white px-4 shadow-sm">
            <Search aria-hidden="true" className="h-4 w-4 shrink-0 text-slate-400" />
            <p className="min-w-0 flex-1 truncate text-left text-sm text-slate-900 sm:text-base">
              {text || <span className="text-slate-400">{auto ? "" : "Type a command"}</span>}
              <span aria-hidden="true" className="laptop-keyboard-that-lights-up-keys-as-it-types-caret ml-px inline-block h-4 w-px translate-y-0.5 bg-sky-600" />
            </p>
            <kbd className="rounded-md border border-slate-200 bg-slate-50 px-1.5 py-0.5 text-xs text-slate-600">⏎</kbd>
          </div>
          <p className="mt-2 h-5 truncate text-center text-sm text-slate-600">{sent ? <>Done: <span className="font-medium text-slate-900">{sent}</span></> : " "}</p>
        </div>

        <div role="img" aria-label="A laptop keyboard whose keys light up as the command is typed" className="mx-auto mt-6 select-none rounded-xl bg-gradient-to-b from-slate-300 to-slate-400/80 p-1.5 shadow-xl shadow-slate-900/15 ring-1 ring-white/60 sm:rounded-2xl sm:p-3">
          <div className="flex flex-col gap-[3px] sm:gap-1">
            {ROWS.map((row, r) => (
              <div key={r} className="flex gap-[3px] sm:gap-1">
                {row.map((k) => {
                  const pressed = down.includes(k.id);
                  return (
                    <span
                      key={k.id}
                      onPointerDown={() => tap(k.id)}
                      style={{ flex: `${k.u ?? 1} 1 0%` }}
                      className={`flex min-w-0 flex-col items-center justify-center rounded-[4px] ring-1 transition-all duration-75 ease-out sm:rounded-md ${r === 0 ? "h-5 sm:h-8" : "h-7 sm:h-11 md:h-12"} ${
                        pressed
                          ? "translate-y-px bg-sky-200 text-sky-950 shadow-[inset_0_1px_3px_rgba(2,132,199,0.45)] ring-sky-500"
                          : "bg-gradient-to-b from-white to-slate-50 text-slate-700 shadow-[inset_0_1px_0_#fff,0_1px_1px_rgba(15,23,42,0.12)] ring-black/10"
                      }`}
                    >
                      <span className={`leading-none ${k.top.length > 1 ? "text-[6px] sm:text-[10px]" : "text-[9px] sm:text-sm"}`}>{k.top}</span>
                      {k.word && <span className="mt-1 hidden text-[8px] leading-none text-slate-500 md:block">{k.word}</span>}
                    </span>
                  );
                })}
              </div>
            ))}
          </div>
        </div>
      </div>
    </section>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/laptop-keyboard-that-lights-up-keys-as-it-types.json

When to use it

How to customize it

Accessibility

Questions

How are the keys sized without fixed widths?

Each row is a flex row and each key gets `flex: <units> 1 0%`. A letter key is 1 unit, tab is 1.5, shift is 2.25 and the space bar is 5. Every row adds up to 14.5 units, so the rows line up at any width.

How does it follow my real keyboard?

A `keydown` listener on the window maps the event to a key id, using `event.code` for keys like shift and return and `event.key` for characters. The id is added to the `down` list and removed again on `keyup`.

Will the listener interfere with forms on my page?

No. It never calls `preventDefault`, and it ignores key presses that happen inside an input, a textarea or a select.

How do I highlight a fixed shortcut instead of typing?

Remove the typing effect and start `down` with the ids you want lit, for example `useState(["command-l", "k"])`.

Why are the keys so small on a phone?

A full row has fourteen keys, so on a 360 pixel screen each is about 20 pixels wide. That is fine for a picture. If you need keys people tap, show a smaller subset of rows at phone width.

Adapted from Apple Keyboard in Cult UI, used under its open-source licence. Licences and credits

Related: Search Field with Grouped Results Dropdown, Typewriter Headline with Cycling Phrases, Glowing AI Prompt Box with Morphing Send Button.

More app and game ui or all free UI components. Start for free to have a full site built around it.

More in App UI

We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.

Product

Who It's For

Features

Resources

Company

View Sitemap