typewriter-ts

A tiny, dependency-free typewriter for the browser, Node, and anything else that holds text. Every example on this page is running live.

npm install @andreasnicolaou/typewriter-ts GitHub npm

A rotating headline reading: Small package. Big personality. Type into anything that holds text. No dependencies, no framework.

Loading typewriter-ts…

What you can build with it

Twelve things people actually ship, each with the code that produces it.

Human cadence

Bars below show the real gap between keystrokes. A fixed delay is a perfect comb; jitter ruins it, which is the point.

jitter
new Typewriter(el, { delay: 90, jitter: 0.9, cursor: '▋', loop: true })
  .type('the quick brown fox')
  .pause(1200)
  .delete()
  .pause(400)
  .start();

Unicode that never breaks

Text advances by grapheme cluster, so families, flags and accents are never torn in half.

graphemes
// one 👨‍👩‍👧 is one keystroke, not seven code points
new Typewriter(el, { delay: 260, deleteDelay: 260, loop: true })
  .type('família 👨‍👩‍👧 café 🇨🇾 ときどき')
  .pause(1600)
  .delete()
  .start();

Typing and correcting

A partial delete(n) backs over the last few characters, so the writer can fix itself.

delete(n)
new Typewriter(el, { delay: 55, deleteDelay: 45, jitter: 0.5, loop: true })
  .type('Ship it on Fridya')
  .pause(650)
  .delete(3) // back over 'dya'
  .pause(220)
  .type('day')
  .pause(2200)
  .delete()
  .start();

A terminal session

The target can be any (text) => void callback, so it renders into a <pre>, a canvas, framework state, or a real TTY.

callback target
const render = (text) => (out.textContent = text);

new Typewriter(render, { delay: 26, jitter: 0.55, cursor: '█', loop: true })
  .type('$ npm install @andreasnicolaou/typewriter-ts\n')
  .pause(650)
  .type('added 1 package in 412ms\n\n')
  .pause(450)
  .type('$ node greet.js\n')
  .pause(550)
  .type('hello from Node ✔')
  .pause(2400)
  .delete()
  .start();

// in a real CLI:
new Typewriter((text) => process.stdout.write(`\r${text}`));

A streaming reply

The look of a model answering, without wiring up a stream.

DOM node
new Typewriter(bubble, { delay: 16, jitter: 0.8, cursor: '▊', loop: true })
  .type('Yes — it renders into a DOM node, a form field, or any callback.')
  .pause(3200)
  .delete()
  .pause(700)
  .start();

Into a form field

Anything with a value works, so inputs and textareas need no special casing.

input value
new Typewriter(document.querySelector('input'), {
  delay: 75,
  jitter: 0.4,
  cursor: false,
  loop: true,
})
  .type(['noise cancelling headphones', 'mechanical keyboard'], { pause: 1600 })
  .start();

Loading states

Rotating status lines read as progress, and cost nothing to run.

string array
new Typewriter(el, { delay: 34, deleteDelay: 12, jitter: 0.5, loop: true })
  .type(
    ['Connecting to the server…', 'Fetching your projects…', 'Almost there…'],
    { pause: 1200 }
  )
  .start();

Continue rendered text

The greeting below is real HTML in this page. initial hands it to the writer instead of wiping it on the first frame.

initial
const extra = ' — 3 new messages.';

new Typewriter(el, { initial: el.textContent, delay: 48, loop: true })
  .pause(1000)
  .type(extra)
  .pause(2400)
  .delete(extra.length) // leaves the server-rendered text intact
  .start();

Sequenced with await

Each line waits for the one above it to finish. The fourth carries on from its own awaited writer, because finished resolves with it.

finished
await new Typewriter(one).type('Uploading…').start().finished;
await new Typewriter(two).type('Processing…').start().finished;
await new Typewriter(three).type('Done ✔').start().finished;

// finished resolves with the writer, so the chain carries on
const writer = await new Typewriter(el).type('One moment…').start().finished;
writer.delete().type('Ready.').start();

Any framework

A callback target drives component state, so there is no DOM to reach into and nothing to adapt.

framework
// React
const [text, setText] = useState('');

useEffect(() => {
  const writer = new Typewriter(setText, { delay: 90, loop: true })
    .type(['React', 'Vue', 'Svelte', 'Solid', 'plain JS'], { pause: 1100 })
    .start();
  return () => writer.destroy();
}, []);

return <span>{text}</span>;

Code writing itself

Newlines are ordinary characters, so a snippet can appear line by line on a landing page.

multiline
new Typewriter(render, { delay: 24, jitter: 0.6, cursor: '▋', loop: true })
  .type("import { Typewriter } from '@andreasnicolaou/typewriter-ts';\n\n")
  .pause(450)
  .type('new Typewriter(headline)\n')
  .type("  .type('Ready when you are.')\n")
  .type('  .start();')
  .pause(2600)
  .delete()
  .pause(600)
  .start();

Under your control

Press them. stop() pauses after the current character and start() picks up where it left off. Once the queue is finished, reset() runs it again.

stop / start
const writer = new Typewriter(el, { delay: 45, jitter: 0.4 })
  .type('Start, stop, resume, reset.')
  .pause(400)
  .type(' The queue remembers where it left off.');

play.onclick = () => writer.start();
pause.onclick = () => writer.stop();
again.onclick = () => writer.reset();