Letter board
React + Tailwind
import { useId, useState } from 'react';
import type { CSSProperties } from 'react';
const css = `
/* Pushed into the felt: comes in from the viewer, overshoots into the groove and wobbles to rest. */
@keyframes field-letterboard-press {
0% { transform: translateY(-3px) scale(1.4) rotate(calc(var(--r) * -3)); opacity: 0; }
45% { transform: translateY(var(--y)) scale(0.92) rotate(calc(var(--r) * 2)); opacity: 1; }
70% { transform: translateY(var(--y)) scale(1.03) rotate(calc(var(--r) * -0.5)); }
100% { transform: translateY(var(--y)) rotate(var(--r)); }
}
.field-letterboard { width: 300px; }
.field-letterboard__label { display: block; margin-bottom: 8px; color: light-dark(#50535b, #c6c8cd); font-size: 12px; }
.field-letterboard__frame { position: relative; padding: 7px; border-radius: 8px; background: light-dark(#cbced3, #393c41); box-shadow: inset 0 1px 0 color-mix(in srgb, light-dark(#50535b, #c6c8cd) 30%, transparent), 0 4px 10px light-dark(rgba(0, 0, 0, 0.08), rgba(0, 0, 0, 0.25)); }
/* Felt with its grooves: a dark line and a lit lip every 13px. */
.field-letterboard__felt {
position: relative;
height: 104px;
border-radius: 3px;
background: repeating-linear-gradient(to bottom, light-dark(#f3f4f7, #1a1c1f) 0 11px, light-dark(#fbfcfd, #121417) 11px 12px, light-dark(#ebedf0, #232529) 12px 13px);
box-shadow: inset 0 2px 6px light-dark(rgba(0, 0, 0, 0.12), rgba(0, 0, 0, 0.4));
}
.field-letterboard__input, .field-letterboard__mirror {
box-sizing: border-box;
width: 100%;
height: 40px;
padding: 0 10px;
font: 700 22px/40px 'Helvetica Neue', Arial, sans-serif;
letter-spacing: 4px;
font-kerning: none;
font-variant-ligatures: none;
text-align: center;
text-transform: uppercase;
}
.field-letterboard__input { position: absolute; left: 0; top: 33px; margin: 0; border: 0; outline: none; background: transparent; color: transparent; caret-color: light-dark(#373a40, #f1f2f3); }
.field-letterboard__input::placeholder { color: light-dark(#6d7179, #7d8087); }
.field-letterboard__input::selection { background: light-dark(#cbced3, #393c41); }
.field-letterboard__mirror { position: absolute; left: 0; top: 33px; overflow: hidden; color: light-dark(#373a40, #f1f2f3); white-space: pre; pointer-events: none; }
.field-letterboard__ch { display: inline-block; transform: translateY(var(--y)) rotate(var(--r)); text-shadow: 0 2px 0 light-dark(rgba(0, 0, 0, 0.12), rgba(0, 0, 0, 0.4)); }
.field-letterboard__ch--new { animation: field-letterboard-press 0.4s cubic-bezier(0.3, 0, 0.3, 1); }
.field-letterboard__frame:focus-within { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 3px; }
@media (prefers-reduced-motion: reduce) {
.field-letterboard__ch--new { animation: none; }
}
`;
// Each plastic letter sits a little crooked, the same way every time for the same letter in the same place.
const tilt = (ch: string, i: number) => {
const c = ch.charCodeAt(0);
return { '--r': `${(((c * 37 + i * 53) % 7) - 3) * 0.7}deg`, '--y': `${(((c * 13 + i * 29) % 3) - 1) * 0.6}px` } as CSSProperties;
};
export function LetterboardInput() {
const id = useId();
const [v, setV] = useState('');
const [grew, setGrew] = useState(false);
return (
<>
<style>
{css}
</style>
<div className="field-letterboard">
<label className="field-letterboard__label" htmlFor={id}>
Headline
</label>
<div className="field-letterboard__frame">
<div className="field-letterboard__felt">
<span className="field-letterboard__mirror" aria-hidden="true">
{[...v].map((ch, i) => (
<span key={`${i}${ch}`} className={`field-letterboard__ch${grew && i === v.length - 1 ? ' field-letterboard__ch--new' : ''}`} style={tilt(ch, i)}>
{ch}
</span>
))}
</span>
<input
id={id}
className="field-letterboard__input"
value={v}
maxLength={14}
placeholder="Now open"
autoComplete="off"
spellCheck={false}
onChange={(e) => {
const next = e.target.value.toUpperCase();
setGrew(next.length > v.length);
setV(next);
}}
/>
</div>
</div>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initFieldLetterboard(el) on the mounted element instead.
HTML
<div class="field-letterboard">
<label class="field-letterboard__label" for="field-letterboard">Headline</label>
<div class="field-letterboard__frame">
<div class="field-letterboard__felt">
<span class="field-letterboard__mirror" aria-hidden="true"></span>
<input id="field-letterboard" class="field-letterboard__input" maxLength="14" placeholder="Now open" autoComplete="off" spellCheck="false" value=""/>
</div>
</div>
</div>CSS
/* Pushed into the felt: comes in from the viewer, overshoots into the groove and wobbles to rest. */
@keyframes field-letterboard-press {
0% { transform: translateY(-3px) scale(1.4) rotate(calc(var(--r) * -3)); opacity: 0; }
45% { transform: translateY(var(--y)) scale(0.92) rotate(calc(var(--r) * 2)); opacity: 1; }
70% { transform: translateY(var(--y)) scale(1.03) rotate(calc(var(--r) * -0.5)); }
100% { transform: translateY(var(--y)) rotate(var(--r)); }
}
.field-letterboard { width: 300px; }
.field-letterboard__label { display: block; margin-bottom: 8px; color: light-dark(#50535b, #c6c8cd); font-size: 12px; }
.field-letterboard__frame { position: relative; padding: 7px; border-radius: 8px; background: light-dark(#cbced3, #393c41); box-shadow: inset 0 1px 0 color-mix(in srgb, light-dark(#50535b, #c6c8cd) 30%, transparent), 0 4px 10px light-dark(rgba(0, 0, 0, 0.08), rgba(0, 0, 0, 0.25)); }
/* Felt with its grooves: a dark line and a lit lip every 13px. */
.field-letterboard__felt {
position: relative;
height: 104px;
border-radius: 3px;
background: repeating-linear-gradient(to bottom, light-dark(#f3f4f7, #1a1c1f) 0 11px, light-dark(#fbfcfd, #121417) 11px 12px, light-dark(#ebedf0, #232529) 12px 13px);
box-shadow: inset 0 2px 6px light-dark(rgba(0, 0, 0, 0.12), rgba(0, 0, 0, 0.4));
}
.field-letterboard__input, .field-letterboard__mirror {
box-sizing: border-box;
width: 100%;
height: 40px;
padding: 0 10px;
font: 700 22px/40px 'Helvetica Neue', Arial, sans-serif;
letter-spacing: 4px;
font-kerning: none;
font-variant-ligatures: none;
text-align: center;
text-transform: uppercase;
}
.field-letterboard__input { position: absolute; left: 0; top: 33px; margin: 0; border: 0; outline: none; background: transparent; color: transparent; caret-color: light-dark(#373a40, #f1f2f3); }
.field-letterboard__input::placeholder { color: light-dark(#6d7179, #7d8087); }
.field-letterboard__input::selection { background: light-dark(#cbced3, #393c41); }
.field-letterboard__mirror { position: absolute; left: 0; top: 33px; overflow: hidden; color: light-dark(#373a40, #f1f2f3); white-space: pre; pointer-events: none; }
.field-letterboard__ch { display: inline-block; transform: translateY(var(--y)) rotate(var(--r)); text-shadow: 0 2px 0 light-dark(rgba(0, 0, 0, 0.12), rgba(0, 0, 0, 0.4)); }
.field-letterboard__ch--new { animation: field-letterboard-press 0.4s cubic-bezier(0.3, 0, 0.3, 1); }
.field-letterboard__frame:focus-within { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 3px; }
@media (prefers-reduced-motion: reduce) {
.field-letterboard__ch--new { animation: none; }
}JavaScript
function initFieldLetterboard(root) {
var input = root.querySelector('.field-letterboard__input');
var mirror = root.querySelector('.field-letterboard__mirror');
var v = '';
// Each plastic letter sits a little crooked, the same way every time for the same letter in the same place.
function tilt(span, ch, i) {
var c = ch.charCodeAt(0);
span.style.setProperty('--r', (((c * 37 + i * 53) % 7) - 3) * 0.7 + 'deg');
span.style.setProperty('--y', (((c * 13 + i * 29) % 3) - 1) * 0.6 + 'px');
}
function onInput() {
var pos = input.selectionStart;
var next = input.value.toUpperCase();
if (input.value !== next) {
input.value = next;
input.setSelectionRange(pos, pos);
}
var grew = next.length > v.length;
v = next;
for (var i = 0; i < v.length; i++) {
var span = mirror.children[i];
if (span && span.textContent === v[i]) {
span.classList.remove('field-letterboard__ch--new');
continue;
}
var fresh = document.createElement('span');
fresh.className = 'field-letterboard__ch';
fresh.textContent = v[i];
tilt(fresh, v[i], i);
if (span) mirror.replaceChild(fresh, span);
else mirror.appendChild(fresh);
}
while (mirror.children.length > v.length) mirror.lastChild.remove();
// The newest letter is pushed into the felt.
if (grew && mirror.lastChild) mirror.lastChild.classList.add('field-letterboard__ch--new');
}
input.addEventListener('input', onInput);
return function () {
input.removeEventListener('input', onInput);
};
}
document.querySelectorAll('.field-letterboard').forEach(initFieldLetterboard);Goal: Create a "Letter board" input.
Visual behavior: A felt letter board: each letter you type is pushed into the groove with a little wobble and stays a touch crooked, like the real plastic letters.
Technique:
- A real input with transparent text sits under a mirror of the value set in the same font and metrics, so the caret, selection and typing all still work while the mirror draws the letters.
- The felt and its grooves are one repeating-linear-gradient. Each letter gets a small rotation and offset from its character and position, so the same text always sits the same way, and the newest letter plays a press keyframe that overshoots into the groove and wobbles to rest.
Accessibility: A real input with a visible <label>; the mirror is aria-hidden. Under prefers-reduced-motion letters appear in place.
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 "Letter board" input.
Visual behavior: A felt letter board: each letter you type is pushed into the groove with a little wobble and stays a touch crooked, like the real plastic letters.
Technique:
- A real input with transparent text sits under a mirror of the value set in the same font and metrics, so the caret, selection and typing all still work while the mirror draws the letters.
- The felt and its grooves are one repeating-linear-gradient. Each letter gets a small rotation and offset from its character and position, so the same text always sits the same way, and the newest letter plays a press keyframe that overshoots into the groove and wobbles to rest.
Accessibility: A real input with a visible <label>; the mirror is aria-hidden. Under prefers-reduced-motion letters appear in place.
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 felt letter board: each letter you type is pushed into the groove with a little wobble and stays a touch crooked, like the real plastic letters.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- state change, keyframe
- Techniques
- keyframes, css variables, gradient
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
A real input with a visible <label>; the mirror is aria-hidden. Under prefers-reduced-motion letters appear in place.