TypewriterATM
A line that types itself: character by character behind a cursor, held, deleted and on to the next string. The strings are authored as elements - without script the first one shows as text, and a screen reader hears the whole list once, never a keystroke at a time; the typed copy is aria-hidden. The cursor is solid while typing and blinks while idle, like a real one. Next to Text Rotate, which rolls words without typing.
On this page (12)
§Typewriter
data-loop cycles the strings: type, hold (data-pause), delete, next. The cursor is solid while typing and blinks while idle. A screen reader hears 'Build design systems. prototypes. dashboards.' once.
§A colour per string
Each string's class or style goes with it: the typed line takes that colour while the string is on screen. --typewriter-cursor-color sets the cursor apart.
§Types once
One string, no data-loop: it types, then stops in the done state and fires typewriter-done - data-cursor-hide='done' lets the cursor go. Replay sets the default state again.
§Cursor styles
data-cursor: the default bar, block, underscore or none; data-cursor-hide='typing' shows it only while the line is idle.
§Speed and natural rhythm
data-speed / data-delete-speed set the ms per character, data-pause the hold; data-variable jitters every keystroke (0.5×-1.5×) so it reads like a person typing, not a metronome.
§Reserved width
In centred text a growing line pushes its neighbours around; data-reserve makes the box as wide as the longest string from the start, and data-align='center' centres the shorter ones inside it.
§Pause and resume
The State API drives it: paused freezes the line where it is, default resumes it, done stops on a string in full. typewriter-typed reports each completed string.
§When it scrolls into view
data-trigger='visible' waits for the line to enter the viewport (IntersectionObserver), with data-start-delay before the first key - scroll down the page and back to see it wait.
§In a terminal
Inside a code mockup line: the command types itself behind a block cursor - the mockup draws the prompt, the typewriter the keystrokes.
§States
Named states via the shared State API, driven per instance through the bound api:
default- running; restarts from the first string (or{ index }), resumes when coming frompausedpaused- frozen where it is, the cursor blinksdone- stopped with a string in full ({ index }); entered by itself at the end of a run withoutdata-loop
The first example carries data-state-demo; bun run screenshots drives it via el.api.setState(name) and captures screenshots/{mode}/typewriter-{state}.png.
Machine contract - verified against typewriter.schema.json by bun run verify:
| State | Type | Values | Default | Description |
|---|---|---|---|---|
index | number | — | 0 | The string on screen; setState('default', { index }) restarts from it. |
paused | boolean | true, false | false | Frozen where it is; the cursor blinks. |
done | boolean | true, false | false | Stopped with a string in full; entered at the end of a run without data-loop. |
§CSS view file
/* -- Typewriter component --------------------------------------- A line typed character by character behind a cursor, deleted and cycled through a list of strings. The authored strings stay in the DOM for assistive tech (visually hidden); the runtime paints an aria-hidden copy. Without script, the first string shows as plain text. */@layer components { .typewriter { --_cursor: var(--typewriter-cursor-color, currentColor); position: relative; display: inline; /* no script (or not yet initialized): the first string, as text */ &:not([data-init]) > :not(:first-child) { display: none; } /* initialized: the authored strings are read, not seen */ & > .typewriter-source { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; } & > .typewriter-line { white-space: pre-wrap; } /* -- Cursor: a bar by default; solid while typing, blinking idle -- */ & .typewriter-cursor { display: inline-block; width: max(2px, 0.08em); height: 1.1em; margin-inline-start: 0.06em; vertical-align: -0.15em; background: var(--_cursor); animation: typewriter-blink 1s steps(1) infinite; } &:is([data-phase="typing"], [data-phase="deleting"]) .typewriter-cursor { animation: none; } &[data-cursor="block"] .typewriter-cursor { width: 0.55em; opacity: 0.85; } &[data-cursor="underscore"] .typewriter-cursor { width: 0.6em; height: max(2px, 0.1em); vertical-align: -0.1em; } &[data-cursor="none"] .typewriter-cursor { display: none; } /* data-cursor-hide: "typing" = only while idle; "done" = gone at the end */ &[data-cursor-hide="typing"]:is([data-phase="typing"], [data-phase="deleting"]) .typewriter-cursor, &[data-cursor-hide="done"][data-state-name="done"] .typewriter-cursor { visibility: hidden; } /* -- data-reserve: as wide as the longest string from the start --- */ &[data-reserve] { display: inline-grid; justify-items: start; vertical-align: bottom; & > :is(.typewriter-line, .typewriter-ghost) { grid-area: 1 / 1; } & > .typewriter-ghost { display: grid; visibility: hidden; pointer-events: none; /* room for the cursor after the longest string */ padding-inline-end: 0.2em; & > span { grid-area: 1 / 1; white-space: pre; } } & > .typewriter-line { white-space: pre; } } &[data-reserve][data-align="center"] { justify-items: center; } &[data-reserve][data-align="end"] { justify-items: end; } } @keyframes typewriter-blink { 50% { opacity: 0; } } /* -- Accessibility -------------------------------------------- */ @media (prefers-reduced-motion: reduce) { /* the runtime swaps whole strings instead of typing; the cursor stays still */ .typewriter .typewriter-cursor { animation: none; } } @media (prefers-contrast: more) { .typewriter[data-cursor="block"] .typewriter-cursor { opacity: 1; } } @media (forced-colors: active) { .typewriter .typewriter-cursor { background: CanvasText; forced-color-adjust: none; } }}§JS view file
// -- Typewriter -------------------------------------------------// Types a list of strings character by character, holds, deletes and// cycles, behind a cursor. The strings are authored as child elements, so// the markup is readable without script and a screen reader hears the whole// list once (the moving copy is aria-hidden) - never a live region churning// through every keystroke. Plus the named-state API (AGENTS.md "State API").// Shared preamble (AGENTS.md "State API"); the implementation lives in core.js —// build.ts rewrites this import into a df$.shadcn.shared binding in dist/.import { defussGlobals } from '../../shared/state-api.js';const df$ = defussGlobals();const typewriterStates = ['default', 'paused', 'done'];const num = (el, key, fallback) => { const v = parseFloat(el.dataset[key]); return Number.isFinite(v) && v >= 0 ? v : fallback;};const reducedMotion = () => globalThis.matchMedia?.('(prefers-reduced-motion: reduce)').matches;/** Characters as the reader sees them (an emoji or accented letter is one). */const graphemes = (text) => globalThis.Intl?.Segmenter ? Array.from(new Intl.Segmenter(undefined, { granularity: 'grapheme' }).segment(text), (s) => s.segment) : Array.from(text);/** Paints `count` characters of string `index`, with that string's colour. */function paint(tw, index, count) { const src = tw._sources[index]; tw._text.textContent = src.chars.slice(0, count).join(''); // colour cycling: each string may carry its own class / style tw._text.className = `typewriter-text${src.className ? ` ${src.className}` : ''}`; tw._text.setAttribute('style', src.style); tw.dataset.index = String(index); tw._index = index; tw._count = count;}/** Sets the phase CSS keys on (the cursor is solid while typing, blinks idle). */const phase = (tw, name) => { tw.dataset.phase = name; };function stop(tw) { clearTimeout(tw._timer); tw._timer = 0;}/** Delay for the next keystroke; data-variable jitters it for a human rhythm. */function keyDelay(tw, base) { if (!tw.hasAttribute('data-variable')) return base; return base * (0.5 + Math.random());}/** * One step of the loop: type the current string, hold, delete, move on. * Each step schedules the next; stop() cancels the chain. */function step(tw) { if (!tw.isConnected) return stop(tw); const src = tw._sources[tw._index]; const last = tw._index === tw._sources.length - 1; const loop = tw.hasAttribute('data-loop'); const next = (fn, ms) => { tw._timer = setTimeout(() => fn(tw), ms); }; if (tw._deleting) { if (tw._count > 0) { phase(tw, 'deleting'); paint(tw, tw._index, tw._count - 1); return next(step, keyDelay(tw, num(tw, 'deleteSpeed', 35))); } tw._deleting = false; paint(tw, (tw._index + 1) % tw._sources.length, 0); return next(step, num(tw, 'speed', 70)); } if (tw._count < src.chars.length) { phase(tw, 'typing'); paint(tw, tw._index, tw._count + 1); return next(step, keyDelay(tw, num(tw, 'speed', 70))); } // the string is complete tw.dispatchEvent(new CustomEvent('typewriter-typed', { bubbles: true, detail: { index: tw._index, text: src.text } })); if (last && !loop) return finish(tw); phase(tw, 'holding'); tw._deleting = true; return next(step, num(tw, 'pause', 1500));}/** Stops on the current string, complete - the natural end of a run. */function finish(tw) { typewriterApi.setState(tw, 'done', { index: tw._index }); tw.dispatchEvent(new CustomEvent('typewriter-done', { bubbles: true, detail: { index: tw._index } }));}/** Reduced motion: whole strings swap in place on the pause rhythm - no typing. */function stepInstant(tw) { if (!tw.isConnected) return stop(tw); const last = tw._index === tw._sources.length - 1; paint(tw, tw._index, tw._sources[tw._index].chars.length); phase(tw, 'idle'); if (last && !tw.hasAttribute('data-loop')) return finish(tw); tw._timer = setTimeout(() => { paint(tw, (tw._index + 1) % tw._sources.length, 0); stepInstant(tw); }, num(tw, 'pause', 1500) + 1000);}function run(tw, delay = 0) { stop(tw); const go = () => (reducedMotion() ? stepInstant(tw) : step(tw)); if (delay) tw._timer = setTimeout(go, delay); else go();}/** * UI side of setState. 'default' (re)starts the cycle - from string * `{ index }` when given; coming from 'paused' without an index it resumes * where it stopped; otherwise from the first string. 'paused' freezes it where it * is (the cursor blinks); 'done' stops with a string shown in full * (`{ index }`, default the current one). */function triggerStateChange(tw, stateName, config, previous) { const count = tw._sources.length; // an editor may hand the index over as a string const asked = config.index === undefined || config.index === '' ? NaN : Number(config.index); const pick = Number.isInteger(asked) ? Math.min(Math.max(asked, 0), count - 1) : undefined; switch (stateName) { case 'default': if (pick === undefined && previous === 'paused') { run(tw); break; } tw._deleting = false; paint(tw, pick ?? 0, 0); phase(tw, 'idle'); run(tw, config.immediate ? 0 : num(tw, 'startDelay', 0)); break; case 'paused': stop(tw); phase(tw, 'idle'); break; case 'done': { stop(tw); const index = pick ?? tw._index ?? 0; paint(tw, index, tw._sources[index].chars.length); phase(tw, 'idle'); break; } }}/** Registry-level API; pass the .typewriter element explicitly. Unknown names throw. */export const typewriterApi = { setState(tw, stateName, config = {}) { if (!typewriterStates.includes(stateName)) { throw new Error( `typewriter: unknown state "${stateName}" (supported: ${typewriterStates.join(', ')})`, ); } const previous = tw.dataset.stateName; tw.dataset.stateName = stateName; tw._stateConfig = config; triggerStateChange(tw, stateName, config, previous); }, getState(tw) { return { name: tw.dataset.stateName || 'default', config: tw._stateConfig ?? {} }; },};df$.typewriterApi = typewriterApi;df$.typewriterStates = typewriterStates;function init() { document.querySelectorAll('.typewriter:not([data-init])').forEach((tw) => { tw.dataset.init = ''; const children = Array.from(tw.children); if (!children.length) return; // no strings authored - nothing to type tw._sources = children.map((c) => ({ text: c.textContent ?? '', chars: graphemes(c.textContent ?? ''), className: c.getAttribute('class') ?? '', style: c.getAttribute('style') ?? '', })); // the authored strings stay in the DOM for assistive tech (visually // hidden by CSS); the typed copy and the cursor are presentation only children.forEach((c) => c.classList.add('typewriter-source')); // the visible line: the typed text + the cursor, one unit const line = document.createElement('span'); line.className = 'typewriter-line'; line.setAttribute('aria-hidden', 'true'); tw._text = document.createElement('span'); tw._text.className = 'typewriter-text'; const cursor = document.createElement('span'); cursor.className = 'typewriter-cursor'; line.append(tw._text, cursor); tw.append(line); // data-reserve: the box is as wide as the longest string from the // start, so the sentence around it never shifts if (tw.hasAttribute('data-reserve')) { const ghost = document.createElement('span'); ghost.className = 'typewriter-ghost'; ghost.setAttribute('aria-hidden', 'true'); tw._sources.forEach((s) => { const g = document.createElement('span'); g.textContent = s.text; ghost.appendChild(g); }); tw.append(ghost); } tw.api = { setState: (stateName, config) => typewriterApi.setState(tw, stateName, config), getState: () => typewriterApi.getState(tw), }; paint(tw, 0, 0); phase(tw, 'idle'); tw.dataset.stateName = 'default'; // data-trigger="visible": start the first time it scrolls into view if (tw.dataset.trigger === 'visible' && globalThis.IntersectionObserver) { const io = new IntersectionObserver((entries) => { if (entries.some((e) => e.isIntersecting)) { io.disconnect(); typewriterApi.setState(tw, 'default', {}); } }); io.observe(tw); } else { typewriterApi.setState(tw, 'default', {}); } });}init();new MutationObserver(init).observe(document, { childList: true, subtree: true });Comments, ideas or improvements? Edit this page's source on GitHub