Theme
On this page (12)
Component Skill — components/typewriter/component-skill.md

Native basis

A <span class="typewriter"> with one child per string; the runtime hides them visually and types an aria-hidden copy with a CSS cursor.

Web Platform APIs

Intl.SegmenterIntersectionObserverCustomEventaria-hiddenprefers-reduced-motion

Classes

.typewriter.typewriter-cursor

Data attributes

data-loop, data-speed (70), data-delete-speed (35), data-pause (1500), data-start-delay, data-variable, data-trigger="visible", data-cursor (block, underscore, none), data-cursor-hide (typing, done), data-reserve, data-align; --typewriter-cursor-color; events typewriter-typed, typewriter-done.

§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 from paused
  • paused - frozen where it is, the cursor blinks
  • done - stopped with a string in full ({ index }); entered by itself at the end of a run without data-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:

StateTypeValuesDefaultDescription
indexnumber—0The string on screen; setState('default', { index }) restarts from it.
pausedbooleantrue, falsefalseFrozen where it is; the cursor blinks.
donebooleantrue, falsefalseStopped 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