Typewriter

No dependencies
Code format
React + Tailwind
const TEXT = 'Loading...';
const N = TEXT.length;
const T = 3.2; // seconds per line
const STRIKES = Array.from({ length: N }, (_, i) => 0.15 + i * 0.17);
const DING = 1.92;
const RETURN = 2.2;
const FED = 2.72;
const LINE = 24;
const at = (s: number) => `${((s / T) * 100).toFixed(2)}%`;
// Each letter sits a hair off its neighbours, the way a worn typewriter prints.
const JITTER = [0.4, -0.3, 0.2, 0.5, -0.4, 0.1, -0.2, 0.3, -0.1, 0.4];
const INK = [1, 0.86, 0.95, 0.9, 1, 0.82, 0.94, 1, 0.88, 0.96];

// The carriage: a jolt as each key lands, a step left after it, then the return and a line feed.
const carriage = [
  '0% { transform: translate(0, 0); }',
  ...STRIKES.flatMap((s, i) => [
    `${at(s)} { transform: translate(${-i}ch, 0); }`,
    `${at(s + 0.02)} { transform: translate(${-i}ch, 0.8px); }`,
    `${at(s + 0.06)} { transform: translate(${-(i + 1)}ch, 0); }`,
  ]),
  `${at(RETURN)} { transform: translate(${-N}ch, 0); animation-timing-function: cubic-bezier(0.55, 0, 0.25, 1); }`,
  `${at(FED)} { transform: translate(0, ${-LINE}px); }`,
  `100% { transform: translate(0, ${-LINE}px); }`,
].join(' ');
// The line being typed is revealed one character at the moment its key lands.
const reveal = ['0% { width: 0; }', ...STRIKES.map((s, i) => `${at(s)} { width: ${i + 1}ch; }`), `100% { width: ${N}ch; }`].join(' ');
// The typebar flicks up to the paper for every strike and falls back.
const typebar = [
  '0%, 100% { transform: rotate(-58deg); opacity: 0; }',
  ...STRIKES.flatMap((s) => [`${at(s - 0.05)} { transform: rotate(-58deg); opacity: 0; }`, `${at(s - 0.03)} { opacity: 1; }`, `${at(s)} { transform: rotate(0deg); opacity: 1; }`, `${at(s + 0.05)} { opacity: 1; }`, `${at(s + 0.07)} { transform: rotate(-58deg); opacity: 0; }`]),
].join(' ');

const css = `
  .loader-typewriter { --paper: light-dark(#f3f4f7, #e5e6e9); --ink: light-dark(#373a40, #121417); --steel: light-dark(#6d7179, #7d8087); position: relative; width: 230px; height: 164px; overflow: hidden; -webkit-mask: linear-gradient(90deg, transparent, #000 14%, #000 86%, transparent), linear-gradient(transparent, #000 30%); -webkit-mask-composite: source-in; mask: linear-gradient(90deg, transparent, #000 14%, #000 86%, transparent), linear-gradient(transparent, #000 30%); mask-composite: intersect; }
  /* The sheet: its left edge starts at the strike point and is carried left a character at a time. */
  .loader-typewriter__paper { position: absolute; left: 87px; top: -40px; width: 240px; height: 206px; padding: 0 0 0 28px; box-sizing: border-box; background: var(--paper); box-shadow: 0 0 0 1px color-mix(in oklab, var(--ink) 16%, transparent), 0 6px 14px light-dark(rgba(0, 0, 0, 0.09), rgba(0, 0, 0, 0.3)); color: var(--ink); font: 700 18px/${LINE}px 'Courier New', Courier, ui-monospace, monospace; animation: loader-typewriter-carriage ${T}s infinite; }
  .loader-typewriter__line { position: absolute; left: 28px; width: ${N}ch; height: ${LINE}px; overflow: hidden; white-space: pre; text-shadow: 0 0 0.6px var(--ink); }
  .loader-typewriter__line span { display: inline-block; }
  .loader-typewriter__line--now { animation: loader-typewriter-reveal ${T}s steps(1, end) infinite; }
  /* The platen the paper wraps round, with the guide that marks where the type lands. */
  .loader-typewriter__platen { position: absolute; left: 0; right: 0; top: 134px; height: 30px; border-radius: 15px; background: linear-gradient(light-dark(#50535b, #2e3035), light-dark(#373a40, #1a1c1f) 55%, light-dark(#42454c, #232529)); box-shadow: inset 0 2px 0 color-mix(in oklab, light-dark(#373a40, #f1f2f3) 18%, transparent), 0 -3px 8px light-dark(rgba(0, 0, 0, 0.08), rgba(0, 0, 0, 0.25)); }
  .loader-typewriter__guide { position: absolute; left: 103px; top: 112px; width: 24px; height: 16px; background: linear-gradient(var(--steel), color-mix(in oklab, var(--steel) 70%, light-dark(#fbfcfd, #121417))); clip-path: polygon(0 0, 40% 0, 50% 45%, 60% 0, 100% 0, 100% 100%, 0 100%); }
  .loader-typewriter__bar { position: absolute; left: 114px; top: 112px; width: 3px; height: 80px; border-radius: 1.5px; background: linear-gradient(light-dark(#50535b, #c6c8cd), var(--steel) 30%); transform-origin: 50% 100%; translate: 0 -2px; animation: loader-typewriter-typebar ${T}s infinite; }
  .loader-typewriter__bar::before { content: ''; position: absolute; left: -3px; top: -2px; width: 9px; height: 7px; border-radius: 1px; background: light-dark(#50535b, #c6c8cd); }
  .loader-typewriter__ding { position: absolute; right: 26px; top: 18px; color: light-dark(#5f626a, #a7abb2); font: italic 600 12px Georgia, 'Times New Roman', serif; opacity: 0; animation: loader-typewriter-ding ${T}s ease-out infinite; }
  @keyframes loader-typewriter-carriage { ${carriage} }
  @keyframes loader-typewriter-reveal { ${reveal} }
  @keyframes loader-typewriter-typebar { ${typebar} }
  @keyframes loader-typewriter-ding { 0%, ${at(DING - 0.01)} { opacity: 0; transform: none; } ${at(DING + 0.06)} { opacity: 1; transform: translateY(-4px) rotate(-6deg); } ${at(DING + 0.5)}, 100% { opacity: 0; transform: translateY(-8px) rotate(-6deg); } }
  @media (prefers-reduced-motion: reduce) {
    .loader-typewriter *, .loader-typewriter *::before { animation: none !important; }
    .loader-typewriter__paper { transform: translateX(-5ch); }
    .loader-typewriter__bar { opacity: 0; }
  }
`;

function Typed() {
  return (
    <>
      {[...TEXT].map((ch, i) => (
        <span key={i} style={{ transform: `translateY(${JITTER[i]}px)`, opacity: INK[i] }}>
          {ch}
        </span>
      ))}
    </>
  );
}

export function Preview() {
  return (
    <>
      <style href="kit-loaders-typewriter" precedence="kit">
        {css}
      </style>
      <div className="loader-typewriter" role="status" aria-label="Loading">
        <div className="loader-typewriter__paper" aria-hidden="true">
          {[0, 1, 2].map((k) => (
            <div key={k} className={`loader-typewriter__line${k === 2 ? ' loader-typewriter__line--now' : ''}`} style={{ top: 128 + (k - 2) * LINE }}>
              <Typed />
            </div>
          ))}
        </div>
        <span className="loader-typewriter__bar" aria-hidden="true" />
        <span className="loader-typewriter__guide" aria-hidden="true" />
        <span className="loader-typewriter__platen" aria-hidden="true" />
        <span className="loader-typewriter__ding" aria-hidden="true">
          ding
        </span>
      </div>
    </>
  );
}

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

Install with shadcn

npx shadcn@latest add @movin/loader-typewriter
Source on GitHub

About this component

Loading... typed onto paper one strike at a time: the typebar flicks up, the carriage steps along, the bell rings and the carriage returns a line.

Dependencies
None
Difficulty
Advanced
Triggers
continuous, loop, keyframe
Techniques
keyframes, text animation, clip path, transform only
Reduced motion
Respected
Theme
Light and dark (follows your page's color-scheme)

The root has role="status" and aria-label="Loading"; everything inside it is aria-hidden. Under prefers-reduced-motion the sheet shows typed lines and the carriage rests mid-line.

More loaders

See all
Worm
Abacus
Barber pole
Ferris wheel