Key

No dependencies
Code format
React + Tailwind
import { useId, useState, type CSSProperties } from 'react';

const LEN = 6;
const CUT = (i: number) => 16 + i * 24; // notch centres along the blade
// The blade's outline: a V notch per digit, as deep as the digit, then the tip.
const blade = (code: string) => {
  const pts = ['0px 0px'];
  for (let i = 0; i < LEN; i++) {
    const d = i < code.length ? 3 + Number(code[i]) * 1.6 : 0;
    pts.push(`${CUT(i) - 8}px 0px`, `${CUT(i)}px ${d.toFixed(1)}px`, `${CUT(i) + 8}px 0px`);
  }
  pts.push('150px 0px', '160px 14px', '156px 28px', '0px 28px');
  return `polygon(${pts.join(', ')})`;
};

const css = `
  .field-key { --steel-hi: light-dark(#fbfcfd, #e5e6e9); --steel: light-dark(#dbdde1, #a7abb2); --steel-lo: light-dark(#6d7179, #393c41); display: grid; justify-items: center; }
  .field-key__bench { position: relative; display: grid; justify-items: center; gap: 6px; width: 262px; cursor: text; }
  .field-key__rig { position: relative; width: 262px; height: 84px; }
  .field-key__input { position: absolute; inset: 0; z-index: 3; width: 100%; height: 100%; margin: 0; padding: 0; border: 0; background: transparent; color: transparent; caret-color: transparent; font-size: 16px; outline: none; cursor: text; }
  .field-key__input::selection { background: transparent; }
  /* The key: a bow with its hole, and a blade whose top edge is cut by clip-path. */
  .field-key__key { position: absolute; left: 6px; top: 16px; width: 206px; height: 48px; transition: translate 0.45s cubic-bezier(0.5, 0, 0.2, 1) 0.3s, scale 0.3s ease-in-out; filter: drop-shadow(0 4px 4px light-dark(rgba(0, 0, 0, 0.12), rgba(0, 0, 0, 0.4))); }
  .field-key__bow { position: absolute; left: 0; top: 0; width: 48px; height: 48px; border-radius: 50%; background: radial-gradient(circle, transparent 0 8px, var(--steel-lo) 8.5px 10px, transparent 10.5px), linear-gradient(160deg, var(--steel-hi), var(--steel) 55%, var(--steel-lo)); }
  .field-key__neck { position: absolute; left: 40px; top: 14px; width: 12px; height: 20px; background: linear-gradient(var(--steel-hi), var(--steel)); }
  .field-key__blade { position: absolute; left: 46px; top: 10px; width: 160px; height: 28px; clip-path: var(--cut); transition: clip-path 0.32s cubic-bezier(0.3, 0, 0.2, 1); background: linear-gradient(180deg, var(--steel-hi), var(--steel) 45%, var(--steel-lo) 60%, var(--steel) 64%, var(--steel) 80%, var(--steel-lo)); }
  .field-key__digit { position: absolute; top: 18px; width: 10px; margin-left: -5px; color: var(--steel-lo); font: 600 7.5px/1 ui-monospace, 'SF Mono', Menlo, Consolas, monospace; text-align: center; }
  .field-key__next { position: absolute; top: 44px; width: 0; height: 0; margin-left: -4px; border: 4px solid transparent; border-bottom: 5px solid light-dark(#6d7179, #7d8087); transition: left 0.3s cubic-bezier(0.3, 1.4, 0.5, 1); }
  .field-key__bench:has(.field-key__input:focus) .field-key__next { border-bottom-color: light-dark(#373a40, #f1f2f3); }
  /* The grinder drops onto the cut it is making, spinning, and throws sparks. */
  .field-key__grinder { position: absolute; top: -10px; width: 30px; height: 30px; margin-left: -15px; border-radius: 50%; background: repeating-conic-gradient(var(--steel) 0 12deg, var(--steel-lo) 12deg 24deg); box-shadow: inset 0 0 0 4px var(--steel-lo); opacity: 0; animation: field-key-grind 0.42s cubic-bezier(0.3, 0, 0.3, 1); pointer-events: none; }
  @keyframes field-key-grind { 0% { opacity: 0; transform: translateY(-10px) rotate(0); } 25%, 70% { opacity: 1; transform: translateY(0) rotate(240deg); } 100% { opacity: 0; transform: translateY(-12px) rotate(400deg); } }
  .field-key__spark { position: absolute; top: 10px; width: 1.5px; height: 6px; border-radius: 1px; background: light-dark(#373a40, #f1f2f3); opacity: 0; transform-origin: 50% 100%; animation: field-key-spark 0.4s ease-out 0.1s; pointer-events: none; }
  @keyframes field-key-spark { 0% { opacity: 1; transform: rotate(var(--a)) translateY(0) scaleY(0.4); } 100% { opacity: 0; transform: rotate(var(--a)) translateY(-16px) scaleY(1); } }
  /* The lock: a cylinder face the key slides into, whose keyway turns once it is in. */
  .field-key__lock { position: absolute; right: 4px; top: 16px; z-index: 2; width: 46px; height: 48px; border-radius: 12px; background: linear-gradient(160deg, var(--steel), var(--steel-lo)); box-shadow: inset 0 1px 0 color-mix(in oklab, light-dark(#373a40, #f1f2f3) 25%, transparent), 0 6px 10px -4px light-dark(rgba(0, 0, 0, 0.15), rgba(0, 0, 0, 0.5)); }
  .field-key__cyl { position: absolute; left: 7px; top: 8px; width: 32px; height: 32px; border-radius: 50%; background: radial-gradient(circle at 40% 35%, var(--steel-hi), var(--steel) 70%); box-shadow: 0 0 0 2px var(--steel-lo); transition: rotate 0.3s ease-in-out 0.75s; }
  .field-key__cyl::after { content: ''; position: absolute; left: 50%; top: 6px; width: 4px; height: 20px; margin-left: -2px; border-radius: 2px; background: light-dark(#fbfcfd, #121417); }
  .field-key[data-done] .field-key__key { translate: 34px 0; scale: 1 0.3; transition: translate 0.45s cubic-bezier(0.5, 0, 0.2, 1), scale 0.3s ease-in-out 0.75s; }
  .field-key[data-done] .field-key__cyl { rotate: 90deg; }
  /* The code panel: six cells, the next one waiting with a caret. */
  .field-key__head { display: flex; justify-content: space-between; width: 214px; color: light-dark(#5f626a, #a7abb2); font: 500 11px/14px system-ui, sans-serif; }
  .field-key__state { font-variant-numeric: tabular-nums; }
  .field-key[data-done] .field-key__state { color: light-dark(#373a40, #f1f2f3); font-weight: 600; }
  .field-key__cells { display: flex; gap: 6px; border-radius: 9px; }
  .field-key__bench:has(.field-key__input:focus-visible) .field-key__cells { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 3px; }
  .field-key__cell { position: relative; display: grid; place-items: center; width: 31px; height: 38px; border-radius: 7px; background: light-dark(#f3f4f7, #1a1c1f); box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035), inset 0 2px 3px light-dark(rgba(0, 0, 0, 0.07), rgba(0, 0, 0, 0.22)); color: light-dark(#373a40, #f1f2f3); font: 600 17px/1 ui-monospace, 'SF Mono', Menlo, Consolas, monospace; transition: box-shadow 0.2s; }
  .field-key__cell--next { box-shadow: inset 0 0 0 1px light-dark(#6d7179, #7d8087), inset 0 2px 3px light-dark(rgba(0, 0, 0, 0.07), rgba(0, 0, 0, 0.22)); }
  .field-key__bench:has(.field-key__input:focus) .field-key__cell--next { box-shadow: inset 0 0 0 1.5px light-dark(#42454c, #e5e6e9), inset 0 2px 3px light-dark(rgba(0, 0, 0, 0.07), rgba(0, 0, 0, 0.22)); }
  .field-key__digit-in { animation: field-key-digit 0.34s cubic-bezier(0.3, 1.5, 0.5, 1) both; }
  @keyframes field-key-digit { from { opacity: 0; transform: translateY(7px) scale(0.6); } }
  .field-key__caret { width: 2px; height: 18px; border-radius: 1px; background: light-dark(#373a40, #f1f2f3); opacity: 0; }
  .field-key__bench:has(.field-key__input:focus) .field-key__caret { animation: field-key-blink 1s steps(1) infinite; }
  @keyframes field-key-blink { 0%, 50% { opacity: 1; } 51%, 100% { opacity: 0; } }
  /* Once the lock turns, the cells take it in one quick wave. */
  .field-key[data-done] .field-key__cell { box-shadow: inset 0 0 0 1px light-dark(#5f626a, #a7abb2), inset 0 2px 3px light-dark(rgba(0, 0, 0, 0.07), rgba(0, 0, 0, 0.22)); animation: field-key-ok 0.36s ease-out calc(0.8s + var(--i) * 45ms); }
  @keyframes field-key-ok { 40% { transform: translateY(-3px); } }
  @media (prefers-reduced-motion: reduce) {
    .field-key *, .field-key[data-done] * { animation: none !important; transition: none !important; }
  }
`;

export function Preview() {
  const id = useId();
  const [code, setCode] = useState('4172');
  const done = code.length === LEN;
  return (
    <>
      <style href="kit-inputs-key" precedence="kit">
        {css}
      </style>
      <div className="field-key" data-done={done || undefined}>
        <label className="field-key__bench" htmlFor={id}>
          <span className="field-key__rig" aria-hidden="true">
          <span className="field-key__key">
            <span className="field-key__neck" />
            <span className="field-key__bow" />
            <span className="field-key__blade" style={{ '--cut': blade(code) } as CSSProperties} />
            {[...code].map((d, i) => (
              <span key={i} className="field-key__digit" style={{ left: 46 + CUT(i) }}>
                {d}
              </span>
            ))}
            {!done && <span className="field-key__next" style={{ left: 46 + CUT(code.length) }} />}
            {code.length > 0 && (
              <span key={code.length} style={{ position: 'absolute', left: 46 + CUT(code.length - 1), top: 0 }}>
                <span className="field-key__grinder" />
                {[-70, -45, -20, 20, 45, 70].map((a) => (
                  <i key={a} className="field-key__spark" style={{ '--a': `${a}deg` } as CSSProperties} />
                ))}
              </span>
            )}
          </span>
          <span className="field-key__lock">
            <span className="field-key__cyl" />
          </span>
          </span>
          <span className="field-key__head">
            <span aria-hidden="true">Key code</span>
            <span className="field-key__state" aria-live="polite">
              {done ? 'Unlocked' : `${code.length} of ${LEN}`}
            </span>
          </span>
          <span className="field-key__cells" aria-hidden="true">
            {Array.from({ length: LEN }, (_, i) => (
              <span key={i} className={`field-key__cell${i === code.length ? ' field-key__cell--next' : ''}`} style={{ '--i': i } as CSSProperties}>
                {i < code.length ? (
                  <span key={code[i]} className="field-key__digit-in">
                    {code[i]}
                  </span>
                ) : (
                  i === code.length && <i className="field-key__caret" />
                )}
              </span>
            ))}
          </span>
          <input
            id={id}
            className="field-key__input"
            inputMode="numeric"
            autoComplete="one-time-code"
            maxLength={LEN}
            aria-label="Key code, six digits"
            value={code}
            onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, LEN))}
          />
        </label>
      </div>
    </>
  );
}

Free to use in any project, commercial included. MIT-0, no attribution required. Licence

Install with shadcn

npx shadcn@latest add @movin/input-key
Source on GitHub

About this component

A six-digit code cut into a key: each digit grinds a notch to its depth with sparks and drops into its cell below, and the finished key slides into the lock and turns.

Dependencies
None
Difficulty
Advanced
Triggers
state change, focus, keyframe, spring
Techniques
clip path, keyframes, css transition, state machine
Reduced motion
Respected
Theme
Light and dark (follows your page's color-scheme)

One native input named "Key code, six digits", transparent over the drawing so a click anywhere focuses it. Focus outlines the code cells and the next cell shows a caret. The count and "Unlocked" are read out from an aria-live line, and the key, lock and sparks are aria-hidden. Under prefers-reduced-motion the cut, key and lock change without moving.

More inputs

See all
Notebook

7 of 10 digits

Pager
Plate
Polygraph