Punch Card

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

const COLS = 24;
const X0 = 16;
const CW = 9.4;
const Y0 = 22;
const RH = 7.4;
// Rows top to bottom: the 12 and 11 zones, then 0 to 9.
const colX = (c: number) => +(X0 + c * CW).toFixed(1);
const row = (r: number | 'z12' | 'z11') => (r === 'z12' ? 0 : r === 'z11' ? 1 : r + 2);
// The real card code: digits punch their row; letters add a zone punch on top.
function holes(ch: string): number[] | null {
  if (ch === ' ') return [];
  if (/[0-9]/.test(ch)) return [row(Number(ch))];
  const c = ch.charCodeAt(0);
  if (ch >= 'A' && ch <= 'I') return [row('z12'), row(c - 64)];
  if (ch >= 'J' && ch <= 'R') return [row('z11'), row(c - 73)];
  if (ch >= 'S' && ch <= 'Z') return [row(0), row(c - 81)];
  return { '-': [row('z11')], '&': [row('z12')], '/': [row(0), row(1)], '.': [row('z12'), row(3), row(8)] }[ch] ?? null;
}

const css = `
  .field-punch-card { --card: light-dark(#dbdde1, #e5e6e9); --ink: light-dark(#50535b, #232529); --print: light-dark(#373a40, #121417); display: grid; justify-items: center; gap: 10px; }
  .field-punch-card__card { position: relative; display: block; width: 258px; height: 114px; background: var(--card); clip-path: polygon(11px 0, 100% 0, 100% 100%, 0 100%, 0 11px); filter: drop-shadow(0 6px 8px light-dark(rgba(0, 0, 0, 0.11), rgba(0, 0, 0, 0.35))); }
  .field-punch-card__wrap { position: relative; padding-top: 10px; cursor: text; }
    /* The keypunch line: a real field, so the caret, selection and editing are the browser's own. */
  .field-punch-card__field { display: flex; align-items: center; gap: 8px; box-sizing: border-box; width: 258px; height: 36px; padding: 0 12px; border-radius: 8px; background: light-dark(#f3f4f7, #1a1c1f); box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035), inset 0 2px 3px light-dark(rgba(0, 0, 0, 0.06), rgba(0, 0, 0, 0.2)); cursor: text; transition: box-shadow 0.2s; }
  .field-punch-card__field:has(.field-punch-card__input:focus) { box-shadow: inset 0 0 0 1px light-dark(#6d7179, #7d8087), inset 0 2px 3px light-dark(rgba(0, 0, 0, 0.06), rgba(0, 0, 0, 0.2)); }
  .field-punch-card__field:has(.field-punch-card__input:focus-visible) { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 2px; }
  .field-punch-card__prompt { color: light-dark(#6d7179, #7d8087); font: 600 13px/1 ui-monospace, 'SF Mono', Menlo, Consolas, monospace; }
  .field-punch-card__input { flex: 1; min-width: 0; height: 100%; margin: 0; padding: 0; border: 0; background: transparent; color: light-dark(#373a40, #f1f2f3); caret-color: light-dark(#373a40, #f1f2f3); font: 600 14px ui-monospace, 'SF Mono', Menlo, Consolas, monospace; letter-spacing: 0.06em; text-transform: uppercase; outline: none; }
  .field-punch-card__input::placeholder { color: light-dark(#6d7179, #7d8087); text-transform: none; letter-spacing: 0; font-weight: 500; }
  .field-punch-card__col { color: light-dark(#5f626a, #a7abb2); font: 500 11px/1 ui-monospace, 'SF Mono', Menlo, Consolas, monospace; white-space: nowrap; font-variant-numeric: tabular-nums; }
  .field-punch-card__col b { color: light-dark(#373a40, #f1f2f3); font-weight: 600; }
  .field-punch-card__digits { position: absolute; left: ${X0 + 3}px; color: var(--ink); font: 500 5.5px/1 ui-monospace, 'SF Mono', Menlo, Consolas, monospace; letter-spacing: ${(CW - 3.3).toFixed(2)}px; white-space: nowrap; pointer-events: none; }
  .field-punch-card__band { position: absolute; left: 0; top: 0; bottom: 0; width: ${CW}px; background: color-mix(in oklab, var(--print) 7%, transparent); transition: transform 0.25s cubic-bezier(0.3, 1.4, 0.5, 1); }
  .field-punch-card__printed { position: absolute; top: 7px; width: ${CW}px; color: var(--print); font: 700 7.5px/1 ui-monospace, 'SF Mono', Menlo, Consolas, monospace; text-align: center; animation: field-punch-card-print 0.3s ease-out 0.12s both; }
  @keyframes field-punch-card-print { from { opacity: 0; transform: translateY(-3px); } }
  /* A hole shows whatever is behind the card; its chad drops out of it. */
  .field-punch-card__hole { position: absolute; width: 4.2px; height: 6px; border-radius: 0.8px; background: light-dark(#fbfcfd, #121417); box-shadow: inset 0 1px 1px light-dark(rgba(0, 0, 0, 0.11), rgba(0, 0, 0, 0.35)); animation: field-punch-card-punch 0.18s cubic-bezier(0.5, 0, 0.5, 1.6) both; }
  @keyframes field-punch-card-punch { from { transform: scale(0.2); } }
  .field-punch-card__chad { position: absolute; z-index: 2; width: 4.2px; height: 6px; border-radius: 0.8px; background: var(--card); box-shadow: 0 1px 1px light-dark(rgba(0, 0, 0, 0.11), rgba(0, 0, 0, 0.35)); opacity: 0; pointer-events: none; animation: field-punch-card-chad 0.8s cubic-bezier(0.4, 0, 0.9, 0.6) forwards; }
  @keyframes field-punch-card-chad { 0% { opacity: 1; transform: none; } 100% { opacity: 0; transform: translate(var(--dx), 70px) rotate(var(--r)); } }
  /* The punch head rides above the card to the next column. */
  .field-punch-card__head { position: absolute; left: 0; top: 0; width: 9px; height: 8px; border-radius: 2px 2px 0 0; background: light-dark(#5f626a, #a7abb2); transition: transform 0.25s cubic-bezier(0.3, 1.4, 0.5, 1); }
  .field-punch-card__head::after { content: ''; position: absolute; left: 3px; bottom: -3px; width: 3px; height: 3px; background: light-dark(#5f626a, #a7abb2); }
  .field-punch-card:has(.field-punch-card__input:focus) .field-punch-card__head { background: light-dark(#373a40, #f1f2f3); }
  .field-punch-card:has(.field-punch-card__input:focus) .field-punch-card__head::after { background: light-dark(#373a40, #f1f2f3); }
  .field-punch-card[data-bad='0'] { animation: field-punch-card-nope-a 0.3s; }
  .field-punch-card[data-bad='1'] { animation: field-punch-card-nope-b 0.3s; }
  @keyframes field-punch-card-nope-a { 25% { transform: translateX(-3px); } 75% { transform: translateX(3px); } }
  @keyframes field-punch-card-nope-b { 25% { transform: translateX(-3px); } 75% { transform: translateX(3px); } }
  @media (prefers-reduced-motion: reduce) {
    .field-punch-card * { animation: none !important; transition: none !important; }
    .field-punch-card__chad { display: none; }
  }
`;

export function Preview() {
  const id = useId();
  const [text, setText] = useState('RUN MOVIN');
  const [caret, setCaret] = useState(text.length);
  const [bad, setBad] = useState(0);
  const up = text.toUpperCase();
  const onChange = (raw: string) => {
    // Characters the card has no code for are refused, with a rattle.
    if ([...raw.toUpperCase()].some((c) => holes(c) === null)) return setBad((b) => b + 1);
    setText(raw);
  };
  // The punch head waits over the column the caret is in.
  const track = (e: { currentTarget: HTMLInputElement }) => setCaret(e.currentTarget.selectionStart ?? e.currentTarget.value.length);
  const col = Math.min(caret, COLS - 1);
  return (
    <>
      <style href="kit-inputs-punch-card" precedence="kit">
        {css}
      </style>
      <div className="field-punch-card" data-bad={bad ? bad % 2 : undefined}>
        <label className="field-punch-card__wrap" htmlFor={id}>
          <span className="field-punch-card__head" style={{ transform: `translateX(${colX(col)}px)` }} aria-hidden="true" />
          <span className="field-punch-card__card" aria-hidden="true">
            <span className="field-punch-card__band" style={{ transform: `translateX(${colX(col)}px)` }} />
            {Array.from({ length: 10 }, (_, d) => (
              <span key={d} className="field-punch-card__digits" style={{ top: Y0 + (d + 2) * RH + 1 }}>
                {String(d).repeat(COLS)}
              </span>
            ))}
            {[...up].map((ch, c) => (
              <span key={`${c}${ch}`}>
                <span className="field-punch-card__printed" style={{ left: colX(c) }}>
                  {ch}
                </span>
                {(holes(ch) ?? []).map((r) => (
                  <span key={r}>
                    <i className="field-punch-card__hole" style={{ left: +(colX(c) + 2.6).toFixed(1), top: +(Y0 + r * RH + 0.7).toFixed(1) }} />
                    <i
                      className="field-punch-card__chad"
                      style={{ left: +(colX(c) + 2.6).toFixed(1), top: +(Y0 + r * RH + 0.7).toFixed(1), '--dx': `${((c * 7 + r * 3) % 9) - 4}px`, '--r': `${((c * 31 + r * 17) % 300) - 150}deg` } as CSSProperties}
                    />
                  </span>
                ))}
              </span>
            ))}
          </span>
        </label>
        <label className="field-punch-card__field">
          <span className="field-punch-card__prompt" aria-hidden="true">
            &gt;
          </span>
          <input
            id={id}
            className="field-punch-card__input"
            maxLength={COLS}
            autoComplete="off"
            spellCheck={false}
            placeholder="Type a command"
            aria-label="Command"
            value={text}
            onChange={(e) => {
              onChange(e.target.value);
              track(e);
            }}
            onSelect={track}
          />
          <span className="field-punch-card__col" aria-hidden="true">
            <b>{String(Math.min(caret + 1, COLS)).padStart(2, '0')}</b>/{COLS}
          </span>
        </label>
      </div>
    </>
  );
}

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

Install with shadcn

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

About this component

A command typed onto a punch card: every character punches its real column code, the chads fall out, the letter prints along the top and the punch head waits over the column the caret is in.

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

A native text input named "Command" with a visible placeholder; focus outlines the field, and clicking the card focuses it too. The card, the head and the column count are aria-hidden. Under prefers-reduced-motion holes appear without the punch or the falling chads.

More inputs

See all
Sand
Shredder
Sieve

Message in a bottle