# Typewriter Text

> A sharp terminal-style typewriter that types, holds, accelerates through deletion, and cycles to the next phrase in sync with cursor and progress signals.

Canonical: https://gsapvault.com/effects/typewriter-text
Live demo: https://gsapvault.com/demos/typewriter-text/index.html

| Property | Value |
|----------|-------|
| Type | effect |
| Tier | free |
| Price | Free |
| Difficulty | beginner |
| Plugins | ScrollTrigger |
| Techniques | text-animation, typewriter, scroll-reveal, infinite-loop |
| Uses Lenis | No |

## Overview

A focused typewriter effect that turns one bold line into a paced creative command sequence. GSAP types each phrase character by character, gives it a deliberate hold, then accelerates the delete pass before landing on the next phrase, while optional cursor, status, progress, and background hooks react to the same phase changes. Configuration stays in data attributes and the complete text remains available without JavaScript or motion.

## Features

- Character-by-character typing driven by snapped GSAP proxy tweens
- Accelerating delete pass creates a clear shift in velocity
- Deliberate per-phrase hold prevents a frantic loop
- Optional looping phrases with responsive mobile alternates
- Automatic single-line fitting across every configured phrase
- Per-element speed, delay, hold, delete speed, and cursor controls
- Optional cursor, phase status, and progress hooks for synchronized UI feedback
- ScrollTrigger starts each sequence once when it enters the viewport
- Reduced-motion and no-JavaScript states show the strongest complete phrase
- Custom typewriter:start and typewriter:complete events for integration

## Use Cases

- Creative studio hero statements with terse rotating capabilities
- Developer-tool landing pages with a controlled terminal voice
- Product value propositions that need more character than a crossfade
- Portfolio introductions with a paced command-line reveal
- Section headlines triggered as the reader reaches them

## Vibe-Code Ready Setup

This effect includes `START-HERE-AI.md`, a product-specific copy-paste setup prompt for Cursor, Claude Code, ChatGPT, GitHub Copilot, Windsurf, and other coding assistants. It tells the assistant to inspect the existing stack, integrate the supplied files, preserve the design, scope selectors, retain accessibility and responsive behaviour, add framework-appropriate GSAP cleanup, and report what it tested.

[How AI-assisted setup works](https://gsapvault.com/vibe-coding)

## How It Works

Each [data-typewriter] element is read into an array of characters and rendered from a proxy value animated by gsap.timeline(). A linear snapped tween creates the rapid type pass, the timeline pauses for the configured hold, and a power3.in delete tween visibly gains speed before the next phrase begins. ScrollTrigger plays the paused sequence once on entry, while gsap.matchMedia() swaps the animation for the original complete phrase when reduced motion is requested; owned timelines, triggers, and replay handlers are removed through the context cleanup.

## Documentation

A scroll-triggered typewriter sequence that types rapidly, holds, accelerates through deletion, and cycles through optional phrases. Cursor, status, progress, and background hooks can react to every phase.

## Quick Start

**1. Add to your HTML `<head>`:**

```html
<link rel="stylesheet" href="path/to/style.css">
```

**2. Add before the closing `</body>` tag:**

```html
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/ScrollTrigger.min.js"></script>
<script src="path/to/script.js"></script>
```

**3. Add `data-typewriter` to text in your `<body>`:**

```html
<h1 data-typewriter>WE MAKE IDEAS MOVE.</h1>
```

The element's complete text is the static fallback. When it reaches 85% of the viewport, the script clears it and types it back once.

## Options

| Attribute | Values | Default | Description |
|---|---:|---:|---|
| `data-type-speed` | Seconds per character | `0.045` | Typing speed; lower values type faster |
| `data-type-delay` | Seconds | `0` | Delay after the element enters the viewport |
| `data-type-cursor` | `true`, `false` | `true` | Adds the generated cursor when no external cursor hook exists |
| `data-type-loop` | Comma-separated phrases | none | Phrases to rotate through after the element's own text |
| `data-type-mobile` | Text | element text | Shorter initial phrase below 600px to preserve a single line |
| `data-type-loop-mobile` | Comma-separated phrases | `data-type-loop` | Shorter looping phrases below 600px |
| `data-type-hold` | Seconds | `1.8` | Time each looping phrase remains complete |
| `data-type-delete-speed` | Multiplier | `0.5` | Delete duration relative to typing; lower is faster |

The original `speed`, `delay`, `cursor`, and looping phrase attributes remain compatible. `hold` and `delete-speed` are optional additions.

## Examples

### Looping Creative Commands

**Add to your HTML `<body>`:**

```html
<h1 data-typewriter
    data-type-speed="0.055"
    data-type-delay="0.35"
    data-type-hold="3"
    data-type-delete-speed="0.75"
    data-type-mobile="MAKE IT MOVE."
    data-type-loop="WE DESIGN SYSTEMS WITH INTENT.,WE SHIP MOTION AT FRAME RATE."
    data-type-loop-mobile="IDEAS IN MOTION.,SYSTEMS THAT MOVE.">
  WE MAKE DIGITAL IDEAS MOVE.
</h1>
```

The element's own text always runs first. Every phrase types linearly, holds, then deletes with `power3.in` acceleration before the next phrase lands.

### Deliberate One-Time Reveal

**Add to your HTML `<body>`:**

```html
<h2 data-typewriter data-type-speed="0.08" data-type-delay="0.5">
  Deliberate, dramatic typing.
</h2>
```

Omit `data-type-loop` for a one-time scroll-triggered sequence.

### Hide the Generated Cursor

**Add to your HTML `<body>`:**

```html
<p data-typewriter data-type-cursor="false">Clean typing, no cursor.</p>
```

## Optional System Hooks

Wrap the line in `data-typewriter-system` to synchronize your own interface. All hooks are optional.

**Add to your HTML `<body>`:**

```html
<section data-typewriter-system data-phase="ready">
  <h2 data-typewriter data-type-loop="BUILD BOLDLY,SHIP CLEARLY">
    DESIGN WITH INTENT
  </h2>

  <span data-typewriter-cursor aria-hidden="true"></span>
  <span data-typewriter-status aria-hidden="true">READY</span>
  <span class="progress" aria-hidden="true">
    <span data-typewriter-progress></span>
  </span>
  <button type="button" data-typewriter-replay>Replay</button>
</section>
```

During playback, the wrapper's `data-phase` changes between `typing`, `holding`, `deleting`, and `ready`. Use those values in CSS to react without adding more JavaScript:

**Add to your CSS:**

```css
[data-typewriter-system][data-phase="typing"] [data-typewriter-cursor] {
  background: lime;
}

[data-typewriter-system][data-phase="deleting"] [data-typewriter-cursor] {
  background: cyan;
  transform: scaleY(0.45);
}
```

## Generated CSS Classes

| Class | Description |
|---|---|
| `.typewriter__text` | Span containing the animated characters |
| `.typewriter__cursor` | Generated cursor when `data-type-cursor` is enabled |
| `.is-typing` | Applied while characters are being added |
| `.is-deleting` | Applied while characters are being removed |
| `.is-complete` | Applied after a non-looping sequence settles |

## Events

Events bubble from the animated element.

**Add to your JavaScript:**

```javascript
const el = document.querySelector('[data-typewriter]');

el.addEventListener('typewriter:start', (event) => {
  console.log('Sequence started:', event.detail.text);
});

el.addEventListener('typewriter:complete', (event) => {
  console.log('Phrase complete:', event.detail.text, event.detail.index);
});
```

| Event | Detail | Description |
|---|---|---|
| `typewriter:start` | `{ text }` | Fires when the timeline first begins |
| `typewriter:complete` | `{ text, index }` | Fires whenever a phrase finishes typing |

## Cleanup

The effect stores its GSAP context on `window.gsapContext`. In a page transition or component teardown, call:

**Add to your JavaScript teardown:**

```javascript
window.gsapContext?.revert();
```

This kills the effect's timelines and ScrollTriggers and removes replay listeners.

## Accessibility

- The element's original complete phrase is its no-JavaScript fallback and accessible label.
- Generated character and cursor spans are hidden from assistive technology.
- `prefers-reduced-motion: reduce` skips typing, deletion, cursor motion, and looping, leaving the strongest complete phrase visible.
- Replay uses a native `<button>`, so it works with keyboard and touch input.
- Reserve enough height for the longest phrase if your phrases vary substantially, preventing layout shift.

## Dependencies

**Required:**

- GSAP 3.12+
- ScrollTrigger

No SplitText or smooth-scroll library is required.

## Source Code

This effect is free. The complete source is included below.

### index.html

```html
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Typewriter Text Demo | GSAP Vault</title>
    <script data-cfasync="false">document.documentElement.classList.add('has-js')</script>
    <link rel="stylesheet" href="assets/style.css">
    <link rel="preconnect" href="https://fonts.googleapis.com">
    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
    <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=Space+Grotesk:wght@500;600;700&family=Syne:wght@600;700;800&display=swap" rel="stylesheet">
</head>
<body>
    <main class="showcase-shell">
        <section class="command-stage" data-typewriter-system data-phase="ready" aria-label="Creative command terminal typewriter demonstration">
            <div class="terminal-grid" aria-hidden="true"></div>
            <div class="terminal-glow" aria-hidden="true"></div>

            <header class="terminal-bar">
                <div class="terminal-id">
                    <span class="signal" aria-hidden="true"></span>
                    <span>CREATIVE.OS / 01</span>
                </div>
                <button class="replay-control" type="button" data-typewriter-replay aria-label="Replay typewriter sequence">
                    Replay sequence
                    <span aria-hidden="true">↗</span>
                </button>
            </header>

            <div class="command-core">
                <p class="command-label"><span>EXEC</span> capability --cycle</p>
                <div class="command-row">
                    <span class="command-prompt" aria-hidden="true">›</span>
                    <h1 class="command-line"
                        data-typewriter
                        data-type-speed="0.055"
                        data-type-delay="0.35"
                        data-type-hold="3"
                        data-type-delete-speed="0.75"
                        data-type-mobile="MAKE IT MOVE."
                        data-type-loop="WE DESIGN SYSTEMS WITH INTENT.,WE SHIP MOTION AT FRAME RATE."
                        data-type-loop-mobile="IDEAS IN MOTION.,SYSTEMS THAT MOVE.">WE MAKE DIGITAL IDEAS MOVE.</h1>
                </div>
            </div>

            <footer class="terminal-status" aria-hidden="true">
                <span class="status-code" data-typewriter-status>READY</span>
                <span class="status-track"><span data-typewriter-progress></span></span>
                <span class="status-meta">GSAP / SIGNAL_OK</span>
            </footer>
        </section>
    </main>

    <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/ScrollTrigger.min.js"></script>
    <script src="assets/script.js"></script>
</body>
</html>
```

### assets/style.css

```css
* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
}

:root {
    --black: #050706;
    --ink: #f2f7f3;
    --muted: #8d9b92;
    --line: #253128;
    --lime: #b7ff37;
    --cyan: #27e6f2;
    --accent: var(--lime);
    --ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
    color-scheme: dark;
}

html,
body {
    min-height: 100%;
}

body {
    min-width: 0;
    background: var(--black);
    color: var(--ink);
    font-family: 'Space Grotesk', system-ui, sans-serif;
    overflow-x: hidden;
    -webkit-font-smoothing: antialiased;
    line-height: 1.4;
}

button,
input,
textarea,
select {
    font: inherit;
}

button {
    color: inherit;
}

::selection {
    background: var(--lime);
    color: var(--black);
}

::-webkit-scrollbar {
    width: 8px;
}

::-webkit-scrollbar-track {
    background: var(--black);
}

::-webkit-scrollbar-thumb {
    background: var(--line);
    border: 2px solid var(--black);
}

.showcase-shell {
    min-height: 100svh;
    padding: clamp(0.65rem, 1.6vw, 1.25rem);
}

.command-stage {
    position: relative;
    isolation: isolate;
    display: grid;
    grid-template-rows: auto 1fr auto;
    width: 100%;
    min-height: calc(100svh - clamp(1.3rem, 3.2vw, 2.5rem));
    overflow: hidden;
    border: 1px solid var(--line);
    background:
        radial-gradient(circle at 78% 48%, rgba(39, 230, 242, 0.045), transparent 34%),
        var(--black);
}

.command-stage::before,
.command-stage::after {
    content: '';
    position: absolute;
    z-index: 3;
    pointer-events: none;
}

.command-stage::before {
    inset: 0;
    background: linear-gradient(90deg, rgba(183, 255, 55, 0.018), transparent 28%, transparent 72%, rgba(39, 230, 242, 0.018));
}

.command-stage::after {
    top: 0;
    right: clamp(1rem, 3vw, 2.5rem);
    width: 1px;
    height: 2.5rem;
    background: var(--cyan);
    box-shadow: 0 0 18px rgba(39, 230, 242, 0.75);
    opacity: 0.55;
}

.terminal-grid {
    position: absolute;
    inset: 0;
    z-index: -2;
    background-image:
        linear-gradient(rgba(183, 255, 55, 0.035) 1px, transparent 1px),
        linear-gradient(90deg, rgba(183, 255, 55, 0.035) 1px, transparent 1px);
    background-size: 4.5rem 4.5rem;
    mask-image: linear-gradient(to bottom, transparent, #000 28%, #000 72%, transparent);
    opacity: 0.35;
    transform: scale(1.02);
    transition: opacity 500ms ease, transform 800ms var(--ease-out-expo);
}

.terminal-glow {
    position: absolute;
    z-index: -1;
    left: 20%;
    top: 50%;
    width: min(58rem, 82vw);
    height: 15rem;
    border-radius: 50%;
    background: rgba(183, 255, 55, 0.1);
    filter: blur(90px);
    opacity: 0.15;
    transform: translate(-18%, -50%) scale(0.82);
    transition: background-color 250ms ease, opacity 500ms ease, transform 700ms var(--ease-out-expo);
}

.command-stage[data-phase='typing'] .terminal-grid {
    opacity: 0.52;
    transform: scale(1);
}

.command-stage[data-phase='typing'] .terminal-glow {
    opacity: 0.3;
    transform: translate(-10%, -50%) scale(1);
}

.command-stage[data-phase='holding'] .terminal-glow {
    background: rgba(39, 230, 242, 0.11);
    opacity: 0.27;
    transform: translate(8%, -50%) scale(0.9);
}

.command-stage[data-phase='deleting'] .terminal-grid {
    opacity: 0.2;
    transform: scale(0.985);
}

.command-stage[data-phase='deleting'] .terminal-glow {
    background: rgba(39, 230, 242, 0.13);
    opacity: 0.18;
    transform: translate(18%, -50%) scale(0.68);
}

.terminal-bar,
.terminal-status {
    position: relative;
    z-index: 4;
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 1rem;
    padding: clamp(0.85rem, 2vw, 1.35rem) clamp(1rem, 3vw, 2.5rem);
    font-family: 'JetBrains Mono', monospace;
    font-size: clamp(0.62rem, 0.8vw, 0.72rem);
    letter-spacing: 0.12em;
    text-transform: uppercase;
}

.terminal-bar {
    border-bottom: 1px solid var(--line);
}

.terminal-id {
    display: flex;
    align-items: center;
    gap: 0.7rem;
    color: var(--muted);
}

.signal {
    position: relative;
    width: 0.48rem;
    height: 0.48rem;
    border-radius: 50%;
    background: var(--lime);
    box-shadow: 0 0 12px rgba(183, 255, 55, 0.65);
}

.command-stage[data-phase='holding'] .signal::after {
    content: '';
    position: absolute;
    inset: -0.3rem;
    border: 1px solid var(--cyan);
    border-radius: inherit;
    animation: signal-pulse 650ms var(--ease-out-expo) 1 both;
}

@keyframes signal-pulse {
    from { opacity: 0.8; transform: scale(0.45); }
    to { opacity: 0; transform: scale(1.45); }
}

html:not(.typewriter-ready) .replay-control {
    visibility: hidden;
}

.replay-control {
    display: inline-flex;
    align-items: center;
    gap: 0.65rem;
    padding: 0.55rem 0.75rem;
    border: 1px solid var(--line);
    background: transparent;
    font-family: 'JetBrains Mono', monospace;
    font-size: inherit;
    letter-spacing: 0.08em;
    text-transform: uppercase;
    cursor: pointer;
    transition: color 180ms ease, border-color 180ms ease, background-color 180ms ease;
}

.replay-control:hover,
.replay-control:focus-visible {
    color: var(--black);
    border-color: var(--lime);
    background: var(--lime);
    outline: none;
}

.command-core {
    position: relative;
    z-index: 2;
    display: flex;
    flex-direction: column;
    justify-content: center;
    min-width: 0;
    padding: clamp(2rem, 6vw, 5.5rem) clamp(1rem, 4vw, 4rem);
}

.command-label {
    margin-bottom: clamp(1.1rem, 2.5vw, 1.8rem);
    color: var(--muted);
    font-family: 'JetBrains Mono', monospace;
    font-size: clamp(0.65rem, 0.9vw, 0.78rem);
    letter-spacing: 0.13em;
    text-transform: uppercase;
}

.command-label span {
    margin-right: 0.75rem;
    color: var(--cyan);
}

.command-row {
    display: flex;
    align-items: flex-start;
    min-width: 0;
}

.command-prompt {
    flex: 0 0 auto;
    margin-right: clamp(0.65rem, 1.5vw, 1.2rem);
    color: var(--cyan);
    font-family: 'JetBrains Mono', monospace;
    font-size: clamp(2rem, 5vw, 4rem);
    font-weight: 400;
}

.command-line {
    flex: 1 1 auto;
    min-width: 0;
    color: var(--ink);
    font-family: 'Syne', system-ui, sans-serif;
    font-size: clamp(2.15rem, 5.2vw, 3rem);
    font-weight: 800;
    line-height: 0.98;
    letter-spacing: -0.055em;
    white-space: nowrap;
}

.has-js:not(.typewriter-ready) .command-line {
    visibility: hidden;
}

.command-cursor,
.typewriter__cursor {
    display: inline-block;
    flex: 0 0 auto;
    width: clamp(0.32rem, 0.7vw, 0.55rem);
    height: clamp(2.05rem, 5vw, 3.85rem);
    margin-left: clamp(0.45rem, 0.9vw, 0.75rem);
    background: var(--lime);
    box-shadow: 0 0 20px rgba(183, 255, 55, 0.35);
    transform: translateY(0.16em) scaleY(1);
    transform-origin: bottom;
    transition: background-color 160ms ease, box-shadow 160ms ease, transform 240ms var(--ease-out-expo), opacity 160ms ease;
}

.command-stage[data-phase='holding'] .command-cursor,
.command-stage[data-phase='holding'] .typewriter__cursor {
    background: var(--cyan);
    box-shadow: 0 0 24px rgba(39, 230, 242, 0.55);
    transform: translateY(0.16em) scaleY(0.72);
}

.command-stage[data-phase='deleting'] .command-cursor,
.command-stage[data-phase='deleting'] .typewriter__cursor {
    background: var(--cyan);
    box-shadow: 0 0 14px rgba(39, 230, 242, 0.45);
    transform: translateY(0.16em) scaleY(0.42);
}

.terminal-status {
    border-top: 1px solid var(--line);
    color: var(--muted);
}

.status-code {
    width: 5.4rem;
    color: var(--lime);
}

.command-stage[data-phase='holding'] .status-code,
.command-stage[data-phase='deleting'] .status-code {
    color: var(--cyan);
}

.status-track {
    position: relative;
    flex: 1;
    max-width: 18rem;
    height: 1px;
    overflow: hidden;
    background: var(--line);
}

.status-track span {
    position: absolute;
    inset: 0;
    background: linear-gradient(90deg, var(--lime), var(--cyan));
    box-shadow: 0 0 12px rgba(39, 230, 242, 0.7);
    transform: scaleX(1);
    transform-origin: left;
}

.status-meta {
    text-align: right;
}

@media (max-width: 600px) {
    .showcase-shell {
        padding: 0.5rem;
    }

    .command-stage {
        min-height: calc(100svh - 1rem);
    }

    .terminal-bar,
    .terminal-status {
        padding: 0.85rem;
    }

    .terminal-id span:last-child {
        display: none;
    }

    .replay-control {
        min-height: 2.65rem;
    }

    .command-core {
        padding: 2.5rem 0.9rem;
    }

    .command-row {
        align-items: flex-start;
    }

    .command-prompt {
        margin-right: 0.5rem;
        line-height: 1;
    }

    .command-line {
        font-size: clamp(2.05rem, 10.5vw, 3.1rem);
        line-height: 1.02;
        white-space: nowrap;
    }

    .command-cursor,
    .typewriter__cursor {
        height: 2.2rem;
        margin-left: 0.35rem;
    }

    .status-track {
        max-width: none;
    }

    .status-meta {
        display: none;
    }
}

@media (prefers-reduced-motion: reduce) {
    *,
    *::before,
    *::after {
        scroll-behavior: auto !important;
        animation-duration: 0.01ms !important;
        animation-iteration-count: 1 !important;
        transition-duration: 0.01ms !important;
    }

    .has-js .command-line {
        visibility: visible;
    }

    .replay-control,
    .command-cursor,
    .typewriter__cursor {
        display: none;
    }

    .terminal-grid,
    .terminal-glow {
        transform: none;
    }
}
```

### assets/script.js

```js
/**
 * Typewriter Text
 *
 * Scroll-triggered character typing with looping phrases, an accelerating
 * delete pass, and optional UI hooks for cursor, phase, and progress feedback.
 *
 * @plugins ScrollTrigger
 * @techniques text-animation, typewriter, scroll-reveal, infinite-loop
 */

(function onReady(init) {
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', init);
    } else {
        init();
    }
})(function initTypewriterText() {
    if (typeof gsap === 'undefined' || typeof ScrollTrigger === 'undefined') {
        document.documentElement.classList.remove('has-js');
        return;
    }

    gsap.registerPlugin(ScrollTrigger);

    const handlers = new Map();
    const ownedTriggers = [];
    const timelines = [];
    const resizeHandlers = [];

    function fitStaticLine(el) {
        el.style.removeProperty('font-size');
        const naturalSize = parseFloat(getComputedStyle(el).fontSize);
        const available = el.clientWidth;
        const required = el.scrollWidth;
        if (!available || !required) return;
        el.style.fontSize = Math.max(11, naturalSize * Math.min(1, (available - 2) / required)) + 'px';
    }

    const ctx = gsap.context(function gsapContextCallback() {
        const mm = gsap.matchMedia();

        mm.add({
            isMotion: '(prefers-reduced-motion: no-preference)',
            isReduced: '(prefers-reduced-motion: reduce)'
        }, function matchMediaCallback(context) {
            const isMotion = context.conditions.isMotion;
            const elements = document.querySelectorAll('[data-typewriter]');

            elements.forEach(function initElement(el) {
                const speedValue = parseFloat(el.dataset.typeSpeed);
                const delayValue = parseFloat(el.dataset.typeDelay);
                const isCompact = window.matchMedia('(max-width: 600px)').matches;
                const loopSource = isCompact && el.dataset.typeLoopMobile
                    ? el.dataset.typeLoopMobile
                    : el.dataset.typeLoop;
                const holdValue = parseFloat(el.dataset.typeHold);
                const deleteValue = parseFloat(el.dataset.typeDeleteSpeed);
                const CONFIG = {
                    speed: Number.isFinite(speedValue) ? Math.max(0.01, speedValue) : 0.045,
                    delay: Number.isFinite(delayValue) ? Math.max(0, delayValue) : 0,
                    hold: Number.isFinite(holdValue) ? Math.max(0.4, holdValue) : 1.8,
                    deleteSpeed: Number.isFinite(deleteValue) ? Math.max(0.1, deleteValue) : 0.5,
                    cursor: el.dataset.typeCursor !== 'false',
                    loop: (loopSource || '')
                        .split(',')
                        .map(function trimPhrase(phrase) { return phrase.trim(); })
                        .filter(Boolean)
                };

                const fullText = (isCompact && el.dataset.typeMobile
                    ? el.dataset.typeMobile
                    : el.textContent).trim();
                if (!fullText) return;
                const phrases = [fullText].concat(CONFIG.loop);

                const system = el.closest('[data-typewriter-system]');
                const cursor = system ? system.querySelector('[data-typewriter-cursor]') : null;
                const status = system ? system.querySelector('[data-typewriter-status]') : null;
                const progress = system ? system.querySelector('[data-typewriter-progress]') : null;
                const replay = system ? system.querySelector('[data-typewriter-replay]') : null;

                el.setAttribute('aria-label', fullText);

                if (!isMotion) {
                    el.textContent = fullText;
                    el.classList.add('is-complete');
                    if (system) system.dataset.phase = 'ready';
                    if (status) status.textContent = 'READY';
                    if (progress) gsap.set(progress, { scaleX: 1 });
                    const fitReduced = function fitReducedLine() { fitStaticLine(el); };
                    requestAnimationFrame(fitReduced);
                    if (document.fonts && document.fonts.ready) document.fonts.ready.then(fitReduced);
                    window.addEventListener('resize', fitReduced);
                    resizeHandlers.push(fitReduced);
                    return;
                }

                el.textContent = '';
                const textSpan = document.createElement('span');
                textSpan.className = 'typewriter__text';
                textSpan.setAttribute('aria-hidden', 'true');
                el.appendChild(textSpan);

                let generatedCursor = null;
                if (CONFIG.cursor && !cursor) {
                    generatedCursor = document.createElement('span');
                    generatedCursor.className = 'typewriter__cursor';
                    generatedCursor.setAttribute('aria-hidden', 'true');
                    el.appendChild(generatedCursor);
                }
                const activeCursor = CONFIG.cursor ? (cursor || generatedCursor) : null;
                if (cursor && !CONFIG.cursor) cursor.hidden = true;

                const looping = phrases.length > 1;
                const proxy = { chars: 0 };
                let currentChars = Array.from(fullText);

                function fitPhrases() {
                    if (!textSpan.isConnected) return;
                    el.style.removeProperty('font-size');
                    const naturalSize = parseFloat(getComputedStyle(el).fontSize);
                    const previousText = textSpan.textContent;
                    let widest = 0;
                    phrases.forEach(function measurePhrase(phrase) {
                        textSpan.textContent = phrase;
                        widest = Math.max(widest, textSpan.getBoundingClientRect().width);
                    });
                    const cursorWidth = activeCursor
                        ? activeCursor.getBoundingClientRect().width + parseFloat(getComputedStyle(activeCursor).marginLeft || 0)
                        : 0;
                    const available = el.clientWidth - cursorWidth - 2;
                    if (available > 0 && widest > 0) {
                        el.style.fontSize = Math.max(11, naturalSize * Math.min(1, available / widest)) + 'px';
                    }
                    textSpan.textContent = previousText;
                }

                requestAnimationFrame(fitPhrases);
                if (document.fonts && document.fonts.ready) document.fonts.ready.then(fitPhrases);
                window.addEventListener('resize', fitPhrases);
                resizeHandlers.push(fitPhrases);

                function render() {
                    if (!textSpan.isConnected) return;
                    textSpan.textContent = currentChars.slice(0, Math.round(proxy.chars)).join('');
                }

                function setPhase(phase, label) {
                    el.classList.toggle('is-typing', phase === 'typing');
                    el.classList.toggle('is-deleting', phase === 'deleting');
                    el.classList.toggle('is-complete', phase === 'ready');
                    if (system) system.dataset.phase = phase;
                    if (status) status.textContent = label;
                }

                const tl = gsap.timeline({
                    paused: true,
                    delay: CONFIG.delay,
                    repeat: looping ? -1 : 0,
                    onStart: function dispatchStart() {
                        el.dispatchEvent(new CustomEvent('typewriter:start', {
                            bubbles: true,
                            detail: { text: fullText }
                        }));
                    }
                });
                timelines.push(tl);

                phrases.forEach(function addPhrase(phrase, index) {
                    const chars = Array.from(phrase);
                    const typeDuration = chars.length * CONFIG.speed;
                    const deleteDuration = chars.length * CONFIG.speed * CONFIG.deleteSpeed;

                    tl.call(function preparePhrase() {
                        currentChars = chars;
                        proxy.chars = 0;
                        render();
                        setPhase('typing', 'COMPOSE');
                        if (progress) gsap.set(progress, { scaleX: 0 });
                    });

                    tl.to(proxy, {
                        chars: chars.length,
                        duration: typeDuration,
                        ease: 'none',
                        snap: { chars: 1 },
                        onUpdate: render,
                        onComplete: function dispatchComplete() {
                            el.dispatchEvent(new CustomEvent('typewriter:complete', {
                                bubbles: true,
                                detail: { text: phrase, index: index }
                            }));
                        }
                    });

                    if (progress) {
                        tl.to(progress, { scaleX: 1, duration: typeDuration, ease: 'none' }, '<');
                    }

                    if (!looping) {
                        tl.call(function settleOnce() { setPhase('ready', 'READY'); });
                        return;
                    }

                    tl.call(function beginHold() {
                        setPhase('holding', 'HOLD');
                    });
                    tl.to({}, { duration: CONFIG.hold });
                    tl.call(function beginDelete() {
                        setPhase('deleting', 'PURGE');
                    });
                    tl.to(proxy, {
                        chars: 0,
                        duration: deleteDuration,
                        ease: 'power3.in',
                        snap: { chars: 1 },
                        onUpdate: render
                    });
                    if (progress) {
                        tl.to(progress, { scaleX: 0, duration: deleteDuration, ease: 'power3.in' }, '<');
                    }
                });

                if (activeCursor) {
                    tl.eventCallback('onRepeat', function resetCursor() {
                        gsap.set(activeCursor, { scaleY: 1, opacity: 1 });
                    });
                }

                const trigger = ScrollTrigger.create({
                    trigger: el,
                    start: 'top 85%',
                    once: true,
                    onEnter: function playTimeline() { tl.play(); }
                });
                ownedTriggers.push(trigger);

                if (replay && !handlers.has(replay)) {
                    const handleReplay = function handleReplay() {
                        if (!el.isConnected) return;
                        tl.pause(0);
                        proxy.chars = 0;
                        render();
                        tl.play();
                    };
                    replay.addEventListener('click', handleReplay);
                    handlers.set(replay, handleReplay);
                }
            });

            document.documentElement.classList.add('typewriter-ready');

            return function cleanupMatchMedia() {
                handlers.forEach(function removeHandler(handler, element) {
                    element.removeEventListener('click', handler);
                });
                handlers.clear();
                timelines.forEach(function killTimeline(timeline) { timeline.kill(); });
                timelines.length = 0;
                ownedTriggers.forEach(function killTrigger(trigger) { trigger.kill(); });
                ownedTriggers.length = 0;
                resizeHandlers.forEach(function removeFitHandler(handler) {
                    window.removeEventListener('resize', handler);
                });
                resizeHandlers.length = 0;
            };
        });
    });

    window.gsapContext = ctx;

    window.addEventListener('beforeunload', function cleanupBeforeUnload() {
        ctx.revert();
    }, { once: true });
});
```

---

From [GSAP Vault](https://gsapvault.com): production-ready GSAP animation effects. Full catalog for agents: https://gsapvault.com/llms-full.txt
