typewriter

[github]

phrases typed and deleted in turn behind a blinking cursor

type 44×3 20 fps 4.1 kB

use it

html

<script type="module" src="https://ascii.rest/ascii.js"></script>

<ascii-art piece="typewriter" options='{"prefix":"i make ","phrases":["small tools.","quiet websites.","things that last.","notes for later."]}'></ascii-art>

No install and no build step. ascii.js defines the <ascii-art> tag, loads the piece from ascii.rest, plays it while it is on screen, and holds the first frame for anyone who prefers reduced motion.

react

// npm install github:bas3line/ascii
import { Ascii } from "ascii.rest/react";
import { typewriter } from "ascii.rest/pieces";

<Ascii piece={typewriter} options={{"prefix":"i make ","phrases":["small tools.","quiet websites.","things that last.","notes for later."]}} />

A client component, so it works in the Next.js app router as it is. Pass the name instead, piece="typewriter", to fetch the piece only when it mounts.

astro

---
// npm install github:bas3line/ascii
import Ascii from "ascii.rest/astro";
---

<Ascii piece="typewriter" options={{"prefix":"i make ","phrases":["small tools.","quiet websites.","things that last.","notes for later."]}} />

Renders the first frame on the server, so the page is whole before any script runs, then plays the piece once it loads.

typescript

// npm install github:bas3line/ascii
import { mount } from "ascii.rest";
import { typewriter } from "ascii.rest/pieces";

const el = document.querySelector<HTMLPreElement>("pre")!;
const stop = mount(el, typewriter, {"prefix":"i make ","phrases":["small tools.","quiet websites.","things that last.","notes for later."]});

mount plays a piece in a <pre>, or on a <canvas> for the coloured ones, and returns a function that stops it. Every piece, option and frame is typed.

typewriter.ts

[js] [github]
/*
 * typewriter: types each phrase a key at a time at an uneven human pace, now
 * and then fixing a typo, holds it under a blinking cursor, deletes it, types the next.
 */
import type { Frame, Meta } from "../types.ts";

export interface TypewriterOptions {
  [key: string]: unknown;
  /** Typed before every phrase and never deleted. */
  prefix: string;
  phrases: string[];
}

export const meta = {
  name: "typewriter",
  category: "type",
  note: "phrases typed and deleted in turn behind a blinking cursor",
  cols: 44,
  rows: 3,
  fps: 20,
  options: { prefix: "i make ", phrases: ["small tools.", "quiet websites.", "things that last.", "notes for later."] },
} satisfies Meta<TypewriterOptions>;

const CURSOR = "▌";
const HOLD = 2.2; // seconds a finished phrase stays
const EMPTY = 0.5; // seconds the line waits empty before the next phrase
// Keys next to each other on a keyboard, for believable slips.
const ROWS = ["qwertyuiop", "asdfghjkl", "zxcvbnm"];

function mulberry32(a: number) {
  return () => {
    a = (a + 0x6d2b79f5) | 0;
    let t = Math.imul(a ^ (a >>> 15), 1 | a);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

function slip(ch: string, rand: () => number): string | null {
  for (const row of ROWS) {
    const i = row.indexOf(ch);
    if (i >= 0) return row[i === 0 ? 1 : i === row.length - 1 ? i - 1 : i + (rand() < 0.5 ? -1 : 1)];
  }
  return null;
}

export default function typewriter({ prefix = meta.options.prefix, phrases = meta.options.phrases }: Partial<TypewriterOptions> = {}): Frame {
  const { cols, rows } = meta;
  const head = String(prefix);
  const list = (phrases.length ? phrases : [""]).map((p) => String(p).slice(0, Math.max(0, cols - 4 - head.length)));
  const rand = mulberry32(31);

  // The whole loop as a list of [time, text on the line], laid out once.
  const events: [number, string][] = [];
  let at = 0, text = "";
  const key = (next: string, wait: number) => {
    at += wait;
    text = next;
    events.push([at, text]);
  };
  const typeDelay = (ch: string) => 0.06 + rand() * 0.09 + (ch === " " ? 0.05 + rand() * 0.08 : 0) + (rand() < 0.05 ? 0.3 : 0);
  const done: number[] = [];
  for (const phrase of list) {
    // Some phrases get one slip: a wrong key, a key or two more, a pause, then backspaces.
    const typo = rand() < 0.5 && phrase.length > 5 ? 3 + Math.floor(rand() * (phrase.length - 4)) : -1;
    for (let i = 0; i < phrase.length; i++) {
      const wrong = i === typo ? slip(phrase[i], rand) : null;
      if (wrong) {
        const more = Math.min(Math.floor(rand() * 3), phrase.length - i - 1);
        key(text + wrong, typeDelay(phrase[i]));
        for (let k = 1; k <= more; k++) key(text + phrase[i + k], typeDelay(phrase[i + k]));
        at += 0.25 + rand() * 0.2;
        for (let k = 0; k <= more; k++) key(text.slice(0, -1), k ? 0.07 : 0.1);
      }
      key(text + phrase[i], typeDelay(phrase[i]) + (i && ".,!?".includes(phrase[i - 1]) ? 0.15 : 0));
    }
    done.push(at);
    // Held backspace: one press, a beat, then it repeats.
    at += HOLD;
    for (let k = 0; k < phrase.length; k++) key(text.slice(0, -1), k === 0 ? 0 : k === 1 ? 0.18 : 0.045);
    at += EMPTY;
  }
  const period = at;
  // Frame 0 shows the first phrase finished, cursor lit, shortly before it is deleted.
  const offset = done[0] + HOLD - 0.75;

  const width = head.length + Math.max(...list.map((p) => p.length)) + 1;
  const x = Math.max(0, Math.floor((cols - width) / 2));
  const blank = " ".repeat(cols);
  return (t) => {
    const now = (((t + offset) % period) + period) % period;
    let line = "", last = 0;
    for (const [when, s] of events) {
      if (when > now) break;
      line = s;
      last = when;
    }
    // Solid while keys land, blinking once the cursor has sat still.
    const idle = now - last;
    const lit = idle < 0.45 || (idle - 0.45) % 1.06 > 0.53;
    const body = head + line + (lit ? CURSOR : "");
    const out = (" ".repeat(x) + body).slice(0, cols).padEnd(cols);
    return Array.from({ length: rows }, (_, r) => (r === rows >> 1 ? out : blank)).join("\n");
  };
}