Plate
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>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initFieldPlate(el) on the mounted element instead.
<div class="field-plate">
<label class="field-plate__plate" for="field-plate">
<span class="field-plate__screw" style="left:22px" aria-hidden="true"></span>
<span class="field-plate__screw" style="right:22px" aria-hidden="true"></span>
<span class="field-plate__state" aria-hidden="true">MOTION STATE</span>
<span class="field-plate__row" aria-hidden="true">
<span class="field-plate__ch">M</span>
<span class="field-plate__ch">O</span>
<span class="field-plate__ch">V</span>
<span class="field-plate__ch field-plate__ch--gap"></span>
<span class="field-plate__ch">1</span>
<span class="field-plate__ch">N</span>
<span class="field-plate__caret"></span>
</span>
<span class="field-plate__sticker" aria-hidden="true">26</span>
<span class="field-plate__motto" aria-hidden="true">DRIVE SLOWLY</span>
<input id="field-plate" class="field-plate__input" maxLength="7" autoComplete="off" spellCheck="false" aria-label="Licence plate" value="MOV 1N"/>
</label>
<p class="field-plate__read" aria-hidden="true">
<b>5</b>
of 6 characters
</p>
</div>.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; }
}function initFieldPlate(root) {
var MAX = 7;
var plate = root.querySelector('.field-plate__plate');
var row = root.querySelector('.field-plate__row');
var input = root.querySelector('.field-plate__input');
var read = root.querySelector('.field-plate__read');
var caret = document.createElement('span');
caret.className = 'field-plate__caret';
var value = input.value;
var bad = 0;
var shown = [].slice.call(row.querySelectorAll('.field-plate__ch')).map(function (el) {
return { ch: el.classList.contains('field-plate__ch--gap') ? ' ' : el.textContent, el: el };
});
var old = row.querySelector('.field-plate__caret');
if (old) old.remove();
// A character is pressed again only when it changes.
function render() {
for (var i = 0; i < Math.max(value.length, shown.length); i++) {
if (i >= value.length) {
shown[i].el.remove();
continue;
}
if (shown[i] && shown[i].ch === value[i]) continue;
var el = document.createElement('span');
el.className = 'field-plate__ch' + (value[i] === ' ' ? ' field-plate__ch--gap' : '');
el.textContent = value[i] === ' ' ? '' : value[i];
if (shown[i]) row.replaceChild(el, shown[i].el);
else row.appendChild(el);
shown[i] = { ch: value[i], el: el };
}
shown.length = value.length;
if (value.length < MAX) row.appendChild(caret);
else caret.remove();
read.innerHTML = '<b>' + value.replace(' ', '').length + '</b> of ' + (MAX - 1) + ' characters';
}
function onInput() {
var next = input.value.toUpperCase();
// Plates take letters, digits and one space between groups.
if (!/^[A-Z0-9]*( [A-Z0-9]*)?$/.test(next)) {
input.value = value;
plate.setAttribute('data-bad', String(bad++ % 2));
return;
}
value = next.slice(0, MAX);
input.value = value;
render();
}
render();
input.addEventListener('input', onInput);
return function () {
input.removeEventListener('input', onInput);
};
}
document.querySelectorAll('.field-plate').forEach(initFieldPlate);Goal: Create a "Plate" input.
Visual behavior: 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.
Technique:
- A real text input lies transparent over the plate; the characters are drawn from its value, uppercased, and anything but letters, digits and one space between groups is refused.
- Each character is condensed type with a lit top edge and a shadowed lower one, so it reads as pressed up out of the metal; a new one plays a short press (scale down from 1.12 with a settle).
- A refused character rattles the plate on its screws: two identical keyframes alternate on a data attribute so every refusal replays.
- The plate itself is a radial gradient with an inset rim, two screws, a state line, a motto and a registration sticker.
Accessibility: 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.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: neutral only. Black, white and grays, no hues. Use the color values above as written, or my project's closest neutral tokens.
Return a single self-contained component. No dependencies.Goal: Create a "Plate" input.
Visual behavior: 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.
Technique:
- A real text input lies transparent over the plate; the characters are drawn from its value, uppercased, and anything but letters, digits and one space between groups is refused.
- Each character is condensed type with a lit top edge and a shadowed lower one, so it reads as pressed up out of the metal; a new one plays a short press (scale down from 1.12 with a settle).
- A refused character rattles the plate on its screws: two identical keyframes alternate on a data attribute so every refusal replays.
- The plate itself is a radial gradient with an inset rim, two screws, a state line, a motto and a registration sticker.
Accessibility: 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.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: use my project's existing color tokens. The color values above are grayscale placeholders. Map each one by its role (surface, text, border, accent, glow) to my theme, and keep their relative lightness so contrast and the effect still read.
Return a single self-contained component. No dependencies.Paste into Claude, ChatGPT or Cursor after filling in {{USER_STACK}}, {{USER_STYLING}} and {{USER_CONSTRAINTS}}. Neutral keeps the greys you see here; Match my project asks the model to map them onto your own colour tokens.
Free to use in any project, commercial included. MIT-0, no attribution required. Licence
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.