Back to Gallery f03

Typewriter Text

FREE

Text types out character by character with a blinking cursor when scrolled into view. Supports looping phrases, custom speed, and delay controls.

ScrollTrigger text-animationtypewriterscroll-reveal Lenis beginner

About This Effect

A lightweight typewriter effect that types text out character by character with a blinking cursor, triggered once when the element scrolls into view. Configuration lives entirely in data attributes: typing speed, start delay, cursor visibility, and an optional set of looping phrases that type, pause, delete, and rotate forever. No SplitText required; the whole effect runs on GSAP core plus ScrollTrigger with a single snapped tween per phrase.

What's Included

  • Character-by-character typing driven by a single GSAP tween with snap
  • Blinking cursor rendered as a CSS-animated span, no JavaScript interval
  • ScrollTrigger fires the animation once as the element enters the viewport
  • Optional looping mode that rotates through comma-separated phrases
  • Per-element control of speed, delay, and cursor via data attributes
  • Custom typewriter:start and typewriter:complete events for integration
  • Reduced-motion branch shows the full text instantly with no cursor blink
  • Screen readers receive the complete text immediately via aria-label

Perfect For

  • Hero headlines with a typed-in reveal that draws the eye on page load
  • Rotating value propositions with looping phrases that stay on message
  • Terminal and developer-tool landing pages with authentic typing feel
  • Section intros with a scroll-triggered reveal that paces the narrative
  • Portfolio taglines with personality that plain fades cannot match

How It Works

Each [data-typewriter] element has its text content read and cleared, then typed back by a gsap.to() tween on a proxy character index with snap: { chars: 1 }, slicing the string in onUpdate. ScrollTrigger.create() with once: true plays the paused timeline when the element reaches 85% of the viewport height. Looping phrases are appended to a repeating gsap.timeline() as type, hold, and delete segments, and gsap.matchMedia() routes reduced-motion users to an instant static render.

Plugins ScrollTrigger
Difficulty Beginner
Smooth Scroll Lenis Integration
Includes HTML + JS + CSS source, documentation, lifetime updates
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Typewriter Text</title>
    <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@400;500;600;700&family=Syne:wght@400;500;600;700;800&display=swap" rel="stylesheet">
</head>
<body>
    <main class="container">
        <header class="page-header">
            <h1 class="page-title">Typewriter Text</h1>
            <p class="page-subtitle">Text types out character by character with a blinking cursor when scrolled into view. Supports looping phrases, custom speed, and delay controls.</p>
        </header>

        <div class="demo-spacer">
            <p>Scroll to trigger</p>
        </div>

        <!-- Example 1: Scroll-triggered headline -->
        <section class="example">
            <div class="example-header">
                <span class="example-number">01</span>
                <h2 class="example-title">Scroll-Triggered Headline</h2>
            </div>
            <div class="example-content">
                <p class="example-instruction">The headline types itself out once when it enters the viewport. Defaults: 0.045s per character, blinking cursor on.</p>
                <h2 class="typewriter-headline" data-typewriter>Build interfaces that feel alive.</h2>
            </div>
        </section>

        <div class="demo-spacer">
            <p>Keep scrolling</p>
        </div>

        <!-- Example 2: Looping phrases -->
        <section class="example">
            <div class="example-header">
                <span class="example-number">02</span>
                <h2 class="example-title">Looping Phrases</h2>
            </div>
            <div class="example-content">
                <p class="example-instruction">Add <code>data-type-loop</code> with comma-separated phrases. The element types its own text first, pauses, deletes, then rotates through the list forever.</p>
                <p class="typewriter-line">We build
                    <span data-typewriter
                          data-type-speed="0.06"
                          data-type-delay="0.3"
                          data-type-loop="smooth animations, bold interfaces, memorable experiences">delightful websites</span>
                </p>
            </div>
        </section>
    </main>

    <!-- GSAP Core -->
    <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>

    <!-- Optional: Lenis smooth scroll -->
    <script src="https://unpkg.com/lenis@1.3.17/dist/lenis.min.js"></script>

    <!-- Effect code -->
    <script src="assets/script.js"></script>
</body>
</html>
/**
 * Typewriter Text
 *
 * Text types out character by character with a blinking cursor when
 * scrolled into view. Supports looping phrases, custom speed, and delay.
 *
 * @plugins ScrollTrigger
 * @techniques text-animation, typewriter, scroll-reveal
 */

gsap.registerPlugin(ScrollTrigger);

window.addEventListener('DOMContentLoaded', function initTypewriterText() {
    const HOLD_TIME = 1.6;          // Seconds a looping phrase stays on screen
    const DELETE_FACTOR = 0.5;      // Deleting runs at 2x typing speed

    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;

            const elements = document.querySelectorAll('[data-typewriter]');

            elements.forEach(function initElement(el) {
                // ============================================
                // CONFIGURATION FROM DATA ATTRIBUTES
                // ============================================
                const CONFIG = {
                    // Seconds per character
                    speed: parseFloat(el.dataset.typeSpeed) || 0.045,
                    // Delay before typing starts (seconds)
                    delay: parseFloat(el.dataset.typeDelay) || 0,
                    // Show blinking cursor (default true)
                    cursor: el.dataset.typeCursor !== 'false',
                    // Optional comma-separated phrases to rotate through
                    loop: (el.dataset.typeLoop || '')
                        .split(',')
                        .map(function trim(s) { return s.trim(); })
                        .filter(Boolean)
                };

                const fullText = el.textContent.trim();
                if (!fullText) return;

                // Screen readers always get the complete text
                el.setAttribute('aria-label', fullText);

                // ============================================
                // REDUCED MOTION: static render, no cursor
                // ============================================
                if (!isMotion) {
                    el.textContent = fullText;
                    return;
                }

                // ============================================
                // DOM SETUP: text span + optional cursor span
                // ============================================
                el.textContent = '';

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

                if (CONFIG.cursor) {
                    const cursorSpan = document.createElement('span');
                    cursorSpan.className = 'typewriter__cursor';
                    cursorSpan.setAttribute('aria-hidden', 'true');
                    el.appendChild(cursorSpan);
                }

                // ============================================
                // TIMELINE: type (and optionally loop phrases)
                // ============================================
                const phrases = [fullText].concat(CONFIG.loop);
                const looping = phrases.length > 1;
                const proxy = { chars: 0 };
                let current = fullText;

                function render() {
                    textSpan.textContent = current.slice(0, Math.round(proxy.chars));
                }

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

                phrases.forEach(function addPhrase(phrase, index) {
                    // Point the renderer at this phrase
                    tl.call(function setPhrase() { current = phrase; });

                    // Type it out, one snapped character per step
                    tl.to(proxy, {
                        chars: phrase.length,
                        duration: phrase.length * CONFIG.speed,
                        ease: 'none',
                        snap: { chars: 1 },
                        onUpdate: render,
                        onComplete: function dispatchComplete() {
                            if (!looping) el.classList.add('is-complete');
                            el.dispatchEvent(new CustomEvent('typewriter:complete', {
                                bubbles: true,
                                detail: { text: phrase, index: index }
                            }));
                        }
                    });

                    // In loop mode: hold, then delete before the next phrase
                    if (looping) {
                        tl.to(proxy, {
                            chars: 0,
                            duration: phrase.length * CONFIG.speed * DELETE_FACTOR,
                            ease: 'none',
                            snap: { chars: 1 },
                            onUpdate: render
                        }, '+=' + HOLD_TIME);
                    }
                });

                // ============================================
                // SCROLLTRIGGER: play once on enter
                // ============================================
                ScrollTrigger.create({
                    trigger: el,
                    start: 'top 85%',
                    once: true,
                    onEnter: function play() { tl.play(); }
                });
            });

            // ============================================
            // CLEANUP FUNCTION
            // ============================================
            return function cleanup() {
                ScrollTrigger.getAll().forEach(function killTrigger(trigger) {
                    trigger.kill();
                });
            };
        });
    });

    // Store context for SPA cleanup
    window.gsapContext = ctx;
});

// ============================================
// OPTIONAL: Lenis smooth scroll integration
// ============================================
(function initLenis() {
    if (typeof Lenis === 'undefined') return;

    const lenis = new Lenis({
        duration: 1.2,
        smoothWheel: true
    });

    lenis.on('scroll', ScrollTrigger.update);

    gsap.ticker.add(function raf(time) {
        lenis.raf(time * 1000);
    });
    gsap.ticker.lagSmoothing(0);

    // Store for cleanup
    window.lenis = lenis;
})();
gsap.registerPlugin(ScrollTrigger),window.addEventListener("DOMContentLoaded",function(){const e=gsap.context(function(){gsap.matchMedia().add({isMotion:"(prefers-reduced-motion: no-preference)",isReduced:"(prefers-reduced-motion: reduce)"},function(e){const{isMotion:t}=e.conditions;return document.querySelectorAll("[data-typewriter]").forEach(function(e){const n={speed:parseFloat(e.dataset.typeSpeed)||.045,delay:parseFloat(e.dataset.typeDelay)||0,cursor:"false"!==e.dataset.typeCursor,loop:(e.dataset.typeLoop||"").split(",").map(function(e){return e.trim()}).filter(Boolean)},o=e.textContent.trim();if(!o)return;if(e.setAttribute("aria-label",o),!t)return void(e.textContent=o);e.textContent="";const r=document.createElement("span");if(r.className="typewriter__text",r.setAttribute("aria-hidden","true"),e.appendChild(r),n.cursor){const t=document.createElement("span");t.className="typewriter__cursor",t.setAttribute("aria-hidden","true"),e.appendChild(t)}const a=[o].concat(n.loop),i=a.length>1,s={chars:0};let c=o;function d(){r.textContent=c.slice(0,Math.round(s.chars))}const l=gsap.timeline({paused:!0,delay:n.delay,repeat:i?-1:0,onStart:function(){e.classList.add("is-typing"),e.dispatchEvent(new CustomEvent("typewriter:start",{bubbles:!0,detail:{text:o}}))}});a.forEach(function(t,o){l.call(function(){c=t}),l.to(s,{chars:t.length,duration:t.length*n.speed,ease:"none",snap:{chars:1},onUpdate:d,onComplete:function(){i||e.classList.add("is-complete"),e.dispatchEvent(new CustomEvent("typewriter:complete",{bubbles:!0,detail:{text:t,index:o}}))}}),i&&l.to(s,{chars:0,duration:t.length*n.speed*.5,ease:"none",snap:{chars:1},onUpdate:d},"+=1.6")}),ScrollTrigger.create({trigger:e,start:"top 85%",once:!0,onEnter:function(){l.play()}})}),function(){ScrollTrigger.getAll().forEach(function(e){e.kill()})}})});window.gsapContext=e}),function(){if("undefined"==typeof Lenis)return;const e=new Lenis({duration:1.2,smoothWheel:!0});e.on("scroll",ScrollTrigger.update),gsap.ticker.add(function(t){e.raf(1e3*t)}),gsap.ticker.lagSmoothing(0),window.lenis=e}();
/* ============================================
   GSAP VAULT DESIGN SYSTEM
   ============================================ */

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

:root {
    /* Core colors */
    --black: #0a0a0a;
    --dark: #111111;
    --surface: #1a1a1a;
    --surface-light: #242424;
    --border: #2a2a2a;
    --border-light: #3a3a3a;

    /* Text hierarchy */
    --text: #ffffff;
    --text-secondary: #b0b0b0;
    --text-muted: #999999;

    /* Accent: cyan */
    --cyan: #00e5ff;
    --accent: var(--cyan);

    /* Easing - matches GSAP patterns */
    --ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
    --ease-out-quart: cubic-bezier(0.25, 1, 0.5, 1);
    --ease-out-cubic: cubic-bezier(0.33, 1, 0.68, 1);
}

html {
    scroll-behavior: auto;
}

body {
    background: var(--black);
    color: var(--text);
    font-family: 'Space Grotesk', system-ui, sans-serif;
    font-weight: 400;
    overflow-x: hidden;
    -webkit-font-smoothing: antialiased;
    -moz-osx-font-smoothing: grayscale;
    line-height: 1.6;
}

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

/* ============================================
   CUSTOM SCROLLBAR
   ============================================ */
::-webkit-scrollbar {
    width: 10px;
}

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

::-webkit-scrollbar-thumb {
    background: var(--surface-light);
    border-radius: 5px;
    border: 2px solid var(--black);
}

::-webkit-scrollbar-thumb:hover {
    background: var(--border-light);
}

/* ============================================
   TYPOGRAPHY
   ============================================ */

/* Syne for display/headlines */
h1, h2, h3, h4, h5, h6 {
    font-family: 'Syne', system-ui, sans-serif;
    font-weight: 700;
    line-height: 1.1;
    letter-spacing: -0.03em;
}

/* JetBrains Mono for code/labels */
code {
    font-family: 'JetBrains Mono', monospace;
    font-size: 0.85em;
    background: var(--surface);
    padding: 0.15em 0.4em;
    border-radius: 4px;
}

/* ============================================
   LAYOUT
   ============================================ */
.container {
    max-width: 1000px;
    margin: 0 auto;
    padding: 5rem 2rem;
}

/* ============================================
   PAGE HEADER
   ============================================ */
.page-header {
    text-align: center;
    margin-bottom: 6rem;
    padding-top: 2rem;
}

.page-title {
    font-size: clamp(2.5rem, 7vw, 4rem);
    font-weight: 700;
    letter-spacing: -0.03em;
    margin-bottom: 1rem;
}

.page-subtitle {
    font-size: 1.125rem;
    color: var(--text-secondary);
    max-width: 500px;
    margin: 0 auto;
}

/* ============================================
   EXAMPLE SECTIONS
   ============================================ */
.example {
    margin-bottom: 8rem;
    padding-bottom: 4rem;
    border-bottom: 1px solid var(--border);
}

.example:last-child {
    margin-bottom: 0;
    padding-bottom: 0;
    border-bottom: none;
}

.example-header {
    display: flex;
    align-items: center;
    gap: 1rem;
    margin-bottom: 2rem;
}

.example-number {
    font-family: 'JetBrains Mono', monospace;
    font-size: 0.75rem;
    font-weight: 600;
    color: var(--accent);
    letter-spacing: 0.1em;
}

.example-title {
    font-family: 'Space Grotesk', system-ui, sans-serif;
    font-size: 0.875rem;
    font-weight: 500;
    text-transform: uppercase;
    letter-spacing: 0.1em;
    color: var(--text-muted);
}

.example-content {
    padding-left: 3rem;
}

.example-instruction {
    font-size: 1rem;
    color: var(--text-secondary);
    margin-bottom: 2rem;
    line-height: 1.7;
}

/* ============================================
   EFFECT-SPECIFIC STYLES
   ============================================ */

/* Spacer pushes examples below the fold so the
   scroll trigger is visible in the demo */
.demo-spacer {
    min-height: 45vh;
    display: flex;
    align-items: center;
    justify-content: center;
}

.demo-spacer p {
    font-family: 'JetBrains Mono', monospace;
    font-size: 0.75rem;
    text-transform: uppercase;
    letter-spacing: 0.2em;
    color: var(--text-muted);
}

/* Typed headline */
.typewriter-headline {
    font-size: clamp(2rem, 6vw, 3.5rem);
    min-height: 2.2em; /* reserve space, avoid layout shift while typing */
    color: var(--text);
}

/* Looping phrase line */
.typewriter-line {
    font-size: clamp(1.25rem, 3.5vw, 1.75rem);
    font-weight: 500;
    min-height: 1.6em;
}

.typewriter-line [data-typewriter] {
    color: var(--accent);
}

/* Blinking cursor */
.typewriter__cursor {
    display: inline-block;
    width: 0.09em;
    height: 1em;
    margin-left: 0.1em;
    background: var(--accent);
    vertical-align: -0.1em;
    animation: typewriter-blink 1s steps(2, start) infinite;
}

@keyframes typewriter-blink {
    0%, 49% {
        opacity: 1;
    }
    50%, 100% {
        opacity: 0;
    }
}

/* ============================================
   RESPONSIVE
   ============================================ */
@media (max-width: 768px) {
    .container {
        padding: 3rem 1.5rem;
    }

    .page-header {
        margin-bottom: 4rem;
    }

    .example {
        margin-bottom: 5rem;
        padding-bottom: 3rem;
    }

    .example-content {
        padding-left: 0;
    }
}

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

    .typewriter__cursor {
        display: none;
        animation: none;
    }
}

/* ============================================
   SCREEN READER ONLY (for ARIA live regions)
   ============================================ */
.sr-only {
    position: absolute;
    width: 1px;
    height: 1px;
    padding: 0;
    margin: -1px;
    overflow: hidden;
    clip: rect(0, 0, 0, 0);
    white-space: nowrap;
    border: 0;
}

Text types out character by character with a blinking cursor when scrolled into view. Supports looping phrases, custom speed, and delay controls.

Quick Start

1. Add to your HTML <head>:

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

2. Add before closing </body> tag:

<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 any text element in your <body>:

<h1 data-typewriter>Build interfaces that feel alive.</h1>

That's it. The element's own text is read, cleared, and typed back in when it scrolls into view.

Options

Attribute Values Default Description
data-type-speed Seconds per character 0.045 Typing speed; lower is faster
data-type-delay Seconds 0 Delay before typing starts, after the element enters the viewport
data-type-cursor true, false true Show the blinking cursor
data-type-loop Comma-separated phrases none Phrases to rotate through after the first: types, pauses, deletes, types the next, and loops forever

Examples

Slower Typing With a Delay

HTML:

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

No Cursor

HTML:

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

Looping Phrases

The element types its own text first, then rotates through the loop list:

HTML:

<p>We build
  <span data-typewriter
        data-type-loop="smooth animations, bold interfaces, memorable experiences">
    delightful websites
  </span>
</p>

CSS Classes

Class Description
.typewriter__text Span holding the typed characters (created automatically)
.typewriter__cursor Blinking cursor span (created automatically)
.is-typing Applied to the element when typing begins
.is-complete Applied when a non-looping element finishes typing

The cursor blink is pure CSS. Restyle it by overriding .typewriter__cursor:

CSS:

.typewriter__cursor {
  background: #ff3366; /* cursor colour */
  width: 0.5em;        /* block-style cursor */
}

Events

The effect dispatches custom events (they bubble). Add this to your own JavaScript:

JavaScript:

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

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

el.addEventListener('typewriter:complete', (e) => {
  console.log('Phrase finished:', e.detail.text, 'index:', e.detail.index);
});
Event Detail Description
typewriter:start { text } Fired once when typing begins
typewriter:complete { text, index } Fired each time a phrase finishes typing (once per phrase in loop mode)

Accessibility

  • Reduced motion: with prefers-reduced-motion: reduce, the full text is shown instantly and the cursor is hidden; nothing animates
  • Screen readers: the complete original text is set as aria-label on the element before typing starts, so assistive technology never hears partial words; the animated spans are aria-hidden
  • No layout shift: reserve space with min-height on the element (see the demo CSS) so surrounding content does not jump while typing
  • Non-interactive: the effect adds no focusable elements and does not trap keyboard focus

Browser Support

Modern browsers (ES6+). Not compatible with IE11.

Dependencies

Required:

  • GSAP 3.12+
  • ScrollTrigger plugin

Optional:

  • Lenis (smooth scroll integration; effect works without it)

Your Cart

Your cart is empty

Browse Effects