Label maker

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

const css = `
  @keyframes field-emboss-punch { 0% { transform: scale(1.5); opacity: 0; } 40% { transform: scale(0.88); opacity: 1; } 100% { transform: none; } }
  @keyframes field-emboss-squeeze-a { 0%, 100% { transform: translateY(0); } 35% { transform: translateY(-5px); } }
  @keyframes field-emboss-squeeze-b { 0%, 100% { transform: translateY(0); } 35% { transform: translateY(-5px); } }
  .field-emboss { width: 300px; }
  .field-emboss__label { display: block; margin-bottom: 8px; color: light-dark(#50535b, #c6c8cd); font-size: 12px; }
  .field-emboss__row { position: relative; display: flex; align-items: center; height: 64px; }
  .field-emboss__maker { flex: none; width: 64px; height: 64px; overflow: visible; }
  .field-emboss__body { fill: light-dark(#cbced3, #393c41); stroke: light-dark(#6d7179, #7d8087); stroke-width: 1.2; }
  .field-emboss__slot { fill: light-dark(#fbfcfd, #121417); }
  .field-emboss__wheel { fill: light-dark(#dbdde1, #2e3035); stroke: light-dark(#5f626a, #a7abb2); stroke-width: 1.2; }
  .field-emboss__notches { fill: none; stroke: light-dark(#5f626a, #a7abb2); stroke-width: 2.5; stroke-dasharray: 0.8 2.12; }
  .field-emboss__dial { transform-box: view-box; transform-origin: 25px 28px; transition: transform 0.3s cubic-bezier(0.3, 1.4, 0.5, 1); }
  .field-emboss__mark { stroke: light-dark(#42454c, #e5e6e9); stroke-width: 2; stroke-linecap: round; }
  .field-emboss__hub { fill: light-dark(#5f626a, #a7abb2); }
  .field-emboss__pointer { fill: light-dark(#42454c, #e5e6e9); }
  /* The window over the wheel shows the letter it is set to. */
  .field-emboss__window { fill: light-dark(#fbfcfd, #e5e6e9); stroke: light-dark(#6d7179, #7d8087); stroke-width: 1; }
  .field-emboss__glyph { fill: light-dark(#373a40, #232529); font: 700 9px ui-monospace, SFMono-Regular, Menlo, monospace; text-anchor: middle; }
  .field-emboss__grip { fill: light-dark(#6d7179, #7d8087); }
  .field-emboss__maker[data-press='a'] .field-emboss__handle { animation: field-emboss-squeeze-a 0.22s ease-out; }
  .field-emboss__maker[data-press='b'] .field-emboss__handle { animation: field-emboss-squeeze-b 0.22s ease-out; }
  /* The tape: dark with raised pale letters in both themes, as the real thing is. */
  .field-emboss__tape {
    position: relative;
    height: 34px;
    margin-left: -6px;
    border-radius: 2px 6px 6px 2px;
    background: light-dark(#42454c, #2e3035);
    box-shadow: 0 2px 4px light-dark(rgba(0, 0, 0, 0.08), rgba(0, 0, 0, 0.25));
    font: 700 16px ui-monospace, SFMono-Regular, Menlo, monospace;
    transition: width 0.18s ease-out;
  }
  .field-emboss__input, .field-emboss__mirror {
    box-sizing: border-box;
    padding: 0 14px;
    font: 700 16px/34px ui-monospace, SFMono-Regular, Menlo, monospace;
    letter-spacing: 3px;
    font-kerning: none;
    font-variant-ligatures: none;
    text-transform: uppercase;
  }
  .field-emboss__input {
    position: absolute;
    left: 0;
    top: 0;
    width: 260px;
    height: 34px;
    margin: 0;
    border: 0;
    outline: none;
    background: transparent;
    color: transparent;
    caret-color: light-dark(#fbfcfd, #f1f2f3);
  }
  .field-emboss__input::placeholder { color: light-dark(#cbced3, #7d8087); }
  .field-emboss__input::selection { background: light-dark(#6d7179, #7d8087); }
  .field-emboss__mirror { position: absolute; inset: 0; white-space: pre; pointer-events: none; color: light-dark(#fbfcfd, #f1f2f3); }
  .field-emboss__ch { display: inline-block; text-shadow: 0 1px 0 light-dark(rgba(0, 0, 0, 0.14), rgba(0, 0, 0, 0.45)), 0 -1px 0 color-mix(in srgb, light-dark(#373a40, #f1f2f3) 25%, transparent); }
  .field-emboss__ch--new { animation: field-emboss-punch 0.28s cubic-bezier(0.3, 0, 0.3, 1); }
  .field-emboss__row:focus-within .field-emboss__tape { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 3px; }
  @media (prefers-reduced-motion: reduce) {
    .field-emboss__tape, .field-emboss__dial { transition: none; }
    .field-emboss__ch--new, .field-emboss__maker .field-emboss__handle { animation: none !important; }
  }
`;

const MAX = 16;
const WHEEL = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';

export function EmbossInput() {
  const id = useId();
  const [v, setV] = useState('');
  const [grew, setGrew] = useState(false);
  const [press, setPress] = useState<'a' | 'b' | undefined>();
  // The wheel turns to the last letter, a tenth of a turn per step round the dial.
  const last = v.slice(-1);
  const turn = WHEEL.indexOf(last) * 10;
  // Room for the placeholder while it is empty, then the tape grows a letter at a time.
  const cells = Math.max(v.length + 1, v ? 0 : 11);
  return (
    <>
      <style>
        {css}
      </style>
      <div className="field-emboss">
        <label className="field-emboss__label" htmlFor={id}>
          Label
        </label>
        <div className="field-emboss__row">
          <svg className="field-emboss__maker" viewBox="0 0 64 64" data-press={press} aria-hidden="true">
            <g className="field-emboss__handle">
              <rect className="field-emboss__grip" x="12" y="40" width="34" height="18" rx="9" />
            </g>
            <rect className="field-emboss__body" x="2" y="8" width="60" height="40" rx="14" />
            <rect className="field-emboss__slot" x="56" y="24" width="6" height="8" rx="1" />
            <circle className="field-emboss__wheel" cx="25" cy="28" r="15" />
            <g className="field-emboss__dial" style={{ transform: `rotate(${turn}deg)` }}>
              <circle className="field-emboss__notches" cx="25" cy="28" r="12" pathLength={36} />
              <path className="field-emboss__mark" d="M25 20V24" />
            </g>
            <circle className="field-emboss__hub" cx="25" cy="28" r="3" />
            <rect className="field-emboss__window" x="18" y="0" width="14" height="13" rx="3" />
            <text className="field-emboss__glyph" x="25" y="9.6">
              {last.trim() || 'A'}
            </text>
            <path className="field-emboss__pointer" d="M23 13H27L25 16Z" />
          </svg>
          <div className="field-emboss__tape" style={{ width: `calc(${cells} * (1ch + 3px) + 28px)` } as CSSProperties}>
            <span className="field-emboss__mirror" aria-hidden="true">
              {[...v].map((ch, i) => (
                <span key={`${i}${ch}`} className={`field-emboss__ch${grew && i === v.length - 1 ? ' field-emboss__ch--new' : ''}`}>
                  {ch}
                </span>
              ))}
            </span>
            <input
              id={id}
              className="field-emboss__input"
              value={v}
              maxLength={MAX}
              placeholder="Name a box"
              autoComplete="off"
              spellCheck={false}
              onChange={(e) => {
                const next = e.target.value
                  .toUpperCase()
                  .replace(/[^A-Z0-9 .&-]/g, '')
                  .slice(0, MAX);
                if (next !== v) setPress((p) => (p === 'a' ? 'b' : 'a'));
                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-emboss
Source on GitHub

About this component

A label field typed on a label maker: the letter wheel turns to each letter, the handle squeezes, and the letter is punched up out of the tape as it feeds out longer.

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

A real input with a visible <label>; the mirror and the label maker are aria-hidden. Under prefers-reduced-motion letters appear without the punch and nothing turns.

More inputs

See all
Letter board

Mailbox
Name tag
Piggy bank