JavaScript Text Animation: 16 Examples You Can Copy

When JavaScript text animation goes wrong, the splitting step is usually to blame. Adrian Roselli's February 2026 tests of GSAP's own SplitText demo found the text announced correctly in only 2 of 8 screen reader and browser pairings.

Below are 16 vanilla JavaScript text animation examples you can try and copy, followed by a guide to splitting, timing, performance and accessibility.

By Bogdan Sandu ยท Updated October 28, 2026

Splitting and revealing text

Four ways to cut text into pieces and bring it in with plain JavaScript. Each one keeps a single readable copy for screen readers, and the splitter and the scroll reveal put the original text back once the reveal ends.

Text splitter with aria-label and revert

  • Intl.Segmenter
  • createElement
  • aria-label

Good type survives every screen size

Switch between Letters and Words to split again.

A splitter that builds every span with createElement and textContent, never innerHTML, and uses Intl.Segmenter so accented letters and emoji stay in one piece. The heading gets an aria-label and the spans get aria-hidden while the reveal runs, then the script restores the plain text, so nothing depends on aria-label support once the motion is over.

View code
.jx-split {
  width: min(100%, 30rem);
  padding: clamp(1.25rem, 5vw, 2rem);
  border: 1px solid #e4dccd;
  border-radius: 20px;
  background: #f8f3ea;
  color: #1d1a16;
}
.jx-split__title {
  min-height: 2.3em;
  margin: 0 0 1.25rem;
  font-family: "Fraunces", Georgia, serif;
  font-size: clamp(1.7rem, 6vw, 2.4rem);
  font-weight: 600;
  line-height: 1.15;
  letter-spacing: -.01em;
  color: #1d1a16;
}
.jx-split__word {
  display: inline-block;
  white-space: nowrap;
}
.jx-split__char {
  display: inline-block;
}
.jx-split__bar {
  display: flex;
  flex-wrap: wrap;
  gap: .75rem;
  align-items: center;
  justify-content: space-between;
}
.jx-split__modes {
  display: inline-flex;
  padding: 3px;
  border: 1.5px solid #1d1a16;
  border-radius: 999px;
}
.jx-split__mode,
.jx-split__replay {
  min-height: 40px;
  padding: 0 1rem;
  border: 0;
  border-radius: 999px;
  font: inherit;
  font-size: .9rem;
  font-weight: 600;
  cursor: pointer;
}
.jx-split__mode {
  background: transparent;
  color: #1d1a16;
}
.jx-split__mode[aria-pressed="true"] {
  background: #1d1a16;
  color: #f8f3ea;
}
.jx-split__replay {
  min-height: 46px;
  padding: 0 1.3rem;
  background: #a63d1c;
  color: #fff;
}
.jx-split__mode:focus-visible,
.jx-split__replay:focus-visible {
  outline: 3px solid #a63d1c;
  outline-offset: 2px;
}
.jx-split__status {
  min-height: 1.4em;
  margin: 1rem 0 0;
  font-size: .85rem;
  color: #5a5047;
}

Web Animations API letter stagger

  • element.animate()
  • finished promise
  • stagger delay

Orbit 4 is here

A calmer calendar for teams that work across time zones.

Each letter gets its own element.animate() call with a delay of index times 45 ms, inside a word mask so the letters rise out of a clipped line. Promise.all on the finished promises tells the script when the whole headline has landed, which is the hook you would use to chain the next step.

View code
.jx-wa {
  container-type: inline-size;
  width: min(100%, 34rem);
  color: #f5f3ee;
}
.jx-wa__sr {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}
.jx-wa__title {
  margin: 0;
  font-size: clamp(2.4rem, 15cqi, 4.6rem);
  font-weight: 800;
  line-height: 1.02;
  letter-spacing: -.035em;
  color: #f5f3ee;
}
.jx-wa__mask {
  display: inline-block;
  padding-bottom: .08em;
  overflow: hidden;
  vertical-align: top;
  white-space: nowrap;
}
.jx-wa__char {
  display: inline-block;
}
.jx-wa__char:only-child {
  color: #c8f169;
}
.jx-wa__sub {
  max-width: 26rem;
  margin: 1rem 0 1.5rem;
  font-size: 1.05rem;
  line-height: 1.5;
  color: #b6bac6;
}
.jx-wa__row {
  display: flex;
  flex-wrap: wrap;
  gap: 1rem;
  align-items: center;
}
.jx-wa__replay {
  min-height: 46px;
  padding: 0 1.4rem;
  border: 0;
  border-radius: 999px;
  background: #c8f169;
  color: #0f1208;
  font: inherit;
  font-weight: 700;
  cursor: pointer;
}
.jx-wa__replay:focus-visible {
  outline: 3px solid #f5f3ee;
  outline-offset: 3px;
}
.jx-wa__state {
  font-family: ui-monospace, Consolas, monospace;
  font-size: .85rem;
  color: #9aa0ad;
}

Word-by-word reveal on scroll

  • IntersectionObserver
  • root option
  • revert after reveal

The slow way north

The night train leaves Oslo at eleven, and by the time the city lights thin out, the dining car has already run out of coffee.

Nobody minds. The point of taking nine hours instead of one is that the trip stops being a gap between two places and becomes a place of its own.

Somewhere past the mountains the snow starts, quietly, and the window turns into a long grey film that nobody is in a hurry to finish.

At dawn the conductor walks the corridor with a thermos, and the first cup of the day tastes better than anything at the airport ever did.

Anna Lindqvist, Field Journal, issue 7

Scroll inside the panel.

An IntersectionObserver watches each paragraph inside the scroll panel and, when one is a third visible, fades its words in one after another and then unobserves it. The ghosted starting state comes from a class the script adds, so with JavaScript off the essay is simply readable, and each paragraph goes back to plain text after its reveal.

View code
.jx-scr {
  width: min(100%, 30rem);
}
.jx-scr__box {
  height: 300px;
  padding: 1.5rem 1.5rem 2rem;
  overflow-y: auto;
  border-radius: 18px;
  background: #fff;
  box-shadow: 0 1px 2px rgba(20, 24, 40, .06), 0 12px 32px rgba(20, 24, 40, .08);
  overscroll-behavior: contain;
}
.jx-scr__box:focus-visible {
  outline: 3px solid #3346d3;
  outline-offset: 3px;
}
.jx-scr__head {
  margin: 0 0 1rem;
  font-family: "Fraunces", Georgia, serif;
  font-size: 1.6rem;
  font-weight: 600;
  line-height: 1.2;
  color: #171a2b;
}
.jx-scr__p {
  margin: 0 0 1.1rem;
  font-size: 1.08rem;
  line-height: 1.65;
  color: #232638;
}
.jx-scr--js .jx-scr__p:not(.is-done) .jx-scr__w {
  opacity: .14;
}
.jx-scr__end {
  margin: 1.5rem 0 0;
  font-size: .85rem;
  color: #5a5f73;
}

Marker highlight sweep

  • mark element
  • scaleX
  • sequenced delays

We cut onboarding from nine screens to three, and support tickets fell by almost a third within one release.

Mara Ilic, Head of Product at Tidepool

The script inserts an aria-hidden ink span behind each mark element and sweeps it in with a scaleX animation, one phrase after the next. A solid color and transform do the work, so no background gradient is needed, and the mark elements keep their meaning for assistive tech whether or not the ink is showing.

View code
.jx-hl {
  width: min(100%, 31rem);
}
.jx-hl__quote {
  margin: 0 0 1.25rem;
  padding: clamp(1.25rem, 5vw, 1.9rem);
  border: 0;
  border-left: 4px solid #1b1b1f;
  border-radius: 4px 16px 16px 4px;
  background: #fff;
}
.jx-hl__quote p:first-child {
  margin: 0 0 1rem;
  font-family: "Fraunces", Georgia, serif;
  font-size: clamp(1.25rem, 4.5vw, 1.55rem);
  font-weight: 500;
  font-style: normal;
  line-height: 1.45;
  color: #1b1b1f;
}
.jx-hl__mark {
  position: relative;
  z-index: 0;
  padding: 0 .1em;
  white-space: nowrap;
  background: #ffd84d;
  color: inherit;
}
.jx-hl--js .jx-hl__mark {
  background: transparent;
}
.jx-hl__ink {
  position: absolute;
  inset: .12em -.08em .02em;
  z-index: -1;
  border-radius: .25em .6em .3em .5em;
  background: #ffd84d;
  transform: scaleX(0);
  transform-origin: left center;
}
.jx-hl.is-on .jx-hl__ink {
  transform: none;
}
.jx-hl .jx-hl__by {
  margin: 0;
  font-family: inherit;
  font-size: .9rem;
  color: #56566a;
}
.jx-hl__btn {
  min-height: 46px;
  padding: 0 1.3rem;
  border: 2px solid #1b1b1f;
  border-radius: 12px;
  background: #fff;
  color: #1b1b1f;
  font: inherit;
  font-weight: 700;
  cursor: pointer;
  transition: background-color .2s, color .2s;
}
.jx-hl__btn[aria-pressed="true"] {
  background: #1b1b1f;
  color: #fff;
}
.jx-hl__btn:focus-visible {
  outline: 3px solid #e0a800;
  outline-offset: 3px;
}

Typing, decoding and swapping text

Effects where the text itself changes, which CSS alone cannot do. Every demo that loops has a visible pause button, because WCAG 2.2.2 asks for one when motion starts on its own and runs longer than five seconds.

Typewriter with variable speed and pause

  • requestAnimationFrame
  • elapsed time
  • pre-placed characters

Dear Ada, the cabin has no signal, so I am writing this by hand. The lake froze last night. Come before the thaw, and bring the good coffee.

The full letter is split into character spans at opacity 0, so the paragraph has its final size before the first keystroke and nothing below it moves. A precomputed schedule adds a random 50 to 90 ms per character and longer pauses after commas and full stops, and the loop reveals every character whose time has passed, so it catches up after a background tab instead of stalling.

View code
.jx-tw {
  width: min(100%, 31rem);
}
.jx-tw__paper {
  padding: clamp(1.25rem, 5vw, 2rem);
  border: 1px solid #e6dbc4;
  border-radius: 6px;
  background: #fcf8ef;
  box-shadow: 0 2px 0 #efe5cf, 0 18px 40px rgba(60, 42, 10, .12);
  transform: rotate(-.6deg);
}
.jx-tw__sr {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}
.jx-tw__text {
  margin: 0;
  font-family: "Fraunces", Georgia, serif;
  font-size: clamp(1.1rem, 4vw, 1.3rem);
  font-style: italic;
  line-height: 1.6;
  color: #2c2620;
}
.jx-tw--js .jx-tw__c {
  opacity: 0;
}
.jx-tw--js .jx-tw__c.is-on {
  opacity: 1;
}
.jx-tw__c.is-caret {
  box-shadow: 2px 0 0 #b3412a;
}
.jx-tw__controls {
  display: flex;
  flex-wrap: wrap;
  gap: .6rem 1rem;
  align-items: center;
  margin-top: 1.4rem;
}
.jx-tw__pause,
.jx-tw__restart {
  min-height: 44px;
  padding: 0 1.1rem;
  border: 1.5px solid #2c2620;
  border-radius: 10px;
  background: #fff;
  color: #2c2620;
  font: inherit;
  font-weight: 600;
  cursor: pointer;
}
.jx-tw__pause[aria-pressed="true"] {
  background: #2c2620;
  color: #fff;
}
.jx-tw__pause:focus-visible,
.jx-tw__restart:focus-visible,
.jx-tw__range:focus-visible {
  outline: 3px solid #b3412a;
  outline-offset: 2px;
}
.jx-tw__speed {
  display: inline-flex;
  gap: .5rem;
  align-items: center;
  font-size: .9rem;
  font-weight: 600;
  color: #2c2620;
}
.jx-tw__range {
  width: 6.5rem;
  accent-color: #b3412a;
}
.jx-tw__out {
  min-width: 3ch;
  font-variant-numeric: tabular-nums;
}
@media (prefers-reduced-motion: no-preference) {
  .jx-tw.is-done .is-caret,
  .jx-tw.is-paused .is-caret {
    animation: jx-tw-blink 1s steps(1) infinite;
  }
}
@keyframes jx-tw-blink {
  50% {
    box-shadow: 2px 0 0 transparent;
  }
}

Type and delete search suggestions

  • setTimeout chain
  • placeholder-shown
  • WCAG 2.2.2 pause

An overlay types a suggestion, holds it, deletes it and moves to the next, which is the animated search placeholder pattern. The overlay is aria-hidden and disappears as soon as the field has focus or a value, the input has a real label plus a static example for screen readers, and the loop has a pause button because it runs well past five seconds.

View code
.jx-td {
  width: min(100%, 28rem);
}
.jx-td__form {
  margin: 0;
  padding: clamp(1.25rem, 5vw, 1.75rem);
  border-radius: 20px;
  background: #0f4c4a;
}
.jx-td__label {
  display: block;
  margin-bottom: .75rem;
  font-family: "Fraunces", Georgia, serif;
  font-size: 1.5rem;
  font-weight: 600;
  color: #f3efe4;
}
.jx-td__field {
  position: relative;
  display: flex;
  align-items: center;
  height: 56px;
  border-radius: 14px;
  background: #fff;
}
.jx-td__input {
  flex: 1;
  min-width: 0;
  height: 100%;
  padding: 0 0 0 1rem;
  border: 0;
  border-radius: 14px 0 0 14px;
  background: transparent;
  color: #13232b;
  font: inherit;
  font-size: 1rem;
  outline: none;
}
.jx-td__input::-webkit-search-cancel-button {
  display: none;
}
.jx-td__field:focus-within {
  box-shadow: 0 0 0 3px #f2c14e;
}
.jx-td__ghost {
  position: absolute;
  top: 50%;
  left: 1rem;
  right: 60px;
  overflow: hidden;
  font-size: 1rem;
  white-space: nowrap;
  color: #56636b;
  pointer-events: none;
  transform: translateY(-50%);
}
.jx-td__input:focus ~ .jx-td__ghost,
.jx-td__input:not(:placeholder-shown) ~ .jx-td__ghost {
  visibility: hidden;
}
.jx-td__typed {
  color: #13232b;
}
.jx-td__caret {
  display: inline-block;
  width: 2px;
  height: 1.1em;
  margin-left: 1px;
  vertical-align: -.2em;
  background: #0f4c4a;
}
.jx-td__go {
  display: grid;
  place-items: center;
  flex: none;
  width: 44px;
  height: 44px;
  margin-right: 6px;
  padding: 0;
  border: 0;
  border-radius: 10px;
  background: #0f4c4a;
  color: #fff;
  cursor: pointer;
}
.jx-td__go:focus-visible {
  outline: 3px solid #f2c14e;
  outline-offset: 2px;
}
.jx-td__sr {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}
.jx-td__pause {
  min-height: 36px;
  margin-top: .9rem;
  padding: 0 .9rem;
  border: 1.5px solid #cfe3df;
  border-radius: 999px;
  background: transparent;
  color: #f3efe4;
  font: inherit;
  font-size: .85rem;
  font-weight: 600;
  cursor: pointer;
}
.jx-td__pause[aria-pressed="true"] {
  background: #f3efe4;
  color: #0f4c4a;
}
.jx-td__pause:focus-visible {
  outline: 3px solid #f2c14e;
  outline-offset: 2px;
}
.jx-td__result {
  min-height: 1.2em;
  margin: .6rem 0 0;
  font-size: .85rem;
  color: #d9ebe7;
}
@media (prefers-reduced-motion: no-preference) {
  .jx-td__caret {
    animation: jx-td-blink 1s steps(1) infinite;
  }
  .jx-td.is-typing .jx-td__caret {
    animation: none;
  }
}
@keyframes jx-td-blink {
  50% {
    opacity: 0;
  }
}

Scramble and decode menu

  • requestAnimationFrame
  • random glyphs
  • hover and focus

Signal and Noise, design studio

Hover or tab through the menu.

Each label flickers through random glyphs and locks in letter by letter, the effect libraries like shuffle-text sell for hero titles and menus. A monospace face keeps the width fixed while the characters churn, and the scrambled layer is aria-hidden next to a hidden copy of the real label, so screen readers never hear the noise.

View code
.jx-sc {
  width: min(100%, 26rem);
  font-family: ui-monospace, Consolas, monospace;
}
.jx-sc__sr {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}
.jx-sc__brand {
  margin: 0 0 1.5rem;
  font-size: clamp(1.5rem, 7vw, 2rem);
  font-weight: 700;
  letter-spacing: .04em;
  color: #ffb547;
  white-space: nowrap;
}
.jx-sc__menu {
  display: grid;
  border-top: 1px solid #2b3140;
}
.jx-sc__item {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 1rem;
  min-height: 56px;
  padding: .7rem .25rem;
  border: 0;
  border-bottom: 1px solid #2b3140;
  background: none;
  color: #e9edf3;
  font: inherit;
  font-size: clamp(1.25rem, 6vw, 1.6rem);
  font-weight: 700;
  letter-spacing: .06em;
  text-align: left;
  cursor: pointer;
  transition: color .2s;
}
.jx-sc__item:hover,
.jx-sc__item:focus-visible {
  color: #ffb547;
}
.jx-sc__item:focus-visible {
  outline: 2px solid #ffb547;
  outline-offset: 2px;
}
.jx-sc__fx {
  white-space: pre;
}
.jx-sc__tag {
  font-size: .75rem;
  font-weight: 400;
  letter-spacing: 0;
  color: #98a1b3;
}

Rotating words with a pause button

  • aria-live="off"
  • grid stacking
  • element.animate()

Payroll that feels effortlessfairinstanthuman

Ledgerly pays your team in 40 countries from one screen, on the same day every month.

All four words sit in the same grid cell, so the slot is always as wide as the longest one and the headline never jumps. Hidden words use visibility: hidden, which also removes them from the accessibility tree, and the slot is marked aria-live="off" so a screen reader reads the current word once instead of announcing every swap.

View code
.jx-rw {
  container-type: inline-size;
  width: min(100%, 32rem);
}
.jx-rw__line {
  margin: 0;
  font-family: "Fraunces", Georgia, serif;
  font-size: clamp(2rem, 10cqi, 3.2rem);
  font-weight: 600;
  line-height: 1.12;
  letter-spacing: -.02em;
  color: #12261d;
}
.jx-rw__slot {
  display: inline-grid;
  padding-bottom: .06em;
  overflow: hidden;
  vertical-align: bottom;
  border-bottom: 4px solid #0d7a4e;
}
.jx-rw__word {
  grid-area: 1 / 1;
  font-style: italic;
  color: #0d7a4e;
  visibility: hidden;
}
.jx-rw__word.is-on {
  visibility: visible;
}
.jx-rw__sub {
  max-width: 25rem;
  margin: 1.1rem 0 1.4rem;
  font-size: 1.05rem;
  line-height: 1.55;
  color: #3d4f46;
}
.jx-rw__pause {
  display: inline-flex;
  gap: .5rem;
  align-items: center;
  min-height: 44px;
  padding: 0 1.1rem;
  border: 1.5px solid #12261d;
  border-radius: 999px;
  background: transparent;
  color: #12261d;
  font: inherit;
  font-size: .9rem;
  font-weight: 600;
  cursor: pointer;
}
.jx-rw__pause svg {
  fill: currentColor;
}
.jx-rw__pause[aria-pressed="true"] {
  background: #12261d;
  color: #fff;
}
.jx-rw__pause:focus-visible {
  outline: 3px solid #0d7a4e;
  outline-offset: 3px;
}

Liquid morph between words

  • SVG feColorMatrix
  • blur crossfade
  • requestAnimationFrame

Sketch, shape, ship.

Three stages, one weekly design review.

Two stacked words trade blur and opacity every frame while an SVG alpha threshold filter on their parent snaps the soft edges back to solid shapes, so one word appears to melt into the next. The visual layer is aria-hidden and a hidden sentence holds all three words, and the loop pauses on demand.

View code
.jx-mo {
  container-type: inline-size;
  width: min(100%, 30rem);
  text-align: center;
}
.jx-mo__defs {
  position: absolute;
  width: 0;
  height: 0;
}
.jx-mo__sr {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}
.jx-mo__stage {
  position: relative;
  height: 1.25em;
  font-size: clamp(3.2rem, 20cqi, 6rem);
  font-weight: 800;
  line-height: 1.25;
  letter-spacing: -.03em;
  color: #f4f1ea;
  filter: url(#jx-mo-threshold);
}
.jx-mo__a,
.jx-mo__b {
  position: absolute;
  inset: 0;
  white-space: nowrap;
  user-select: none;
}
.jx-mo__b {
  opacity: 0;
}
.jx-mo__caption {
  margin: .75rem 0 1.4rem;
  font-size: 1rem;
  color: #a9b0c2;
}
.jx-mo__pause {
  min-height: 44px;
  padding: 0 1.4rem;
  border: 1.5px solid #f4f1ea;
  border-radius: 999px;
  background: transparent;
  color: #f4f1ea;
  font: inherit;
  font-weight: 600;
  cursor: pointer;
}
.jx-mo__pause[aria-pressed="true"] {
  background: #f4f1ea;
  color: #0b0f1a;
}
.jx-mo__pause:focus-visible {
  outline: 3px solid #7fb3ff;
  outline-offset: 3px;
}

Split-flap departure board

  • character drum
  • per-cell timing
  • aria-live status

Each cell steps through a fixed drum of characters, space to Z to 9, until it reaches its target, exactly like a mechanical board, so cells that travel further settle later. A small scaleY flick on every step sells the flap. The board itself is aria-hidden and a polite status line announces the new departure once when you press the button.

View code
.jx-fl {
  width: min(100%, 24rem);
}
.jx-fl__board {
  display: grid;
  gap: 1rem;
  padding: clamp(.8rem, 4vw, 1.1rem);
  border: 1px solid #2a2e36;
  border-radius: 14px;
  background: #16181d;
}
.jx-fl__pair {
  display: flex;
  flex-wrap: wrap;
  gap: 1rem 1.5rem;
}
.jx-fl__row {
  display: grid;
  gap: .4rem;
}
.jx-fl__label {
  font-size: .72rem;
  font-weight: 700;
  letter-spacing: .12em;
  text-transform: uppercase;
  color: #a3a9b6;
}
.jx-fl__cells {
  display: flex;
  gap: 3px;
}
.jx-fl__cell {
  position: relative;
  display: grid;
  place-items: center;
  flex: 0 1 30px;
  min-width: 0;
  height: 42px;
  border-radius: 4px;
  background: #262a31;
  color: #f7c948;
  font-family: ui-monospace, Consolas, monospace;
  font-size: 1.3rem;
  font-weight: 700;
  white-space: pre;
}
.jx-fl__cell::after {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  top: 50%;
  height: 1px;
  background: #0d0e11;
}
.jx-fl__sr {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}
.jx-fl__next {
  min-height: 46px;
  margin-top: 1.1rem;
  padding: 0 1.3rem;
  border: 0;
  border-radius: 10px;
  background: #f7c948;
  color: #16181d;
  font: inherit;
  font-weight: 700;
  cursor: pointer;
}
.jx-fl__next:focus-visible {
  outline: 3px solid #fff;
  outline-offset: 3px;
}

Numbers and timed text

requestAnimationFrame loops that read the clock instead of counting frames, so they land on time even after a background tab wakes up. The final values sit in the markup before any script runs.

Count-up stats with easing

  • requestAnimationFrame
  • easeOutExpo
  • Intl.NumberFormat
Orders this month
12,480
Delivered on time
98.6%
Revenue, year to date
$4.2M

One requestAnimationFrame loop drives all three numbers from the elapsed time, passed through an ease-out curve so they rush and then settle. Intl.NumberFormat formats every frame, including the compact currency, and each number has a reserved width plus tabular figures, so the cards never twitch while the digits change.

View code
.jx-ct {
  width: min(100%, 36rem);
}
.jx-ct__grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(9.5rem, 1fr));
  gap: .9rem;
  margin: 0 0 1.25rem;
}
.jx-ct__card {
  padding: 1.1rem 1.2rem;
  border: 1px solid #e3e6ee;
  border-top: 4px solid #2f5fe0;
  border-radius: 14px;
  background: #fff;
}
.jx-ct__card:nth-child(2) {
  border-top-color: #12946b;
}
.jx-ct__card:nth-child(3) {
  border-top-color: #e0662f;
}
.jx-ct__label {
  font-size: .85rem;
  font-weight: 600;
  color: #4a5368;
}
.jx-ct__value {
  margin: .35rem 0 0;
}
.jx-ct__num {
  display: inline-block;
  font-size: clamp(1.9rem, 7vw, 2.3rem);
  font-weight: 800;
  line-height: 1.1;
  letter-spacing: -.02em;
  font-variant-numeric: tabular-nums;
  color: #101828;
}
.jx-ct__sr {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}
.jx-ct__replay {
  min-height: 44px;
  padding: 0 1.2rem;
  border: 1.5px solid #101828;
  border-radius: 10px;
  background: #fff;
  color: #101828;
  font: inherit;
  font-weight: 600;
  cursor: pointer;
}
.jx-ct__replay:focus-visible {
  outline: 3px solid #2f5fe0;
  outline-offset: 2px;
}

Odometer number ticker

  • digit columns
  • translateY
  • transition-delay

Orders today

Every digit is a clipped column holding 0 to 9, and the script only sets a translateY on each column, so the browser animates the roll with a CSS transition that sits inside a no-preference media query. Columns are reused from the right, a new leading digit simply rolls in, and the screen reader copy is a polite status line that changes once per update.

View code
.jx-tk {
  width: min(100%, 22rem);
  padding: 1.6rem 1.5rem;
  border-radius: 22px;
  background: #1d1b2f;
  color: #f6f2ff;
}
.jx-tk__label {
  margin: 0 0 .4rem;
  font-size: .9rem;
  font-weight: 600;
  color: #c4bee0;
}
.jx-tk__value {
  display: flex;
  margin: 0;
  font-size: clamp(2.8rem, 13vw, 3.6rem);
  font-weight: 800;
  line-height: 1.1;
  letter-spacing: -.02em;
  font-variant-numeric: tabular-nums;
}
.jx-tk__col {
  display: inline-block;
  height: 1.1em;
  overflow: hidden;
}
.jx-tk__strip {
  display: flex;
  flex-direction: column;
}
.jx-tk__strip span {
  display: block;
  height: 1.1em;
  text-align: center;
}
.jx-tk__sep {
  display: inline-block;
}
.jx-tk__sr {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}
.jx-tk__delta {
  margin: .5rem 0 1.25rem;
  font-size: .9rem;
  color: #7fe0ab;
}
.jx-tk__btn {
  min-height: 46px;
  padding: 0 1.2rem;
  border: 0;
  border-radius: 12px;
  background: #a99cff;
  color: #14112a;
  font: inherit;
  font-weight: 700;
  cursor: pointer;
}
.jx-tk__btn:focus-visible {
  outline: 3px solid #f6f2ff;
  outline-offset: 3px;
}
@media (prefers-reduced-motion: no-preference) {
  .jx-tk__strip {
    transition: transform .9s cubic-bezier(.2, .8, .2, 1);
  }
}

Karaoke transcript highlight

  • word timings
  • performance.now()
  • play and pause

Small Rooms, episode 12

Most homepages try to say everything at once. The ones that work pick a single sentence, put it first, and trust the reader to scroll for the rest.

The script gives every word a start time from its length and the punctuation after it, then a clock loop marks words as spoken, current or upcoming, the way a podcast player follows its audio track. In a real player you would read audio.currentTime instead of the clock. Only colors change, so it runs the same with reduced motion, and it never starts on its own.

View code
.jx-ka {
  width: min(100%, 30rem);
  padding: clamp(1.2rem, 5vw, 1.6rem);
  border-radius: 20px;
  background: #fff;
  box-shadow: 0 14px 40px rgba(26, 26, 46, .1);
}
.jx-ka__head {
  display: flex;
  gap: .9rem;
  align-items: center;
  margin-bottom: 1.2rem;
}
.jx-ka__play {
  display: grid;
  place-items: center;
  flex: none;
  width: 48px;
  height: 48px;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: #1a1a2e;
  color: #fff;
  cursor: pointer;
}
.jx-ka__play svg {
  fill: currentColor;
}
.jx-ka__i-pause,
.jx-ka.is-playing .jx-ka__i-play {
  display: none;
}
.jx-ka.is-playing .jx-ka__i-pause {
  display: block;
}
.jx-ka__play:focus-visible {
  outline: 3px solid #e09f1f;
  outline-offset: 3px;
}
.jx-ka__meta {
  flex: 1;
  min-width: 0;
}
.jx-ka__show {
  margin: 0 0 .45rem;
  font-size: .9rem;
  font-weight: 700;
  color: #1a1a2e;
}
.jx-ka__bar {
  height: 6px;
  overflow: hidden;
  border-radius: 3px;
  background: #e6e7ee;
}
.jx-ka__fill {
  display: block;
  height: 100%;
  background: #1a1a2e;
  transform: scaleX(0);
  transform-origin: left;
}
.jx-ka__time {
  flex: none;
  font-size: .8rem;
  font-variant-numeric: tabular-nums;
  color: #555a6e;
}
.jx-ka__text {
  margin: 0;
  font-family: "Fraunces", Georgia, serif;
  font-size: clamp(1.2rem, 4.6vw, 1.45rem);
  line-height: 1.55;
  color: #62667a;
}
.jx-ka__w {
  padding: .05em .08em;
  border-radius: 4px;
  transition: color .15s, background-color .15s;
}
.jx-ka__w.is-said {
  color: #1a1a2e;
}
.jx-ka__w.is-now {
  background: #ffd166;
  color: #1a1a2e;
}

Pointer and input effects

Text that reacts to the visitor. The pointer effect stays off until it is switched on and ignores touch, the glitch fires only on click, and the badge echoes what you type one character at a time.

Letters that dodge the pointer

  • pointermove
  • pointerType check
  • opt-in switch

Magnetic Fields, a poster series

Off by default. Mouse only, touch is ignored.

Letters within 120 px of the mouse are pushed away along the line from the pointer, harder the closer it gets, and a CSS transition smooths each move. The effect is off until the switch is turned on, only reacts to pointerType mouse so touch scrolling is never hijacked, and stays disabled when the system asks for reduced motion.

View code
.jx-rp {
  container-type: inline-size;
  width: min(100%, 30rem);
}
.jx-rp__field {
  padding: clamp(1.4rem, 6cqi, 2.2rem);
  border-radius: 18px;
  background: #ff5a36;
  color: #14110f;
}
.jx-rp.is-on .jx-rp__field {
  cursor: crosshair;
}
.jx-rp__sr {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}
.jx-rp__word {
  margin: 0;
  font-size: clamp(2.6rem, 16cqi, 4.8rem);
  font-weight: 800;
  line-height: .95;
  letter-spacing: -.03em;
  text-transform: uppercase;
  color: #14110f;
}
.jx-rp__l {
  display: inline-block;
}
.jx-rp__foot {
  margin: 1.4rem 0 0;
  font-size: .85rem;
  font-weight: 700;
  letter-spacing: .1em;
  text-transform: uppercase;
  color: #14110f;
}
.jx-rp__toggle {
  display: inline-flex;
  gap: .7rem;
  align-items: center;
  min-height: 44px;
  margin-top: 1rem;
  font-weight: 600;
  color: #14110f;
  cursor: pointer;
}
.jx-rp__check {
  position: absolute;
  opacity: 0;
  width: 1px;
  height: 1px;
}
.jx-rp__switch {
  position: relative;
  width: 46px;
  height: 26px;
  border-radius: 13px;
  background: #8a8f9c;
  transition: background-color .2s;
}
.jx-rp__switch::after {
  content: "";
  position: absolute;
  top: 3px;
  left: 3px;
  width: 20px;
  height: 20px;
  border-radius: 50%;
  background: #fff;
}
.jx-rp__check:checked + .jx-rp__switch {
  background: #14110f;
}
.jx-rp__check:checked + .jx-rp__switch::after {
  left: 23px;
}
.jx-rp__check:focus-visible + .jx-rp__switch {
  outline: 3px solid #ff5a36;
  outline-offset: 3px;
}
.jx-rp__check:disabled + .jx-rp__switch {
  opacity: .5;
}
.jx-rp__note {
  margin: .25rem 0 0;
  font-size: .85rem;
  color: #4d5160;
}
@media (prefers-reduced-motion: no-preference) {
  .jx-rp__l {
    transition: transform .3s cubic-bezier(.2, .8, .2, 1);
  }
  .jx-rp__switch::after {
    transition: left .2s ease;
  }
}

Glitch burst on click

  • generated keyframes
  • clip-path inset
  • steps easing

Dock Seven

Live sets every Friday, doors at 22:00

Each click builds a fresh set of random keyframes, slicing two colored copies of the title with clip-path inset and shoving them sideways, then hands them to element.animate() with stepped easing so the burst snaps instead of sliding. It lasts under half a second, never repeats on its own, and the button is disabled under reduced motion.

View code
.jx-gl {
  container-type: inline-size;
  width: min(100%, 30rem);
}
.jx-gl__sr {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}
.jx-gl__title {
  position: relative;
  display: grid;
  font-size: clamp(2.6rem, 16cqi, 5rem);
  font-weight: 800;
  line-height: 1;
  letter-spacing: -.02em;
  color: #f2f2f2;
}
.jx-gl__base,
.jx-gl__layer {
  grid-area: 1 / 1;
}
.jx-gl__layer {
  opacity: 0;
  mix-blend-mode: screen;
  pointer-events: none;
}
.jx-gl__layer--r {
  color: #ff2e57;
}
.jx-gl__layer--c {
  color: #22d8e0;
}
.jx-gl__meta {
  margin: 1rem 0 1.5rem;
  padding-top: 1rem;
  border-top: 1px solid #343a48;
  font-family: ui-monospace, Consolas, monospace;
  font-size: .9rem;
  color: #b8bfcc;
}
.jx-gl__btn {
  min-height: 46px;
  padding: 0 1.3rem;
  border: 2px solid #f2f2f2;
  border-radius: 0;
  background: #f2f2f2;
  color: #0b0f1a;
  font: inherit;
  font-weight: 700;
  letter-spacing: .02em;
  cursor: pointer;
  transition: background-color .15s, color .15s;
}
.jx-gl__btn:hover {
  background: transparent;
  color: #f2f2f2;
}
.jx-gl__btn:focus-visible {
  outline: 3px solid #22d8e0;
  outline-offset: 3px;
}
.jx-gl__btn:disabled {
  border-color: #6b7280;
  background: transparent;
  color: #b8bfcc;
  cursor: not-allowed;
}

Badge that echoes your typing

  • input event
  • grapheme diff
  • element.animate()

Type a name into the field.

On every input event the script compares the new graphemes with the ones already on the badge, keeps the shared start, removes the rest and adds only the new characters, so only freshly typed letters drop in. The badge is aria-hidden because the labelled input already holds the same text.

View code
.jx-ie {
  width: min(100%, 20rem);
}
.jx-ie__badge {
  position: relative;
  display: grid;
  justify-items: center;
  gap: .6rem;
  min-height: 15rem;
  padding: 2.4rem 1.25rem 1.5rem;
  overflow: hidden;
  border-radius: 18px;
  background: #fff;
  box-shadow: 0 16px 40px rgba(28, 46, 38, .14);
  text-align: center;
}
.jx-ie__badge::before {
  content: "";
  position: absolute;
  inset: 0 0 auto;
  height: 5.2rem;
  background: #24493b;
}
.jx-ie__slot {
  position: absolute;
  top: .8rem;
  left: 50%;
  width: 3.2rem;
  height: .55rem;
  border-radius: 1rem;
  background: #e9efe9;
  transform: translateX(-50%);
}
.jx-ie__event {
  position: relative;
  font-size: .8rem;
  font-weight: 700;
  letter-spacing: .08em;
  text-transform: uppercase;
  color: #e9efe9;
}
.jx-ie__name {
  display: block;
  min-height: 2.6em;
  margin-top: 2.2rem;
  font-family: "Fraunces", Georgia, serif;
  font-size: 1.7rem;
  font-weight: 600;
  line-height: 1.2;
  color: #1c2e26;
  white-space: pre-wrap;
  overflow-wrap: anywhere;
}
.jx-ie.is-empty .jx-ie__name::before {
  content: "Your name";
  color: #6c7a73;
}
.jx-ie__c {
  display: inline-block;
}
.jx-ie__role {
  padding: .3rem .8rem;
  border-radius: 999px;
  background: #f2c14e;
  color: #1c2e26;
  font-size: .8rem;
  font-weight: 700;
}
.jx-ie__label {
  display: block;
  margin: 1.4rem 0 .45rem;
  font-weight: 600;
  color: #1c2e26;
}
.jx-ie__input {
  box-sizing: border-box;
  width: 100%;
  height: 48px;
  padding: 0 .9rem;
  border: 2px solid #1c2e26;
  border-radius: 10px;
  background: #fff;
  color: #1c2e26;
  font: inherit;
  font-size: 1rem;
}
.jx-ie__input::placeholder {
  color: #6c7a73;
}
.jx-ie__input:focus-visible {
  outline: 3px solid #f2c14e;
  outline-offset: 2px;
}

What is JavaScript text animation and when does CSS fall short?

CSS keyframes cover a single element that fades, slides or pulses, a fixed list of words rotating on a loop, or any effect that starts on load and never reacts to anything. No script needed for those.

Per-letter sequencing is where it stops being practical. Every unit needs its own delay, and somebody has to write all those values.

JavaScript takes over when the text itself has to change and not only its style. Typing and scrambling both work that way. So does any effect where each character gets a computed delay, where the start depends on scroll position, hover or a runtime value, or where a number counts up to a figure read from data.

Mixed setups are common and often the best option. Script splits the text and writes an index onto each span, then a CSS rule reads that index to set the delay and does the actual animating.

Which text animation effects can you build?

Typewriter, fade-in, staggered reveal, scramble, rotating words and number counters cover most real projects. Each suits a different kind of text, and most of them fall apart on anything longer than a line or two.

Typing and rotating effects

Algolia’s InstantSearch documentation recommends a random pause of 50 to 90 milliseconds between typed characters, because that range looks the most natural.

The same guide uses typing in animated search placeholders, where the motion draws attention to the search box and suggests possible queries.

A typewriter effect shows characters one at a time, usually with a blinking cursor and sometimes a delete phase. Rotating words are simpler. One word in a headline swaps for the next on a timer.

Reserve a fixed width for the rotating slot, or the rest of the line jumps on every swap. Both effects belong on one short line.

Reveal and stagger effects

The same set of character spans can take a fade, a slide, a blur or a rotation. The stagger decides how it reads.

Stagger means each unit starts a fixed delay after the previous one. The order is a design choice, so it can run forward, backward, from the center outward, or at random.

Opacity from 0 to 1 plus a short translateY does most of the work, and a scale-up from slightly smaller works too. Stick to transform and opacity for reveals. Simple versions also exist in pure CSS, like this collection of CSS text animations, which is the better pick when the text never changes.

Scramble and counter effects

Scramble effects flicker random characters, then settle on the final text. The shuffle-text library produces exactly this, and ICS.media’s roundup of text libraries points to hero sections and menus as its best fit.

Counters roll a number from its old value to a new one. NumberFlow, from the same roundup, handles smooth transitions between numeric values.

That roundup also carries a warning worth keeping. Animation should never make text harder to read or briefly cover important content.

How do you split text into characters, words and lines?

Wrap each character, word or line in its own span, set display to inline-block so transforms apply, then animate the spans instead of the text. A splitting tool does the wrapping and can restore the original markup afterward.

Wrapping characters and words in spans

A browser treats raw text as a text node, which cannot be styled or animated on its own. Each unit has to sit inside an element first.

Letter spans need display: inline-block, because transforms do not apply to plain inline boxes. A wrapper around each word with white-space: nowrap stops a line from breaking in the middle of a word. Spaces stay outside the letter spans, so the paragraph still wraps normally.

Splitting.js and GSAP’s SplitText do this wrapping for you, and Anime.js v4 ships a splitText utility that returns characters, words and lines.

GSAP’s 3.13 rewrite of SplitText improved emoji and foreign character support and added a standalone mode that loads without GSAP’s core (GSAP, 2025).

Grapheme-safe splitting with Intl.Segmenter

Intl.Segmenter became Baseline newly available in April 2024, according to MDN.

Chrome 87 and Edge 87 shipped it in 2020 and Safari 14.1 in 2021, per web.dev. Baseline waited for Firefox 125 in April 2024.

It reached Baseline Widely available on October 16, 2026, per the web-features data.

The reason to care is simple. Splitting a string with split(“”) or the spread operator cuts it by code unit or code point, so an emoji sequence joined by zero-width joiners, or a letter with a combining accent, falls into pieces and each piece gets its own span. The animation then tears the symbol apart.

Grapheme granularity returns what a reader sees as one character. Word granularity is required for Chinese and Japanese, which do not separate words with spaces.

Splitting lines on resize

Line splitting measures where words wrap, so the result is only valid at the current width. A rotated phone or a late-loading font changes the breaks.

Run the split after document.fonts.ready resolves, or the measurement uses fallback metrics. To handle resizing, watch the container with a ResizeObserver, debounce the callback, restore the original markup, then split again. SplitText offers a revert() method for the restore step.

Skip this and lines end up broken in the wrong places after the first orientation change.

Which implementation approach fits the effect?

Match the approach to what changes. Styles on split text suit CSS plus a few lines of JavaScript, and sequenced reveals without a dependency suit the Web Animations API.

Text that itself changes (typing, scramble, counters) needs a script loop. Long timelines or scroll sync justify a library.

CSS plus JavaScript is the lightest route. Transform and opacity animate smoothly, the delay comes from a custom property set on each span, and the code stays tiny. The catch is that the text cannot change, and chaining steps means listening for animationend events.

The Web Animations API is native and needs no dependency. Element.animate() has worked across browsers since March 2020 and is Baseline Widely available (MDN), and each call returns an Animation object with playback controls and a finished promise. Its keyframes take CSS properties only, according to MDN, so typing and counters are out.

A timer or requestAnimationFrame loop gives you full control and is the only route for typing, scramble and counters. Easing, pausing and cleanup are yours to write, though.

Libraries bring timelines, stagger, scroll triggers and splitting built in. You pay in bundle weight, and when a few lines of CSS could do the job, it’s a dependency you didn’t need.

For most text effects the first two approaches are enough. Reach for a library when the timeline gets long, not before.

How do GSAP, Anime.js, Typed.js and Motion compare for text?

GSAP has the broadest text toolset, Motion is the lightest for simple reveals, and Anime.js splits and staggers with a small API. Typed.js does one thing, typing.

LibraryText featuresSize and costBest fit
GSAPSplitText, TextPlugin, ScrollTriggerAbout 23.5 kB core, free since 2025Timelines and scroll-synced headlines
Anime.jssplitText, staggerModular and tree-shakeable in v4Stagger-heavy reveals
Typed.jsTyping, backspacing, looping stringsTyping onlyOne typed headline
Motionanimate(), stagger; split text in Motion+2.6 kB mini, 18 kB hybrid; core is MITLight reveals, React projects

Cost changed in 2025. After Webflow’s acquisition, GSAP became free for everyone, commercial use included, along with all former Club plugins such as SplitText (Webflow and GSAP announcements, 2025).

Motion’s own feature table puts split text under Motion+, its one-time-fee membership, while the core library is MIT licensed (npm).

The size figures come from Motion’s GSAP comparison page, which lists GSAP core at about 23.5 kB without tree-shaking. Motion’s docs disagree with themselves slightly: the same comparison page says 3.2 kB for the mini build and 22 kB for the full one in its prose, and the React bundle-size page lists 3.3 kB and 23 kB for useAnimate, so measure your own build output.

For a single typewriter line, I’d take Typed.js or twenty lines of vanilla JavaScript over loading a full animation library.

If you want class-based effects with no animation code at all, a ready-made CSS animations library such as Animate.css pairs well with a splitter like Splitting.js.

How to build a typewriter effect in vanilla JavaScript

Keep the full text in the DOM, reveal it character by character from a timed loop, and cancel the loop on teardown. The steps below get it working.

  1. Write the final text in the markup, then split it into character spans with Intl.Segmenter so emoji and accented letters stay whole.
  2. Set every span to opacity 0. The text keeps its final size, so the paragraph never reflows while it types.
  3. Start a requestAnimationFrame loop. On each frame, work out how many characters should be visible from the elapsed time, then set those spans to opacity 1.
  4. Mark the newest visible span with a caret (a CSS border or pseudo-element) and let it blink once the loop finishes.
  5. Cancel the frame request when the element leaves the DOM, and show the full text at once when the user prefers reduced motion.

Computing the count from elapsed time matters. requestAnimationFrame callbacks pause in background tabs, so a loop that adds one character per tick stalls, while an elapsed-time loop catches up the moment the tab returns.

A human rhythm needs a precomputed schedule of reveal times, because a per-tick random delay does not fit an elapsed-time loop.

Appending text with textContent also works, but every new character reflows the line and pushes anything below it. The DOM also holds only part of the sentence until the loop ends.

How do you trigger text animation on scroll, hover or page load?

Start the animation from the event that matches where the text sits. Load events suit headlines already in view, IntersectionObserver suits text further down, and mouse or focus events suit interactive labels.

For page load, start on DOMContentLoaded, because the window load event waits for every image to finish first. For viewport entry, an IntersectionObserver callback starts the effect, then calls unobserve on the element so it runs once.

Scroll-linked animation is GSAP ScrollTrigger’s territory. It ties progress to scroll distance through its scrub option, with start and end positions you set. Hover and focus are the easy ones: mouseenter and focus start the effect, mouseleave and blur reset it.

Text above the fold should animate on load. Waiting for a scroll event leaves it static on the first screen.

IntersectionObserver has worked in every major browser since March 2019 and is Baseline Widely available, according to MDN, so it needs no polyfill.

The threshold option takes a ratio from 0 to 1 for how much of the element must be visible, and rootMargin grows or shrinks the root box on each side. A threshold of 0.2 plus a negative bottom margin delays the start until the text sits clearly inside the viewport.

Text inside a collapsed accordion or hidden tab has no box to intersect, so the observer never fires. Touch screens have no hover, so a hover-only effect needs a tap or viewport-entry fallback.

And hide the initial state with a class that script adds, not in plain CSS. Otherwise visitors with JavaScript off see an empty heading.

How do you keep text animation fast and free of layout shift?

Frame time decides it. Google’s web.dev puts the budget at about 16.7 ms per frame for 60 fps, and past that the frame rate drops and the motion judders.

Split text multiplies the work, because every character span is its own animated box.

Transform and opacity changes can run on the compositor thread, away from the main thread. Color and background-color changes repaint the element. Anything that touches font-size, letter-spacing, width or margin recomputes geometry, and paint and composite then run again (web.dev).

Animated elements work best on their own compositor layer, but web.dev warns that every layer costs memory and management, and that on devices with limited memory the cost can outweigh the benefit. A per-character effect on a long paragraph multiplies those layers.

No published threshold says how many spans is too many. Test on a mid-range phone, not on the machine you built it on.

Layout shift is the other cost. Cumulative Layout Shift counts unexpected movement, and web.dev defines a good score as 0.1 or less at the 75th percentile of page loads.

Typed or rotating text with no reserved space causes it. So does animating width or height instead of using transform: scale(), and so does a late font swap that changes line breaks after the split.

Reserve space with min-height or min-width on the container, and pre-place the characters as in the typewriter build above.

The Chrome DevTools Performance panel shows both problems. Record the animation, then look for frames longer than 16.7 ms and for flagged layout shifts.

How do you make animated text accessible?

Check the reduced-motion setting before animating, keep one readable copy of the text for screen readers, and give looping effects a pause control.

Reduced motion

The prefers-reduced-motion media query is Baseline Widely available since July 2022 and has worked in every major browser since January 2020 (MDN, web-features data).

Its value is reduce, not none. Users expect non-essential motion to go, so the safe version of a typewriter is the finished text shown at once, or a short fade.

Screen readers

Wrapped letters get read one by one. In 2020, Adrian Roselli found the Awwwards site putting every letter in its own div, which screen readers announced letter by letter.

The usual fix goes through ARIA, with aria-label on the parent and aria-hidden on each span. GSAP’s SplitText does this by default, and its documentation says the setting works for the majority of screen readers.

Roselli’s February 2026 tests disagree. His results table lists the text announced correctly in only 2 of 8 screen reader and browser pairings, and JAWS with Chrome never announced it.

The sources diverge on one detail. According to Roselli, GSAP’s demo wraps text in a div, a generic element that prohibits aria-label, while the docs’ example code uses headings, which allow it.

GSAP’s Cassie Evans replied that she would review the docs. She also named two other routes, a screen-reader-only duplicate of the text and reverting the split once the animation ends.

For one-time reveals, reverting is the sturdiest option, because the accessible copy is then the real text and nothing depends on aria support. Looping effects need the duplicate.

WCAG 2.2 requirements

Under the standards for web accessibility, 2.2.2 Pause, Stop, Hide (Level A) requires a control for moving content that starts automatically, lasts more than five seconds and sits beside other content (W3C).

Rotating words and endless typewriter loops fall under it. A reveal that finishes within five seconds does not.

2.3.3 Animation from Interactions (Level AAA) covers motion triggered by hover or scroll, which users must be able to disable unless the motion carries the content itself. W3C’s guidance lists honoring the reduced-motion preference as one way to meet it.

When does text animation break or hurt the page?

Per-letter splitting breaks joined scripts. Arabic letters change shape depending on their neighbors, so one span per letter leaves disconnected letterforms, according to the Framer marketplace documentation for a shape-safe animation component. Persian, Urdu and Syriac are joined the same way.

The same page says per-letter spans also drop Latin ligatures such as fi and ffl. For these scripts, split by word or line, or animate a mask over unsplit text.

Kerning goes too. Letter-level inline-block spans stop the browser from kerning across glyph pairs, so tight pairs loosen and the line visibly shifts at the moment of the split.

Griffo’s splitText documentation calls this visual jumping and measures character positions before splitting to apply margin corrections. Word-level splits keep kerning inside each word.

Animate the headline, not the paragraph under it. A reader scanning for an answer waits on every character that has not appeared yet.

Low-power hardware is the last problem. MDN notes that reducing animation also helps people on low battery or low-end devices, and a per-character effect that runs smoothly on a desktop is the first thing to cut on a budget phone.

Leave the effect off error messages, prices and legal notices. Navigation labels and anything else visitors read on every visit don’t need it either, and neither does any spot where a plain fade does the same job.

JavaScript Text Animation FAQ

Does animated text hurt indexing?

Not when the final text ships in the page’s HTML. Google indexes from rendered HTML after running scripts (Google Search Central), so text built only by script waits for that rendering step.

Put the real text in the markup and animate on top of it.

How do you animate text in React?

Run the animation inside an effect or hook, never during render, and clean it up on unmount. GSAP’s SplitText integrates with the useGSAP hook, and Motion ships React components through its motion/react entry point.

What stagger delay works for a text reveal?

No official standard exists. Pick the total reveal time first, then divide by the number of units: one second across 40 characters means 25 ms per character.

On longer lines, switch from characters to words so the reveal stays quick.

How can you pause, loop or restart a text animation?

Web Animations API objects expose play, pause and reverse methods plus a finished promise, and GSAP timelines add pause() and restart(). Looping is an iteration option in the first case and a repeat value of -1 in the second.

Are Lettering.js and Textillate.js still worth using?

Only on a site that already loads jQuery. Both are jQuery plugins, so anywhere else they add a dependency for work that Splitting.js or a few lines of native script handle.

Choosing a First Effect and Testing It Before Launch

A first JavaScript text animation should be a one-time reveal on a single headline, split by word and driven by CSS with script-set delays.

The trade-off is flair. That scope rules out per-letter flourishes and endless loops.

Build the reveal and the reduced-motion fallback together, run a screen reader pass before adding polish, and profile frames and layout shift on a mid-range phone last.

The order follows cost of change. Splitting structure and the fallback shape every later step, while profiling only tunes them.

Verified October 28, 2026: the advice to revert the split after the effect holds until GSAP publishes the screen reader results table its team floated in reply to Roselli’s February 2026 post.

Before launch, run the finished page through a web accessibility checklist.