Letter board

No dependencies
Code format
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>
    </>
  );
}

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

Install with shadcn

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

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.

More inputs

See all

Mailbox
Name tag
Piggy bank
Price tag