f03

Typewriter Text

Free

A typewriter that types a line character by character, holds it, accelerates through deletion, and cycles to the next phrase, with optional cursor, status and progress hooks.

ScrollTrigger beginner
4 more details
text-animationtypewriterscroll-revealinfinite-loop

This demo reads better at your own screen size than in the frame below:

Updated

About this effect

A focused typewriter effect that turns one line of copy into a paced 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.

Typewriter Text - GSAP animation effect preview

What's included

10 items
  • 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

Perfect for

5 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

How it works

1 section

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.

Plugins ScrollTrigger
Difficulty Beginner
Includes HTML + JS + CSS source, documentation, AI setup prompt, lifetime updates

Lighthouse, as measured

Google Lighthouse on this effect's demo, from the latest scan. A measurement of the demo as shipped, not a promise for your page.

Accessibility
100
Best practices
100

No performance score, on purpose. That figure depends on how you deploy: your server's compression and caching, your CDN, the connection and the device doing the test, none of which the code controls. The same page can score very differently on two consecutive runs, so measure it where it will live.

<!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=Inter:wght@400;500;600&display=swap" rel="stylesheet">
</head>
<body>
    <main class="stage" aria-label="Typewriter text demonstration">
        <h1 class="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="Read the docs."
            data-type-loop="Edit the code.,Ship it your way."
            data-type-loop-mobile="Edit the code.,Ship it your way.">Read the docs. Edit the code.</h1>
    </main>

    <script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/ScrollTrigger.min.js"></script>
    <script src="assets/script.js"></script>
</body>
</html>
/**
 * 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 });
});
!function(e){"loading"===document.readyState?document.addEventListener("DOMContentLoaded",e):e()}(function(){if("undefined"==typeof gsap||"undefined"==typeof ScrollTrigger)return void document.documentElement.classList.remove("has-js");gsap.registerPlugin(ScrollTrigger);const e=new Map,t=[],n=[],o=[];const a=gsap.context(function(){gsap.matchMedia().add({isMotion:"(prefers-reduced-motion: no-preference)",isReduced:"(prefers-reduced-motion: reduce)"},function(a){const r=a.conditions.isMotion;return document.querySelectorAll("[data-typewriter]").forEach(function(a){const i=parseFloat(a.dataset.typeSpeed),s=parseFloat(a.dataset.typeDelay),c=window.matchMedia("(max-width: 600px)").matches,d=c&&a.dataset.typeLoopMobile?a.dataset.typeLoopMobile:a.dataset.typeLoop,l=parseFloat(a.dataset.typeHold),u=parseFloat(a.dataset.typeDeleteSpeed),p={speed:Number.isFinite(i)?Math.max(.01,i):.045,delay:Number.isFinite(s)?Math.max(0,s):0,hold:Number.isFinite(l)?Math.max(.4,l):1.8,deleteSpeed:Number.isFinite(u)?Math.max(.1,u):.5,cursor:"false"!==a.dataset.typeCursor,loop:(d||"").split(",").map(function(e){return e.trim()}).filter(Boolean)},m=(c&&a.dataset.typeMobile?a.dataset.typeMobile:a.textContent).trim();if(!m)return;const f=[m].concat(p.loop),h=a.closest("[data-typewriter-system]"),y=h?h.querySelector("[data-typewriter-cursor]"):null,g=h?h.querySelector("[data-typewriter-status]"):null,w=h?h.querySelector("[data-typewriter-progress]"):null,C=h?h.querySelector("[data-typewriter-replay]"):null;if(a.setAttribute("aria-label",m),!r){a.textContent=m,a.classList.add("is-complete"),h&&(h.dataset.phase="ready"),g&&(g.textContent="READY"),w&&gsap.set(w,{scaleX:1});const e=function(){!function(e){e.style.removeProperty("font-size");const t=parseFloat(getComputedStyle(e).fontSize),n=e.clientWidth,o=e.scrollWidth;n&&o&&(e.style.fontSize=Math.max(11,t*Math.min(1,(n-2)/o))+"px")}(a)};return requestAnimationFrame(e),document.fonts&&document.fonts.ready&&document.fonts.ready.then(e),window.addEventListener("resize",e),void o.push(e)}a.textContent="";const E=document.createElement("span");E.className="typewriter__text",E.setAttribute("aria-hidden","true"),a.appendChild(E);let x=null;p.cursor&&!y&&(x=document.createElement("span"),x.className="typewriter__cursor",x.setAttribute("aria-hidden","true"),a.appendChild(x));const S=p.cursor?y||x:null;y&&!p.cursor&&(y.hidden=!0);const M=f.length>1,b={chars:0};let v=Array.from(m);function L(){if(!E.isConnected)return;a.style.removeProperty("font-size");const e=parseFloat(getComputedStyle(a).fontSize),t=E.textContent;let n=0;f.forEach(function(e){E.textContent=e,n=Math.max(n,E.getBoundingClientRect().width)});const o=S?S.getBoundingClientRect().width+parseFloat(getComputedStyle(S).marginLeft||0):0,r=a.clientWidth-o-2;r>0&&n>0&&(a.style.fontSize=Math.max(11,e*Math.min(1,r/n))+"px"),E.textContent=t}function F(){E.isConnected&&(E.textContent=v.slice(0,Math.round(b.chars)).join(""))}function A(e,t){a.classList.toggle("is-typing","typing"===e),a.classList.toggle("is-deleting","deleting"===e),a.classList.toggle("is-complete","ready"===e),h&&(h.dataset.phase=e),g&&(g.textContent=t)}requestAnimationFrame(L),document.fonts&&document.fonts.ready&&document.fonts.ready.then(L),window.addEventListener("resize",L),o.push(L);const z=gsap.timeline({paused:!0,delay:p.delay,repeat:M?-1:0,onStart:function(){a.dispatchEvent(new CustomEvent("typewriter:start",{bubbles:!0,detail:{text:m}}))}});n.push(z),f.forEach(function(e,t){const n=Array.from(e),o=n.length*p.speed,r=n.length*p.speed*p.deleteSpeed;z.call(function(){v=n,b.chars=0,F(),A("typing","COMPOSE"),w&&gsap.set(w,{scaleX:0})}),z.to(b,{chars:n.length,duration:o,ease:"none",snap:{chars:1},onUpdate:F,onComplete:function(){a.dispatchEvent(new CustomEvent("typewriter:complete",{bubbles:!0,detail:{text:e,index:t}}))}}),w&&z.to(w,{scaleX:1,duration:o,ease:"none"},"<"),M?(z.call(function(){A("holding","HOLD")}),z.to({},{duration:p.hold}),z.call(function(){A("deleting","PURGE")}),z.to(b,{chars:0,duration:r,ease:"power3.in",snap:{chars:1},onUpdate:F}),w&&z.to(w,{scaleX:0,duration:r,ease:"power3.in"},"<")):z.call(function(){A("ready","READY")})}),S&&z.eventCallback("onRepeat",function(){gsap.set(S,{scaleY:1,opacity:1})});const q=ScrollTrigger.create({trigger:a,start:"top 85%",once:!0,onEnter:function(){z.play()}});if(t.push(q),C&&!e.has(C)){const t=function(){a.isConnected&&(z.pause(0),b.chars=0,F(),z.play())};C.addEventListener("click",t),e.set(C,t)}}),document.documentElement.classList.add("typewriter-ready"),function(){e.forEach(function(e,t){t.removeEventListener("click",e)}),e.clear(),n.forEach(function(e){e.kill()}),n.length=0,t.forEach(function(e){e.kill()}),t.length=0,o.forEach(function(e){window.removeEventListener("resize",e)}),o.length=0}})});window.gsapContext=a,window.addEventListener("beforeunload",function(){a.revert()},{once:!0})});
* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
}

:root {
    --page: #ececeb;
    --surface: #f7f7f6;
    --ink: #141412;
    --ink-dim: rgba(20, 20, 18, 0.72);
    --rule: rgba(20, 20, 18, 0.14);
    --rule-strong: rgba(20, 20, 18, 0.3);

    /* The caret is the thing the effect moves: it carries the accent. */
    --accent: #e5322d;
    --accent-ink: #ffffff;

    color-scheme: light;
}

html {
    background: var(--page);
}

body {
    min-width: 320px;
    background: var(--page);
    color: var(--ink);
    font-family: 'Inter', system-ui, sans-serif;
    -webkit-font-smoothing: antialiased;
    line-height: 1.4;
}

::selection {
    background: var(--accent);
    color: var(--accent-ink);
}

:focus-visible {
    outline: 2px solid var(--accent);
    outline-offset: 3px;
}

/* Full-bleed ground, one line, nothing else. */
.stage {
    display: grid;
    place-items: center;
    min-height: 100svh;
    padding: clamp(1.25rem, 6vw, 5rem);
}

.line {
    display: flex;
    align-items: center;
    justify-content: center;
    width: min(64rem, 100%);
    min-width: 0;
    font-size: clamp(1.75rem, 5.5vw, 3.5rem);
    font-weight: 500;
    letter-spacing: -0.03em;
    line-height: 1.1;
    white-space: nowrap;
}

/* The script measures every phrase before it types, so the line stays hidden
   until it is fitted rather than flashing at its unfitted size. */
.has-js:not(.typewriter-ready) .line {
    visibility: hidden;
}

.typewriter__cursor {
    display: inline-block;
    flex: 0 0 auto;
    width: 0.08em;
    height: 1em;
    margin-left: 0.14em;
    background: var(--accent);
    animation: type-cursor 0.75s steps(1) infinite;
}

@keyframes type-cursor {
    50% { opacity: 0; }
}

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

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

    .typewriter__cursor {
        display: none;
    }
}

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>:

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

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

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

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

<h1 data-typewriter>Read the docs. Edit the code.</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 Phrases

Add to your HTML <body>:

<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="Read the docs."
    data-type-loop="Ship it your way.,One line typed on scroll."
    data-type-loop-mobile="Ship it your way.,Typed on scroll.">
  Read the docs. Edit the code.
</h1>

Phrases are separated by commas, so a phrase cannot contain one.

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>:

<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>:

<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>:

<section data-typewriter-system data-phase="ready">
  <h2 data-typewriter data-type-loop="Edit the code.,Ship it your way.">
    Read the docs.
  </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:

[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:

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:

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.