Plate

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

const MAX = 7;

const css = `
  .field-plate { --metal: light-dark(#f3f4f7, #e5e6e9); --ink: light-dark(#373a40, #121417); --rim: color-mix(in oklab, var(--metal) 78%, var(--ink)); display: grid; justify-items: center; gap: 10px; }
  .field-plate__plate { position: relative; display: block; width: 248px; height: 124px; border-radius: 12px; background: radial-gradient(140% 90% at 30% 0%, var(--metal), color-mix(in oklab, var(--metal) 88%, var(--ink))); box-shadow: inset 0 0 0 2px var(--metal), inset 0 0 0 5px var(--rim), inset 0 0 0 6px var(--metal), 0 14px 22px -12px light-dark(rgba(0, 0, 0, 0.18), rgba(0, 0, 0, 0.6)), 0 2px 3px light-dark(rgba(0, 0, 0, 0.08), rgba(0, 0, 0, 0.25)); cursor: text; }
  .field-plate__plate:has(.field-plate__input:focus-visible) { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 3px; }
  .field-plate__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; text-transform: uppercase; }
  .field-plate__input::selection { background: transparent; }
  .field-plate__state { position: absolute; left: 0; right: 0; top: 12px; color: var(--ink); font: italic 700 11px/1 Georgia, 'Times New Roman', serif; letter-spacing: 0.3em; text-align: center; }
  .field-plate__motto { position: absolute; left: 0; right: 0; bottom: 11px; color: color-mix(in oklab, var(--ink) 70%, var(--metal)); font: 600 7px/1 system-ui, sans-serif; letter-spacing: 0.32em; text-align: center; }
  .field-plate__screw { position: absolute; top: 14px; width: 9px; height: 9px; border-radius: 50%; background: radial-gradient(circle at 40% 35%, var(--metal), var(--rim) 80%); box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--ink) 35%, transparent); }
  .field-plate__screw::after { content: ''; position: absolute; left: 1.5px; right: 1.5px; top: 4px; height: 1px; background: color-mix(in oklab, var(--ink) 55%, transparent); rotate: 30deg; }
  .field-plate__sticker { position: absolute; right: 22px; bottom: 22px; display: grid; place-items: center; width: 22px; height: 15px; border-radius: 2px; background: var(--ink); color: var(--metal); font: 800 8px/1 system-ui, sans-serif; rotate: -4deg; }
  /* The characters: pressed up out of the metal, lit from above. */
  .field-plate__row { position: absolute; left: 0; right: 0; top: 32px; display: flex; justify-content: center; align-items: center; height: 56px; gap: 2px; }
  .field-plate__ch { display: block; min-width: 14px; color: var(--ink); font: 700 50px/1 'DIN Condensed', 'Arial Narrow', 'Roboto Condensed', 'Helvetica Neue', sans-serif; text-align: center; text-shadow: 0 -1px 0 color-mix(in oklab, var(--metal) 40%, light-dark(#fbfcfd, #121417)), 0 1.5px 0 color-mix(in oklab, var(--ink) 60%, transparent), 0 3px 3px light-dark(rgba(0, 0, 0, 0.09), rgba(0, 0, 0, 0.3)); animation: field-plate-press 0.42s cubic-bezier(0.2, 0.9, 0.3, 1.25) both; }
  .field-plate__ch--gap { width: 14px; min-width: 14px; }
  @keyframes field-plate-press { from { opacity: 0.2; transform: scale(1.12); text-shadow: none; } 40% { opacity: 1; transform: scale(0.96); } }
  .field-plate__caret { width: 3px; height: 40px; margin-left: 3px; border-radius: 1.5px; background: var(--ink); opacity: 0; }
  .field-plate__plate:has(.field-plate__input:focus) .field-plate__caret { animation: field-plate-blink 1s steps(1) infinite; }
  @keyframes field-plate-blink { 0%, 50% { opacity: 0.85; } 51%, 100% { opacity: 0; } }
  .field-plate__plate[data-bad='0'] { animation: field-plate-rattle-a 0.36s; }
  .field-plate__plate[data-bad='1'] { animation: field-plate-rattle-b 0.36s; }
  @keyframes field-plate-rattle-a { 20% { rotate: -2deg; } 45% { rotate: 1.6deg; } 70% { rotate: -0.8deg; } }
  @keyframes field-plate-rattle-b { 20% { rotate: -2deg; } 45% { rotate: 1.6deg; } 70% { rotate: -0.8deg; } }
  .field-plate__read { color: light-dark(#5f626a, #a7abb2); font: 500 11.5px system-ui, sans-serif; }
  .field-plate__read b { color: light-dark(#373a40, #f1f2f3); font-weight: 600; }
  @media (prefers-reduced-motion: reduce) {
    .field-plate * { animation: none !important; }
  }
`;

export function Preview() {
  const id = useId();
  const [plate, setPlate] = useState('MOV 1N');
  const [bad, setBad] = useState(0);
  return (
    <>
      <style href="kit-inputs-plate" precedence="kit">
        {css}
      </style>
      <div className="field-plate">
        <label className="field-plate__plate" htmlFor={id} data-bad={bad ? bad % 2 : undefined}>
          <span className="field-plate__screw" style={{ left: 22 }} aria-hidden="true" />
          <span className="field-plate__screw" style={{ right: 22 }} aria-hidden="true" />
          <span className="field-plate__state" aria-hidden="true">
            MOTION STATE
          </span>
          <span className="field-plate__row" aria-hidden="true">
            {[...plate].map((ch, i) => (
              <span key={`${i}${ch}`} className={`field-plate__ch${ch === ' ' ? ' field-plate__ch--gap' : ''}`}>
                {ch === ' ' ? '' : ch}
              </span>
            ))}
            {plate.length < MAX && <span className="field-plate__caret" />}
          </span>
          <span className="field-plate__sticker" aria-hidden="true">
            26
          </span>
          <span className="field-plate__motto" aria-hidden="true">
            DRIVE SLOWLY
          </span>
          <input
            id={id}
            className="field-plate__input"
            maxLength={MAX}
            autoComplete="off"
            spellCheck={false}
            aria-label="Licence plate"
            value={plate}
            onChange={(e) => {
              const next = e.target.value.toUpperCase();
              // Plates take letters, digits and one space between groups.
              if (!/^[A-Z0-9]*( [A-Z0-9]*)?$/.test(next)) return setBad((b) => b + 1);
              setPlate(next.slice(0, MAX));
            }}
          />
        </label>
        <p className="field-plate__read" aria-hidden="true">
          <b>{plate.replace(' ', '').length}</b> of {MAX - 1} characters
        </p>
      </div>
    </>
  );
}

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

Install with shadcn

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

About this component

A licence plate: each character presses up out of the metal as you type, the caret waits in the next space, and a character a plate cannot take makes it rattle.

Dependencies
None
Difficulty
Intermediate
Triggers
state change, focus, keyframe
Techniques
keyframes, state machine, gradient
Reduced motion
Respected
Theme
Light and dark (follows your page's color-scheme)

One native input named "Licence plate", transparent over the plate so a click anywhere focuses it; focus outlines the plate and shows a caret in the next space. The plate art and the count are aria-hidden. Under prefers-reduced-motion characters appear without the press and the plate does not rattle.

More inputs

See all
Polygraph
Punch Card
Sand
Shredder