Typewriter

Cycling typewriter text with a cursor.

Live demo

01

A four-phase state machine β€” type, pause, delete, wait β€” wrapped in a single span. Every section below mounts a different prop combination, all running at once.

Hero Β· multi-phrase loop Β· typeSpeed 70 Β· pauseDuration 2500

Four phrases cycle on a long pause. The status row shows which phrase the loop is currently revealing, so you can see the cycle without inspecting the DOM.

Current phrase #1 of 4 "Build beautiful interfaces."

Greetings Β· multilingual loop Β· typeSpeed 100 Β· deleteSpeed 60

Mixed-script phrases β€” the per-character timing handles emoji and CJK glyphs identically because the loop indexes by code point.

Single phrase Β· loop=false Β· types once and stops

Disable looping for hero copy you want to land and rest. The cursor keeps blinking after the typing completes.

Speed comparison Β· 60 vs 120 vs 250 ms per char

Three identical phrases, three different typeSpeed values. Watch the rightmost row crawl β€” useful for when you want the typing motion itself to convey weight.

60ms
120ms
250ms

Cursor variants Β· pipe Β· underscore Β· block Β· none

cursorChar accepts any glyph; showCursor=false hides it entirely once the phrase finishes.

'|' (default)
'_' underscore
'β–Œ' block
no cursor

Terminal Β· cycling commands Β· cursorChar="_"

A monospace surface with a green prompt. The blinking underscore mimics a real terminal caret.

$Β 

Implementation

02
Typewriter.svelte
<script lang="ts">
  import Typewriter from '$lib/components/Typewriter.svelte';
</script>
​
<Typewriter phrases={['Hello', 'World', 'Welcome']} typeSpeed={80} pauseDuration={2000} />

Typewriter runs a four-phase state machine β€” typing, pausing, deleting, waiting β€” driven by Svelte 5 $effect timers. The blinking cursor is pure CSS and the full target phrase is published via aria-label so screen readers announce the destination once instead of every keystroke.

Logic explainer

03

What Does It Do? (Plain English)

Typewriter reveals a list of phrases one character at a time, holds each phrase for a beat, deletes it, and types the next. The blinking cursor sits at the trailing edge throughout. It is the classic "I am a developer / designer / human" hero-section effect β€” typed by code rather than recorded in a video.

Think of it as a tiny actor on stage with a teleprompter: the script (phrases) is fixed, but the cadence is yours to direct via typeSpeed, deleteSpeed, and pauseDuration.

How It Works (Pseudo-Code)

state:
  displayText  = ''
  phraseIndex  = 0
  charIndex    = 0
  phase        = 'typing' | 'pausing' | 'deleting' | 'waiting'
  started      = startDelay > 0 ? false : true
  prefersReducedMotion = matchMedia('(prefers-reduced-motion: reduce)')

derive currentPhrase = phrases[phraseIndex]

on mount:
  if startDelay > 0:
    timer = setTimeout(() => started = true, startDelay)

on every state change (single $effect):
  if not started or phrases empty: bail
  if prefersReducedMotion: displayText = currentPhrase; bail

  switch phase:
    typing:
      if charIndex < currentPhrase.length:
        after typeSpeed ms β†’ charIndex++; displayText = slice(0, charIndex)
      else if not loop and last phrase:
        rest at full text  // never delete
      else:
        after pauseDuration ms β†’ phase = 'pausing'

    pausing:
      β†’ phase = 'deleting'  // explicit phase aids state-flow reasoning

    deleting:
      if charIndex > 0:
        after deleteSpeed ms β†’ charIndex--; displayText = slice(0, charIndex)
      else:
        after 200ms β†’ phase = 'waiting'

    waiting:
      phraseIndex = (phraseIndex + 1) mod phrases.length
      charIndex = 0
      phase = 'typing'

  cleanup: clearTimeout(timer)  // cancels in-flight tick on unmount / dep change

The whole machine lives in one $effect. Each branch sets exactly one timeout, and the cleanup function cancels it. Phase transitions schedule themselves; nothing runs in parallel.

The Core Concept: Single-Effect State Machine

Naive typewriters use setInterval and a tangled mess of "am I deleting yet?" booleans. This component compresses the entire lifecycle into a four-phase finite state machine driven by one $effect.

  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”  charIndex == phrase.length   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
  β”‚ typing  β”‚ ─────────────────────────────▢│ pausing  β”‚
  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜                                β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜
       β–²                                          β”‚ 0ms
       β”‚                                          β–Ό
       β”‚                                     β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
  β”Œβ”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”    charIndex == 0             β”‚ deleting β”‚
  β”‚ waiting  β”‚ ◀──────────────────────────── β”‚          β”‚
  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜                                β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
       β”‚ 200ms tick β†’ next phrase

The win: every $effect re-run reads the current phase, schedules exactly one timeout, and returns a cleanup. Svelte 5 invalidates the effect when any reactive dependency changes β€” so changing phrases mid-run cleanly cancels the in-flight tick and starts the new phase.

CSS Animation Strategy

The cursor is pure CSS β€” no JS toggle, no setInterval. A 1s step-end keyframe alternates opacity: 1 ↔ 0, which produces the chunky on-off flicker of a real terminal cursor (linear easing would look like a soft pulse).

@keyframes blink {
  0%, 100% { opacity: 1; }
  50%      { opacity: 0; }
}
.typewriter-cursor { animation: blink 1s step-end infinite; }

Reduced-motion users skip the entire JS animation (displayText = currentPhrase once) and the cursor freezes at full opacity (animation: none in the @media block). No flicker, no movement.

Performance

  • One setTimeout outstanding at any time. No interval fan-out, no parallel timers.
  • DOM is two <span>s: text and cursor. No per-character span tree, so re-renders are flat regardless of phrase length.
  • displayText = currentPhrase.slice(0, charIndex) β€” string slicing is O(n) but n is the live phrase, which is small (UI text). No measurable cost.
  • The cursor blink runs on the GPU compositor (opacity-only animation), so the JS thread stays free for the typing tick.

State Flow Diagram

                  startDelay elapsed
  [waiting-to-start] ─────────▢ [typing]
                                   β”‚
                                   β”‚ charIndex == phrase.length
                                   β–Ό
                              [pausing]  (pauseDuration ms)
                                   β”‚
                                   β–Ό
                              [deleting]
                                   β”‚
                                   β”‚ charIndex == 0
                                   β–Ό
                              [waiting]  (200ms)
                                   β”‚
                                   β–Ό phraseIndex = (i + 1) mod n
                              [typing]   (next phrase)

  prefers-reduced-motion: skip the whole graph, paint final text once.
  loop = false on last phrase: stop at end of [typing], no [pausing].

Props Reference

Prop Type Default Description
phrases string[] required Array of strings to cycle through.
typeSpeed number 80 Milliseconds between typed characters.
deleteSpeed number 50 Milliseconds between deleted characters.
pauseDuration number 2000 Milliseconds to hold a fully-typed phrase before deleting.
loop boolean true Cycle forever, or stop at the final phrase.
showCursor boolean true Render the trailing blinking cursor.
cursorChar string '|' Glyph used for the cursor.
startDelay number 0 Milliseconds to wait after mount before typing.
class string '' Extra classes on the wrapper span.

Edge Cases

Situation Behaviour
phrases is [] Effect bails immediately β€” empty span renders, no timers scheduled.
phrases updates mid-run $effect cleanup cancels the in-flight timeout; new run starts from the (still valid) current phaseIndex or wraps.
loop = false and final phrase typed Effect returns without scheduling β€” cursor keeps blinking on the resting text.
User has prefers-reduced-motion: reduce displayText snaps to currentPhrase; cursor animation freezes at opacity 1. Screen reader still gets the live aria-label.
Component unmounts during a tick Cleanup runs clearTimeout(timer); no orphan timer fires.
typeSpeed = 0 Each character ticks on the next event loop turn β€” visually instantaneous but each phrase still completes one phase at a time.
Surrogate-pair character (emoji) in a phrase slice operates on code units, so a multi-unit emoji may reveal half-formed for one tick; for emoji-heavy text use Array.from(phrase) upstream.

Dependencies

  • Svelte 5.x β€” single-$effect state machine relies on cleanup-function semantics.
  • $lib/types β€” imports the shared TypewriterProps interface.
  • Zero runtime dependencies otherwise β€” no setInterval, no animation library.

File Structure

src/lib/components/Typewriter.svelte   # implementation
src/lib/components/Typewriter.md       # this file (rendered inside ComponentPageShell)
src/routes/typewriter/+page.svelte     # demo page

API

04
PropTypeDefaultDescription
phrasesstring[]requiredStrings to cycle through.
typeSpeednumber80Milliseconds per typed character.
deleteSpeednumber50Milliseconds per deleted character.
pauseDurationnumber2000Hold time after a phrase completes.
loopbooleantrueLoop the phrase list or run once.
showCursorbooleantrueShow the blinking cursor.
cursorCharstring"|"Cursor character β€” pipe, underscore, block.
startDelaynumber0Delay before starting the first phrase.
classstring""Extra class for the wrapper span.