Back to Gallery

Typewriter Text

FREE

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

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

About This Effect

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.

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
<!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>
/**
 * 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 {
    --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;
    }
}

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

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

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

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

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

Your Cart

Your cart is empty

Browse Effects