CSS Text Animations: 17 Examples You Can Copy Today

Animated text is everywhere. HTTP Archive's 2020 Web Almanac found the animation property on 73% of mobile pages, while only 24% checked prefers-reduced-motion.

Below are 17 CSS text animations you can try and copy, followed by a guide to how they work, what they cost and how to keep them accessible.

By Bogdan Sandu · Updated October 26, 2026

Entrance and reveal animations

Text that animates in once. Each demo plays when it scrolls into view and has a Replay button, and the script only hides the text right before it plays, so the copy stays readable if JavaScript or motion is off.

Typewriter with a blinking caret

  • steps()
  • ch units
  • finite caret blink

$ ship deploy --prod

Uploaded 14 pages in 2.3 s

Deployed to production

Plays when it scrolls into view. Use Replay to run it again.

The command is 20 characters in a monospace font, so the line animates its width from 0 to 20ch in steps(20) and every step reveals exactly one character. The caret is a right border that blinks 8 times at 0.6 s and then stops, which keeps it inside the five-second limit.

View code
.ta-type {
  width: min(100%, 360px);
  padding-bottom: 18px;
  overflow: hidden;
  border-radius: 14px;
  background: #15171d;
  color: #e6e8ee;
  font: 15px/1.6 ui-monospace, Consolas, monospace;
  box-shadow: 0 18px 40px rgba(21, 23, 29, .22);
}
.ta-type__bar {
  display: flex;
  align-items: center;
  gap: 6px;
  padding: 10px 14px;
  background: #20232c;
  color: #9aa1b1;
  font-size: 12px;
}
.ta-type__bar i {
  width: 10px;
  height: 10px;
  border-radius: 50%;
  background: #3b404d;
}
.ta-type__bar span {
  margin-left: auto;
}
.ta-type__cmd,
.ta-type__out {
  margin: 0;
  padding: 0 18px;
}
.ta-type__cmd {
  padding-top: 16px;
  color: #f6f7fa;
}
.ta-type__line {
  display: inline-block;
  box-sizing: content-box;
  width: 20ch;
  overflow: hidden;
  border-right: 2px solid transparent;
  white-space: nowrap;
  vertical-align: bottom;
}
.ta-type__out {
  margin-top: 4px;
  color: #a3abbb;
}
.ta-type__out--ok {
  color: #7fe0a6;
}
.ta-type__replay {
  min-height: 32px;
  margin: 16px 18px 0;
  padding: 0 14px;
  border: 1px solid #454b5a;
  border-radius: 999px;
  background: transparent;
  color: #e6e8ee;
  font: inherit;
  font-size: 13px;
  cursor: pointer;
}
.ta-type__replay:hover {
  background: #262a35;
}
.ta-type__replay:focus-visible {
  outline: 2px solid #7fe0a6;
  outline-offset: 2px;
}
@media (prefers-reduced-motion: no-preference) {
  .ta-type.ta-js:not(.is-play) .ta-type__line {
    width: 0;
  }
  .ta-type.ta-js:not(.is-play) .ta-type__out {
    opacity: 0;
  }
  .ta-type.is-play .ta-type__line {
    animation: ta-typing 1.3s steps(20) both, ta-caret .6s step-end 8;
  }
  .ta-type.is-play .ta-type__out {
    animation: ta-type-in .3s ease-out 1.5s both;
  }
  .ta-type.is-play .ta-type__out--ok {
    animation-delay: 1.9s;
  }
}
@media (prefers-reduced-motion: reduce) {
  .ta-type__replay {
    display: none;
  }
}
@keyframes ta-typing {
  from { width: 0; }
}
@keyframes ta-caret {
  0%, 100% { border-right-color: #f6f7fa; }
  50% { border-right-color: transparent; }
}
@keyframes ta-type-in {
  from { opacity: 0; transform: translateY(4px); }
}

Staggered letter rise

  • custom property index
  • calc() delay
  • aria-label

Seasonal produce boxes, packed the morning they ship.

Plays when it scrolls into view. Use Replay to run it again.

Each letter is an inline-block span with its index in --ta-i, and animation-delay is calc(var(--ta-i) * 45ms). The heading carries the whole word in aria-label and the letters are aria-hidden, so a screen reader announces one word instead of seven letters.

View code
.ta-rise {
  display: grid;
  justify-items: center;
  gap: 10px;
  text-align: center;
  color: #1f3a2c;
}
.ta-rise__word {
  margin: 0;
  font-family: "Fraunces", Georgia, serif;
  font-size: clamp(60px, 18vw, 104px);
  font-weight: 700;
  line-height: 1;
  letter-spacing: -.02em;
}
.ta-rise__word span {
  display: inline-block;
}
.ta-rise__sub {
  max-width: 30ch;
  margin: 0;
  color: #47584d;
  font-size: 16px;
  line-height: 1.5;
}
.ta-rise__replay {
  min-height: 32px;
  margin-top: 6px;
  padding: 0 16px;
  border: 1px solid #1f3a2c;
  border-radius: 999px;
  background: transparent;
  color: #1f3a2c;
  font: inherit;
  font-size: 14px;
  cursor: pointer;
}
.ta-rise__replay:hover {
  background: #e3eadf;
}
.ta-rise__replay:focus-visible {
  outline: 2px solid #1f3a2c;
  outline-offset: 2px;
}
@media (prefers-reduced-motion: no-preference) {
  .ta-rise.ta-js:not(.is-play) .ta-rise__word span,
  .ta-rise.ta-js:not(.is-play) .ta-rise__sub {
    opacity: 0;
  }
  .ta-rise.is-play .ta-rise__word span {
    animation: ta-rise .55s cubic-bezier(.2, .8, .2, 1) both;
    animation-delay: calc(var(--ta-i) * 45ms);
  }
  .ta-rise.is-play .ta-rise__sub {
    animation: ta-rise-fade .5s ease-out .35s both;
  }
}
@media (prefers-reduced-motion: reduce) {
  .ta-rise__replay {
    display: none;
  }
}
@keyframes ta-rise {
  from { opacity: 0; transform: translateY(.45em) rotate(8deg); }
}
@keyframes ta-rise-fade {
  from { opacity: 0; transform: translateY(6px); }
}

Clip-path wipe behind a sliding bar

  • clip-path: inset()
  • transform-origin swap
  • per-line delay

The nightmarket is open

Fridays from 6 pm on Pier 9, forty stalls and a brass band.

Plays when it scrolls into view. Use Replay to run it again.

A coral bar grows across each line, then shrinks toward the right while the text underneath opens with clip-path: inset(), so the letters appear exactly where the bar leaves. The bar uses transform only, and the clip runs once per line, which keeps the paint cost small.

View code
.ta-wipe {
  width: min(100%, 460px);
  color: #f6efe4;
}
.ta-wipe__title {
  margin: 0 0 18px;
  font-family: "Fraunces", Georgia, serif;
  font-size: clamp(38px, 11vw, 60px);
  font-weight: 700;
  line-height: 1.05;
}
.ta-wipe__line {
  position: relative;
  display: block;
  width: fit-content;
}
.ta-wipe__line > span {
  display: block;
}
.ta-wipe__line::after {
  content: "";
  position: absolute;
  inset: 0 -4px;
  background: #ff7a59;
  transform: scaleX(0);
  transform-origin: left;
}
.ta-wipe__meta {
  max-width: 34ch;
  margin: 0;
  color: #c9c0b3;
  font-size: 16px;
  line-height: 1.5;
}
.ta-wipe__replay {
  min-height: 32px;
  margin-top: 18px;
  padding: 0 16px;
  border: 1px solid #ff7a59;
  border-radius: 999px;
  background: transparent;
  color: #f6efe4;
  font: inherit;
  font-size: 14px;
  cursor: pointer;
}
.ta-wipe__replay:hover {
  background: #2a1d18;
}
.ta-wipe__replay:focus-visible {
  outline: 2px solid #ff7a59;
  outline-offset: 2px;
}
@media (prefers-reduced-motion: no-preference) {
  .ta-wipe.ta-js:not(.is-play) .ta-wipe__line > span {
    clip-path: inset(0 100% 0 0);
  }
  .ta-wipe.ta-js:not(.is-play) .ta-wipe__meta {
    opacity: 0;
  }
  .ta-wipe.is-play .ta-wipe__line::after {
    animation: ta-wipe-bar 1s cubic-bezier(.7, 0, .3, 1) both;
    animation-delay: calc(var(--ta-i) * 160ms);
  }
  .ta-wipe.is-play .ta-wipe__line > span {
    animation: ta-wipe-text 1s cubic-bezier(.7, 0, .3, 1) both;
    animation-delay: calc(var(--ta-i) * 160ms);
  }
  .ta-wipe.is-play .ta-wipe__meta {
    animation: ta-wipe-fade .5s ease-out .9s both;
  }
}
@media (prefers-reduced-motion: reduce) {
  .ta-wipe__replay {
    display: none;
  }
}
@keyframes ta-wipe-bar {
  0% { transform: scaleX(0); transform-origin: left; }
  50% { transform: scaleX(1); transform-origin: left; }
  50.1% { transform: scaleX(1); transform-origin: right; }
  100% { transform: scaleX(0); transform-origin: right; }
}
@keyframes ta-wipe-text {
  0%, 50% { clip-path: inset(0 100% 0 0); }
  50.1% { clip-path: inset(0 0 0 0); }
  100% { clip-path: inset(0 0 0 0); }
}
@keyframes ta-wipe-fade {
  from { opacity: 0; }
}

Masked lines that slide up

  • overflow: hidden mask
  • translateY(110%)
  • descender padding
Old brick and white houses along a canal, with two small boats moored by a wooden jetty

Slow morningsby the canal

Travel journal, 6 min read

Plays when it scrolls into view. Use Replay to run it again.

Each headline line sits in its own overflow: hidden box, and the inner span slides up from below the mask. Only transform animates, so this is the cheapest reveal in the set. The mask has a little bottom padding so the descenders on g and y are not cut off.

View code
.ta-mask {
  width: min(100%, 340px);
  overflow: hidden;
  border-radius: 18px;
  background: #fffdf8;
  box-shadow: 0 16px 36px rgba(48, 36, 20, .14);
}
.ta-mask img {
  display: block;
  width: 100%;
  height: auto;
  aspect-ratio: 16 / 10;
  object-fit: cover;
}
.ta-mask__body {
  padding: 18px 22px 22px;
}
.ta-mask__title {
  margin: 0 0 8px;
  color: #2b2118;
  font-family: "Fraunces", Georgia, serif;
  font-size: 32px;
  font-style: italic;
  font-weight: 600;
  line-height: 1.1;
}
.ta-mask__line {
  display: block;
  overflow: hidden;
  padding-bottom: .1em;
  margin-bottom: -.1em;
}
.ta-mask__line > span {
  display: block;
}
.ta-mask__meta {
  margin: 0;
  color: #6b5a48;
  font-size: 14px;
}
.ta-mask__replay {
  min-height: 32px;
  margin-top: 14px;
  padding: 0 16px;
  border: 1px solid #2b2118;
  border-radius: 999px;
  background: transparent;
  color: #2b2118;
  font: inherit;
  font-size: 14px;
  cursor: pointer;
}
.ta-mask__replay:hover {
  background: #f3ece0;
}
.ta-mask__replay:focus-visible {
  outline: 2px solid #2b2118;
  outline-offset: 2px;
}
@media (prefers-reduced-motion: no-preference) {
  .ta-mask.ta-js:not(.is-play) .ta-mask__line > span {
    transform: translateY(110%);
  }
  .ta-mask.ta-js:not(.is-play) .ta-mask__meta {
    opacity: 0;
  }
  .ta-mask.is-play .ta-mask__line > span {
    animation: ta-mask-up .7s cubic-bezier(.2, .8, .2, 1) both;
    animation-delay: calc(var(--ta-i) * 110ms);
  }
  .ta-mask.is-play .ta-mask__meta {
    animation: ta-mask-fade .4s ease-out .45s both;
  }
}
@media (prefers-reduced-motion: reduce) {
  .ta-mask__replay {
    display: none;
  }
}
@keyframes ta-mask-up {
  from { transform: translateY(110%); }
}
@keyframes ta-mask-fade {
  from { opacity: 0; }
}

Blur-in focus pull

  • filter: blur()
  • word stagger
  • scale

Less noise. More signal.

Focus mode mutes every channel except the three you pin.

Plays when it scrolls into view. Use Replay to run it again.

Words start blurred, slightly enlarged and transparent, then settle into focus one after another, like a camera pulling focus. Splitting by word instead of by letter keeps kerning intact and lets screen readers read the sentence normally. Blur is a paint effect, so keep it to a short headline.

View code
.ta-blur {
  width: min(100%, 420px);
  padding: 32px 28px;
  border-radius: 20px;
  background: #1c2541;
  color: #f2f4ff;
  text-align: center;
}
.ta-blur__title {
  margin: 0 0 12px;
  font-size: clamp(34px, 10vw, 48px);
  font-weight: 700;
  line-height: 1.1;
  letter-spacing: -.02em;
}
.ta-blur__title span {
  display: inline-block;
}
.ta-blur__sub {
  max-width: 30ch;
  margin: 0 auto;
  color: #c3c9e4;
  font-size: 16px;
  line-height: 1.5;
}
.ta-blur__replay {
  min-height: 32px;
  margin-top: 18px;
  padding: 0 16px;
  border: 1px solid #8f9bd6;
  border-radius: 999px;
  background: transparent;
  color: #f2f4ff;
  font: inherit;
  font-size: 14px;
  cursor: pointer;
}
.ta-blur__replay:hover {
  background: #26315a;
}
.ta-blur__replay:focus-visible {
  outline: 2px solid #b9c3ff;
  outline-offset: 2px;
}
@media (prefers-reduced-motion: no-preference) {
  .ta-blur.ta-js:not(.is-play) .ta-blur__title span,
  .ta-blur.ta-js:not(.is-play) .ta-blur__sub {
    opacity: 0;
  }
  .ta-blur.is-play .ta-blur__title span {
    animation: ta-blur-in .8s cubic-bezier(.2, .7, .2, 1) both;
    animation-delay: calc(var(--ta-i) * 120ms);
  }
  .ta-blur.is-play .ta-blur__sub {
    animation: ta-blur-in .6s ease-out .5s both;
  }
}
@media (prefers-reduced-motion: reduce) {
  .ta-blur__replay {
    display: none;
  }
}
@keyframes ta-blur-in {
  from { opacity: 0; filter: blur(10px); transform: scale(1.08); }
}

Looping text with a way to stop it

Rotating words, a shimmer, a ticker and a breathing cue. Anything that loops either ends within five seconds, waits for a click to start, or comes with a visible pause button, which is what WCAG 2.2.2 asks for.

Rotating words in a headline

  • overflow mask
  • keyframe holds
  • animation-play-state

Payroll that works for freelancers, studios and agencies

Run payslips, taxes and contractor invoices from one ledger.

Use Pause animation to stop the loop.

The three audiences sit in a vertical list inside a one-line mask, with the first word repeated at the end so the loop restarts without a jump. Keyframes hold each word for about two seconds. Screen readers get one plain sentence from a visually hidden span, and the pause button stops the loop, as WCAG 2.2.2 requires for motion that runs longer than five seconds.

View code
.ta-rot {
  width: min(100%, 440px);
  color: #1b1640;
}
.ta-rot__title {
  margin: 0 0 12px;
  font-family: "Fraunces", Georgia, serif;
  font-size: clamp(30px, 8.4vw, 42px);
  font-weight: 600;
  line-height: 1.2;
}
.ta-rot__sr {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}
.ta-rot__slot {
  display: inline-block;
  height: 1.2em;
  overflow: hidden;
  color: #5b34e0;
  vertical-align: bottom;
}
.ta-rot__list {
  display: flex;
  flex-direction: column;
}
.ta-rot__list span {
  display: block;
  height: 1.2em;
  text-decoration: underline;
  text-decoration-thickness: .08em;
  text-underline-offset: .14em;
}
.ta-rot__sub {
  margin: 0 0 18px;
  color: #4a4668;
  font-size: 16px;
  line-height: 1.5;
}
.ta-rot__toggle {
  min-height: 36px;
  padding: 0 16px;
  border: 1px solid #1b1640;
  border-radius: 999px;
  background: #fff;
  color: #1b1640;
  font: inherit;
  font-size: 14px;
  cursor: pointer;
}
.ta-rot__toggle[aria-pressed="true"] {
  background: #1b1640;
  color: #fff;
}
.ta-rot__toggle:focus-visible {
  outline: 2px solid #5b34e0;
  outline-offset: 2px;
}
@media (prefers-reduced-motion: no-preference) {
  .ta-rot__list {
    animation: ta-rot 7.5s cubic-bezier(.7, 0, .3, 1) infinite;
  }
  .ta-rot.is-paused .ta-rot__list {
    animation-play-state: paused;
  }
}
@media (prefers-reduced-motion: reduce) {
  .ta-rot__toggle {
    display: none;
  }
}
@keyframes ta-rot {
  0%, 26% { transform: translateY(0); }
  33%, 59% { transform: translateY(-25%); }
  66%, 92% { transform: translateY(-50%); }
  100% { transform: translateY(-75%); }
}

Shimmer on a status line

  • background-clip: text
  • @supports
  • finite iterations

Generating your quarterly report

Step 2 of 3: matching 1,284 invoices

Plays three times when it scrolls into view. Use Replay to run it again.

A light band inside a solid-color background sweeps across the letters through background-clip: text. This is the one demo in the set where a gradient is the technique itself. The sweep runs three times in 4.8 seconds and stops, and browsers without background-clip: text keep plain readable text because the clip sits inside @supports.

View code
.ta-shim {
  width: min(100%, 380px);
  padding: 24px;
  border-radius: 16px;
  background: #10141f;
  color: #aab2c3;
}
.ta-shim__text {
  margin: 0 0 6px;
  color: #aab2c3;
  font-size: 19px;
  font-weight: 700;
}
.ta-shim__step {
  margin: 0 0 18px;
  color: #8d96aa;
  font-size: 14px;
}
.ta-shim__rows {
  display: grid;
  gap: 8px;
}
.ta-shim__rows i {
  height: 10px;
  border-radius: 5px;
  background: #1f2636;
}
.ta-shim__rows i:nth-child(2) {
  width: 82%;
}
.ta-shim__rows i:nth-child(3) {
  width: 64%;
}
.ta-shim__replay {
  min-height: 32px;
  margin-top: 18px;
  padding: 0 16px;
  border: 1px solid #39425a;
  border-radius: 999px;
  background: transparent;
  color: #e4e8f1;
  font: inherit;
  font-size: 14px;
  cursor: pointer;
}
.ta-shim__replay:hover {
  background: #1a2030;
}
.ta-shim__replay:focus-visible {
  outline: 2px solid #e4e8f1;
  outline-offset: 2px;
}
@media (prefers-reduced-motion: no-preference) {
  @supports (background-clip: text) or (-webkit-background-clip: text) {
    .ta-shim.is-play .ta-shim__text {
      background: linear-gradient(100deg, #aab2c3 0 42%, #ffffff 50%, #aab2c3 58% 100%) 100% 0 / 300% 100% no-repeat;
      -webkit-background-clip: text;
      background-clip: text;
      color: transparent;
      animation: ta-shim 1.6s linear 3 both;
    }
  }
}
@media (prefers-reduced-motion: reduce) {
  .ta-shim__replay {
    display: none;
  }
}
@keyframes ta-shim {
  from { background-position: 100% 0; }
  to { background-position: 0 0; }
}

Announcement ticker with a pause button

  • two-track loop
  • animation-play-state
  • :focus-within
  • Free returns within 30 days
  • Autumn knitwear is in
  • Studio open Saturdays, 10 to 6
  • Gift cards from 20 euros

Hover the bar or use Pause ticker to stop it.

Two copies of the list sit side by side and both translate by their own width, so the second copy takes the place of the first and the loop never shows a gap. The copy is aria-hidden. The bar pauses on hover, on keyboard focus and through a visible button, and without motion it falls back to wrapped static text.

View code
.ta-tick {
  display: flex;
  align-items: center;
  width: 100%;
  background: #141414;
  color: #f4efe6;
  font-size: 14px;
  font-weight: 500;
  letter-spacing: .04em;
  text-transform: uppercase;
}
.ta-tick__view {
  display: flex;
  flex: 1;
  min-width: 0;
  overflow: hidden;
}
.ta-tick__track {
  display: flex;
  flex-wrap: wrap;
  gap: 4px 0;
  margin: 0;
  padding: 12px 16px;
  list-style: none;
}
.ta-tick__track li {
  padding-right: 18px;
}
.ta-tick__track li::after {
  content: "/";
  padding-left: 18px;
  color: #c9a96e;
}
.ta-tick__track--copy {
  display: none;
}
.ta-tick__toggle {
  flex: none;
  min-height: 44px;
  margin: 0;
  padding: 0 16px;
  border: 0;
  border-left: 1px solid #3a3a3a;
  background: #141414;
  color: #f4efe6;
  font: inherit;
  font-size: 12px;
  letter-spacing: .06em;
  text-transform: uppercase;
  cursor: pointer;
}
.ta-tick__toggle[aria-pressed="true"] {
  background: #c9a96e;
  color: #141414;
}
.ta-tick__toggle:focus-visible {
  outline: 2px solid #c9a96e;
  outline-offset: -4px;
}
.ta-tick__page {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 18px;
  padding: 22px 20px 120px;
  color: #3b3b3b;
  font-size: 15px;
}
.ta-tick__page b {
  margin-right: auto;
  color: #141414;
  font-family: "Fraunces", Georgia, serif;
  font-size: 22px;
}
@media (prefers-reduced-motion: no-preference) {
  .ta-tick__track {
    flex: none;
    flex-wrap: nowrap;
    padding: 12px 0;
    white-space: nowrap;
    animation: ta-tick 22s linear infinite;
  }
  .ta-tick__track--copy {
    display: flex;
  }
  .ta-tick__view:hover .ta-tick__track,
  .ta-tick:focus-within .ta-tick__track,
  .ta-tick.is-paused .ta-tick__track {
    animation-play-state: paused;
  }
}
@media (prefers-reduced-motion: reduce) {
  .ta-tick__toggle {
    display: none;
  }
}
@keyframes ta-tick {
  to { transform: translateX(-100%); }
}

Breathing letter-spacing

  • letter-spacing
  • grid stacking
  • click to start

Breathe in

Four seconds in, four seconds out.

Press Start breathing.

A breathing cue for a meditation app. The words widen their letter-spacing as the ring grows and tighten as it shrinks, on an eight-second cycle. Letter-spacing is a layout property, so the words sit in a fixed-size box that cannot push anything else around, and the loop only starts when the user presses Start.

View code
.ta-breath {
  display: grid;
  justify-items: center;
  width: min(100%, 340px);
  padding: 26px 20px;
  border-radius: 24px;
  background: #e5eee6;
  color: #1e3b2f;
  text-align: center;
}
.ta-breath__stage {
  display: grid;
  place-items: center;
  width: 100%;
  height: 210px;
}
.ta-breath__ring,
.ta-breath__words {
  grid-area: 1 / 1;
}
.ta-breath__ring {
  width: 210px;
  height: 210px;
  border: 2px solid #8fb39d;
  border-radius: 50%;
  background: #f5f9f5;
  transform: scale(.82);
}
.ta-breath__words {
  position: relative;
  z-index: 1;
  display: grid;
  width: 100%;
  margin: 0;
  font-family: "Fraunces", Georgia, serif;
  font-size: 26px;
  font-weight: 600;
}
.ta-breath__in,
.ta-breath__out {
  grid-area: 1 / 1;
  letter-spacing: .02em;
}
.ta-breath__out {
  opacity: 0;
}
.ta-breath__hint {
  margin: 14px 0 16px;
  color: #3f5a4d;
  font-size: 15px;
}
.ta-breath__toggle {
  min-height: 44px;
  padding: 0 22px;
  border: 0;
  border-radius: 999px;
  background: #1e3b2f;
  color: #f5f9f5;
  font: inherit;
  font-size: 15px;
  font-weight: 500;
  cursor: pointer;
}
.ta-breath__toggle[aria-pressed="true"] {
  background: #fff;
  color: #1e3b2f;
  box-shadow: inset 0 0 0 2px #1e3b2f;
}
.ta-breath__toggle:focus-visible {
  outline: 3px solid #4f8a6b;
  outline-offset: 3px;
}
@media (prefers-reduced-motion: no-preference) {
  .ta-breath.is-on .ta-breath__ring {
    animation: ta-breath-ring 8s ease-in-out infinite;
  }
  .ta-breath.is-on .ta-breath__in {
    animation: ta-breath-in 8s ease-in-out infinite;
  }
  .ta-breath.is-on .ta-breath__out {
    animation: ta-breath-out 8s ease-in-out infinite;
  }
}
@media (prefers-reduced-motion: reduce) {
  .ta-breath__toggle {
    display: none;
  }
}
@keyframes ta-breath-ring {
  0%, 100% { transform: scale(.82); }
  50% { transform: scale(1); }
}
@keyframes ta-breath-in {
  0% { opacity: 1; letter-spacing: .02em; }
  46% { opacity: 1; letter-spacing: .2em; }
  52%, 96% { opacity: 0; letter-spacing: .2em; }
  100% { opacity: 1; letter-spacing: .02em; }
}
@keyframes ta-breath-out {
  0%, 46% { opacity: 0; letter-spacing: .2em; }
  52% { opacity: 1; letter-spacing: .2em; }
  96% { opacity: 1; letter-spacing: .02em; }
  100% { opacity: 0; letter-spacing: .02em; }
}

Hover and focus text effects

Effects that run because someone pointed at or tabbed to the text. All of them trigger on :focus-visible as well as :hover, and the animated copies are hidden from screen readers so names are read once.

Ink fill on hover

  • attr() copy
  • clip-path transition
  • content alt text

Hover or tab to a link.

Each link has a second copy of its label in ::before, painted in ink and clipped to nothing. Hover or focus opens the clip from left to right, so the dark text seems to pour into the grey letters. The copy uses content: attr(data-text) / "" so screen readers that honor the alt syntax do not read the label twice.

View code
.ta-fill {
  width: min(100%, 360px);
  padding: 18px 26px;
  border-radius: 18px;
  background: #fff;
  box-shadow: 0 12px 30px rgba(17, 24, 39, .08);
}
.ta-fill ul {
  margin: 0;
  padding: 0;
  list-style: none;
}
.ta-fill li + li {
  border-top: 1px solid #e7e9ef;
}
.ta-fill a {
  position: relative;
  display: inline-block;
  padding: 6px 0;
  color: #646b78;
  font-family: "Fraunces", Georgia, serif;
  font-size: clamp(34px, 10vw, 44px);
  font-weight: 600;
  line-height: 1.15;
  text-decoration: none;
}
.ta-fill a::before {
  content: attr(data-text);
  content: attr(data-text) / "";
  position: absolute;
  top: 6px;
  left: 0;
  color: #b0321c;
  white-space: nowrap;
  clip-path: inset(0 100% 0 0);
}
.ta-fill a:hover::before,
.ta-fill a:focus-visible::before {
  clip-path: inset(0 0 0 0);
}
.ta-fill a:focus-visible {
  outline: 2px solid #b0321c;
  outline-offset: 4px;
  border-radius: 4px;
}
@media (prefers-reduced-motion: no-preference) {
  .ta-fill a::before {
    transition: clip-path .45s cubic-bezier(.6, 0, .2, 1);
  }
}

Scribbled strike-through

  • SVG pathLength
  • stroke-dashoffset
  • :checked
Launch checklist

Tick an item.

Checking an item draws a hand-drawn SVG line through its text. The path has pathLength="1", so stroke-dasharray: 1 and a dashoffset going from 1 to 0 draw it at any width. The real state lives in the checkbox, which a screen reader announces, and the scribble is decoration.

View code
.ta-todo {
  width: min(100%, 340px);
  min-width: 0;
  margin: 0;
  padding: 18px 22px 12px;
  border: 0;
  border-radius: 16px;
  background: #fffbea;
  box-shadow: 0 10px 28px rgba(80, 64, 10, .1);
}
.ta-todo legend {
  float: left;
  width: 100%;
  margin-bottom: 6px;
  padding: 0;
  color: #3a2f12;
  font-family: "Fraunces", Georgia, serif;
  font-size: 22px;
  font-weight: 700;
}
.ta-todo__item {
  clear: left;
  display: flex;
  align-items: center;
  gap: 12px;
  min-height: 44px;
  color: #2f2a1c;
  font-size: 17px;
  cursor: pointer;
}
.ta-todo__item input {
  flex: none;
  display: grid;
  place-items: center;
  width: 22px;
  height: 22px;
  margin: 0;
  border: 2px solid #3a2f12;
  border-radius: 6px;
  background: #fff;
  appearance: none;
  cursor: pointer;
}
.ta-todo__item input::after {
  content: "";
  width: 6px;
  height: 11px;
  margin-top: -3px;
  border: solid #fff;
  border-width: 0 2.5px 2.5px 0;
  transform: rotate(45deg);
}
.ta-todo__item input:checked {
  border-color: #3a2f12;
  background: #3a2f12;
}
.ta-todo__item input:focus-visible {
  outline: 2px solid #c2410c;
  outline-offset: 2px;
}
.ta-todo__text {
  position: relative;
}
.ta-todo__text svg {
  position: absolute;
  top: 50%;
  left: -3px;
  width: calc(100% + 6px);
  height: .7em;
  overflow: visible;
  transform: translateY(-45%);
  pointer-events: none;
}
.ta-todo__text path {
  fill: none;
  stroke: #c2410c;
  stroke-width: 2.5;
  stroke-linecap: round;
  stroke-dasharray: 1;
  stroke-dashoffset: 1;
  opacity: 0;
}
.ta-todo__item:has(input:checked) .ta-todo__text {
  color: #6b6450;
}
.ta-todo__item input:checked + .ta-todo__text path {
  stroke-dashoffset: 0;
  opacity: 1;
}
@media (prefers-reduced-motion: no-preference) {
  .ta-todo__text path {
    transition: stroke-dashoffset .5s cubic-bezier(.6, 0, .3, 1), opacity 0s .5s;
  }
  .ta-todo__item input:checked + .ta-todo__text path {
    transition: stroke-dashoffset .5s cubic-bezier(.6, 0, .3, 1), opacity 0s;
  }
}

Letter wave on a call to action

  • per-letter delay
  • aria-label on the link
  • :focus-visible

Hover or tab to the button.

Hovering or focusing the button sends a single wave through its letters: each span rises and falls with a delay of 30 ms times its index. The animation is attached to the hover state, so it runs once per hover instead of looping. The link is named by aria-label and the letter spans are aria-hidden.

View code
.ta-wave {
  display: inline-flex;
  align-items: center;
  gap: 12px;
  min-height: 60px;
  padding: 0 28px;
  border-radius: 999px;
  background: #d9f99d;
  color: #14200a;
  font-size: clamp(20px, 6vw, 26px);
  font-weight: 700;
  text-decoration: none;
}
.ta-wave__text span {
  display: inline-block;
}
.ta-wave__gap {
  width: .28em;
}
.ta-wave svg {
  fill: none;
  stroke: currentColor;
  stroke-width: 2.4;
  stroke-linecap: round;
  stroke-linejoin: round;
}
.ta-wave:focus-visible {
  outline: 3px solid #d9f99d;
  outline-offset: 4px;
}
@media (prefers-reduced-motion: no-preference) {
  .ta-wave:hover .ta-wave__text span,
  .ta-wave:focus-visible .ta-wave__text span {
    animation: ta-wave .6s ease-in-out both;
    animation-delay: calc(var(--ta-i, 0) * 30ms);
  }
  .ta-wave svg {
    transition: transform .3s ease;
  }
  .ta-wave:hover svg,
  .ta-wave:focus-visible svg {
    transform: translateX(4px);
  }
}
@keyframes ta-wave {
  0%, 100% { transform: translateY(0); }
  40% { transform: translateY(-.35em); }
  70% { transform: translateY(.06em); }
}

Short glitch on an offline card

  • attr() layers
  • clip-path slices
  • steps(1)

Signal lost

Channel 7 went offline at 21:14. We will reconnect as soon as the stream is back.

Hover the card or tab to the button.

Two copies of the title in ::before and ::after jump between clipped slices in magenta and cyan for two short bursts when the card is hovered or the button gets focus. The slices change about three times per second, which stays at the WCAG 2.3.1 limit for flashes, and the effect stops on its own.

View code
.ta-glitch {
  width: min(100%, 380px);
  padding: 28px 26px;
  border: 1px solid #262b38;
  border-radius: 18px;
  background: #11141c;
  color: #e9ecf3;
}
.ta-glitch__title {
  position: relative;
  margin: 0 0 10px;
  font-family: ui-monospace, Consolas, monospace;
  font-size: clamp(32px, 9vw, 42px);
  font-weight: 700;
  letter-spacing: -.02em;
  text-transform: uppercase;
}
.ta-glitch__title::before,
.ta-glitch__title::after {
  content: attr(data-text);
  content: attr(data-text) / "";
  position: absolute;
  inset: 0;
  opacity: 0;
  pointer-events: none;
}
.ta-glitch__title::before {
  color: #ff4f8b;
}
.ta-glitch__title::after {
  color: #3ddcff;
}
.ta-glitch__text {
  margin: 0 0 20px;
  color: #a9b0c0;
  font-size: 15px;
  line-height: 1.55;
}
.ta-glitch__retry {
  min-height: 44px;
  padding: 0 20px;
  border: 0;
  border-radius: 10px;
  background: #e9ecf3;
  color: #11141c;
  font: inherit;
  font-size: 15px;
  font-weight: 700;
  cursor: pointer;
}
.ta-glitch__retry:hover {
  background: #fff;
}
.ta-glitch__retry:focus-visible {
  outline: 2px solid #3ddcff;
  outline-offset: 3px;
}
@media (prefers-reduced-motion: no-preference) {
  .ta-glitch:hover .ta-glitch__title::before,
  .ta-glitch:focus-within .ta-glitch__title::before {
    animation: ta-glitch-a 1.4s steps(1, end) 2;
  }
  .ta-glitch:hover .ta-glitch__title::after,
  .ta-glitch:focus-within .ta-glitch__title::after {
    animation: ta-glitch-b 1.4s steps(1, end) 2;
  }
}
@keyframes ta-glitch-a {
  0% { opacity: 1; clip-path: inset(8% 0 62% 0); transform: translate(-4px, 0); }
  25% { opacity: 1; clip-path: inset(54% 0 22% 0); transform: translate(3px, 0); }
  50% { opacity: 1; clip-path: inset(28% 0 44% 0); transform: translate(-2px, 0); }
  75% { opacity: 0; }
}
@keyframes ta-glitch-b {
  0% { opacity: 1; clip-path: inset(60% 0 8% 0); transform: translate(4px, 0); }
  25% { opacity: 1; clip-path: inset(18% 0 58% 0); transform: translate(-3px, 0); }
  50% { opacity: 1; clip-path: inset(72% 0 4% 0); transform: translate(2px, 0); }
  75% { opacity: 0; }
}

Numbers, boards and scroll-linked text

Text animations that carry information: a counter driven by a registered custom property, a flip board, a highlighter that marks the key sentence, and a scroll-driven reveal that needs no script at all.

Counting numbers with @property

  • @property <integer>
  • counter()
  • tabular-nums
Projects shipped
128
On-time delivery
97%
Countries served
36

Registering --ta-n as an integer lets the browser interpolate it, and counter-reset plus counter() turns the value into visible text, so the stats count up with no script in the animation. The count is aria-hidden and the real number sits in a visually hidden span. Without @property the final number simply shows.

View code
@property --ta-n {
  syntax: "<integer>";
  inherits: false;
  initial-value: 0;
}
.ta-count {
  width: min(100%, 520px);
  text-align: center;
}
.ta-count__grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
  gap: 12px;
  margin: 0;
}
.ta-count__stat {
  display: flex;
  flex-direction: column-reverse;
  gap: 4px;
  padding: 20px 14px;
  border-radius: 16px;
  background: #fff;
  box-shadow: 0 8px 24px rgba(13, 92, 99, .08);
}
.ta-count__stat dt {
  color: #4b5b5d;
  font-size: 14px;
}
.ta-count__stat dd {
  margin: 0;
}
.ta-count__num {
  --ta-n: var(--ta-to);
  counter-reset: ta-n var(--ta-n);
  color: #0d5c63;
  font-family: "Fraunces", Georgia, serif;
  font-size: 44px;
  font-weight: 700;
  font-variant-numeric: tabular-nums;
  line-height: 1.1;
}
.ta-count__num::before {
  content: counter(ta-n);
}
.ta-count__num--pct::after {
  content: "%";
}
.ta-count__sr {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}
.ta-count__replay {
  min-height: 32px;
  margin-top: 18px;
  padding: 0 16px;
  border: 1px solid #0d5c63;
  border-radius: 999px;
  background: transparent;
  color: #0d5c63;
  font: inherit;
  font-size: 14px;
  cursor: pointer;
}
.ta-count__replay:hover {
  background: #dfeeee;
}
.ta-count__replay:focus-visible {
  outline: 2px solid #0d5c63;
  outline-offset: 2px;
}
@media (prefers-reduced-motion: no-preference) {
  .ta-count.ta-js:not(.is-play) .ta-count__num {
    --ta-n: 0;
  }
  .ta-count.is-play .ta-count__num {
    animation: ta-count 1.1s cubic-bezier(.2, .7, .2, 1) both;
  }
}
@media (prefers-reduced-motion: reduce) {
  .ta-count__replay {
    display: none;
  }
}
@keyframes ta-count {
  from { --ta-n: 0; }
}

Highlighter that draws across lines

  • mark element
  • background-size
  • inline background slicing

Good typography is invisible until it fails. Readers notice a cramped line height long before they notice a good one, and most of them simply leave.

From the house style guide at Fieldnote Press

The key sentence is a mark element with a flat yellow band painted as a background image (a single-color gradient, used only because background-size needs an image). Growing background-size from 0 to 100% draws the band, and because inline backgrounds are sliced across line boxes, it travels line by line like a real marker.

View code
.ta-mark {
  width: min(100%, 440px);
  margin: 0;
  padding: 28px 26px;
  border-left: 4px solid #1d1d1f;
  background: #fff;
}
.ta-mark__quote {
  margin: 0 0 14px;
}
.ta-mark__quote p {
  margin: 0;
  color: #1d1d1f;
  font-family: "Fraunces", Georgia, serif;
  font-size: clamp(21px, 5.6vw, 25px);
  line-height: 1.45;
}
.ta-mark__hl {
  padding: 0 .08em;
  background-color: transparent;
  background-image: linear-gradient(#ffd84d, #ffd84d);
  background-position: 0 88%;
  background-repeat: no-repeat;
  background-size: 100% .5em;
  color: inherit;
}
.ta-mark__cite {
  color: #55565c;
  font-size: 14px;
}
.ta-mark__replay {
  min-height: 32px;
  margin-top: 16px;
  padding: 0 16px;
  border: 1px solid #1d1d1f;
  border-radius: 999px;
  background: transparent;
  color: #1d1d1f;
  font: inherit;
  font-size: 14px;
  cursor: pointer;
}
.ta-mark__replay:hover {
  background: #f4f4f5;
}
.ta-mark__replay:focus-visible {
  outline: 2px solid #1d1d1f;
  outline-offset: 2px;
}
@media (prefers-reduced-motion: no-preference) {
  .ta-mark.ta-js:not(.is-play) .ta-mark__hl {
    background-size: 0 .5em;
  }
  .ta-mark.is-play .ta-mark__hl {
    animation: ta-mark 1.2s cubic-bezier(.6, 0, .3, 1) .2s both;
  }
}
@media (prefers-reduced-motion: reduce) {
  .ta-mark__replay {
    display: none;
  }
}
@keyframes ta-mark {
  from { background-size: 0 .5em; }
}

Split-flap departure board

  • rotateX flip
  • staggered cells
  • aria-live

Lisbon, 14:05, gate B12

Press Next departure.

Every character is its own cell with a hinge line across the middle. When the data changes, only the cells whose letter changed flip in with rotateX from 90 degrees, each a little later than the one before. The cells are aria-hidden and a polite live region announces the whole row once.

View code
.ta-flap {
  display: grid;
  justify-items: center;
  gap: 18px;
  width: min(100%, 560px);
}
.ta-flap__board {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 14px 18px;
  padding: 18px;
  border-radius: 14px;
  background: #1a1c21;
}
.ta-flap__field {
  display: grid;
  gap: 6px;
}
.ta-flap__label {
  color: #a8adb8;
  font-size: 11px;
  letter-spacing: .08em;
  text-transform: uppercase;
}
.ta-flap__cells {
  display: flex;
  gap: 3px;
}
.ta-flap__cells b {
  position: relative;
  display: grid;
  place-items: center;
  width: 28px;
  height: 38px;
  border-radius: 4px;
  background: #2a2d34;
  color: #ffc94a;
  font: 700 22px/1 ui-monospace, Consolas, monospace;
  white-space: pre;
}
.ta-flap__cells b::after {
  content: "";
  position: absolute;
  inset: 50% 0 auto;
  height: 1px;
  background: #121317;
}
.ta-flap__sr {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}
.ta-flap__next {
  min-height: 44px;
  padding: 0 22px;
  border: 0;
  border-radius: 10px;
  background: #1a1c21;
  color: #ffc94a;
  font: inherit;
  font-size: 15px;
  font-weight: 700;
  cursor: pointer;
}
.ta-flap__next:hover {
  background: #2a2d34;
}
.ta-flap__next:focus-visible {
  outline: 3px solid #e0a325;
  outline-offset: 3px;
}
@media (prefers-reduced-motion: no-preference) {
  .ta-flap__cells b.is-flip {
    animation: ta-flap .42s cubic-bezier(.3, 1.4, .5, 1) both;
    animation-delay: calc(var(--ta-i, 0) * 28ms);
  }
}
@keyframes ta-flap {
  from { transform: perspective(160px) rotateX(90deg); filter: brightness(.6); }
}

Scroll-driven paragraph reveal

  • animation-timeline: view()
  • animation-range
  • @supports

On quiet interfaces

The best reading apps share one habit. Nothing on the page moves while your eyes are on the words.

Motion belongs at the edges: a paragraph settling in as it arrives, a heading that finds its place, then stillness.

Tie that motion to the scrollbar and the reader stays in charge. Stop scrolling and the page stops with you.

Scroll back up and every paragraph is already finished. No replay, no waiting, no surprise.

That is the whole trick. The effect only ever answers something the reader just did.

Scroll inside the box. Needs a browser with scroll-driven animations.

Each paragraph runs its fade and lift against a view() timeline, so progress follows its position in the scroll box instead of the clock. animation-range: entry limits the effect to the moment a paragraph enters, and everything sits inside @supports (animation-timeline: view()), so browsers without scroll-driven animations show plain text.

View code
.ta-scroll {
  width: min(100%, 420px);
  height: 300px;
  overflow-y: auto;
  padding: 26px 26px 80px;
  border-radius: 18px;
  background: #fff;
  color: #22252c;
  box-shadow: 0 12px 30px rgba(17, 24, 39, .08);
  overscroll-behavior: contain;
}
.ta-scroll:focus-visible {
  outline: 2px solid #2546d6;
  outline-offset: 3px;
}
.ta-scroll__title {
  margin: 0 0 14px;
  font-family: "Fraunces", Georgia, serif;
  font-size: 28px;
  font-weight: 700;
}
.ta-scroll__p {
  margin: 0 0 22px;
  font-family: "Fraunces", Georgia, serif;
  font-size: 20px;
  line-height: 1.5;
}
@media (prefers-reduced-motion: no-preference) {
  @supports (animation-timeline: view()) {
    .ta-scroll__p {
      animation: ta-scroll-in linear both;
      animation-timeline: view();
      animation-range: entry 0% entry 100%;
    }
  }
}
@keyframes ta-scroll-in {
  from { opacity: .12; transform: translateY(18px); }
}
10 CSS Text Animations You Can Copy Today · WPDean

How do CSS text animations work?

Stylesheet rules alone can move, fade, recolor or clip text over time, and no JavaScript animation loop is involved.

A keyframes rule describes the states the text passes through. The animation property then attaches that rule to an element.

According to MDN, the animation shorthand bundles name, duration, timing function, delay, iteration count, direction, fill mode and play state. Text effects lean hardest on a handful of those.

Timing function sets the feel. cubic-bezier() curves give smooth motion, and steps() gives the frame-by-frame jumps a typewriter needs. Delay offsets the start, which is how staggered text works. Loops like shimmer and marquee use an iteration count of infinite, while one-offs get a fixed number.

Alternate direction plays forward and then back, so it suits a pulsing glow. Fill mode set to both holds the first keyframe during the delay and the last one afterward, which means text never flashes visible or snaps back.

Transitions cover the simplest case. They run between two states when something changes, such as :hover or a class toggle, and reverse on their own.

Keyframe animations start without a state change, hold any number of steps and can loop. Once text needs more than a start and an end, keyframes are the only option.

CSS has no selector for individual characters (only ::first-letter and ::first-line), so per-letter effects need every character wrapped in its own span. That limit shapes most of what follows.

Which text properties can you animate?

Any property with a numeric or color value can be animated. For smooth text motion, though, web.dev’s guidance from the Chrome team points to transform and opacity, because browsers can keep those on the compositing stage.

PropertyWhat it does to textCost classBest use
transform (translate, scale, rotate)Moves, resizes or tilts textCompositorSlide-ins, wave, marquee
opacityFades text in or outCompositorFade-ins
color, text-shadow, background-positionRecolors, adds glow, shifts a gradientPaintGlow, shimmer
letter-spacing, width, font-sizeReflows the surrounding textLayoutTypewriter, hover tracking
clip-pathCuts away part of the text boxProfile firstWipe reveals
font-weight (variable fonts)Thickens or thins glyphsLayoutHover and breathing effects

Glow pulses animate the blur radius or color of text-shadow. A CSS text shadow generator speeds up building the start and end values.

Gradients are the odd case. A gradient is an image value, so its color stops do not interpolate, and a plain custom property fails too because the browser reads it as a string.

Registering the property with @property fixes that. The rule declares a type and an initial value, and says whether the value inherits. After that the browser can animate it, and MDN lists it as Baseline 2024 (newly available).

The older workaround skips registration. Paint an oversized gradient and animate background-position. Most shimmer effects are built that way.

CSS text animation effects and how each is built

The table compares typewriter, fade, slide, shimmer, reveal, glitch, wave and marquee. The cost and fallback columns are the ones worth reading first.

EffectCore propertiesCost classMain failure modeReduced-motion fallback
Typewriterwidth, steps(), border caretLayoutFixed character count, wraps breakShow full text at once
Fade and slide inopacity, transformCompositorBase opacity 0 hides text if the animation failsKeep the fade, drop the slide
Gradient shimmerbackground-position, background-clipPaintInvisible text without a fallback colorStatic gradient or solid color
Text revealtransform or clip-pathCompositor or profile firstDescenders get clippedPlain fade
Glitchtext-shadow, pseudo-elements, transformPaintFlicker hurts readabilityStatic text
Wavetransform per characterCompositorSplit spans confuse screen readersStatic text
Marqueetransform (translateX)CompositorEndless loop, no pause controlStatic, wrapped text

For a headline, a fade with a short upward slide beats a glitch or wave in most cases. It costs almost nothing and falls back to plain text without any drama.

Typewriter and blinking cursor

The classic typewriter animates width from 0 to the full text width with steps(), and the step count equals the number of characters.

.typewriter {
  width: 24ch;
  overflow: hidden;
  white-space: nowrap;
  font-family: monospace;
  border-right: 2px solid currentColor;
  animation: typing 2s steps(24) forwards,
  caret 0.6s step-end infinite;
}

@keyframes typing { from { width: 0; } }
@keyframes caret { 50% { border-color: transparent; } }

The caret is just a right border that blinks through its own step-end animation.

You need a known character count and a monospace font, or the ch unit stops matching the text width. Wrapped lines break it too, since white-space: nowrap forces a single line.

Width is a layout property. That is fine for one short headline and a poor choice for paragraphs.

Fade-in and slide-in

These effects pair opacity with translateY or translateX, which keeps them cheap and predictable.

@keyframes rise {
  from { opacity: 0; transform: translateY(0.5em); }
}

.rise { animation: rise 600ms ease-out both; }

Set the fill-mode to both. Without it, delayed text flashes visible before the animation starts.

Short travel distances read better than long ones. Half an em is plenty.

Gradient shimmer

Start with a linear-gradient background sized at 200% width. Add background-clip: text, plus the -webkit- prefixed version for older engines, and set -webkit-text-fill-color: transparent so the gradient shows through the letters. Then animate background-position from 0 to 200%, looping with linear timing.

Set a solid color as the base style, then wrap the clip and the transparent fill in @supports (background-clip: text), so an unsupported browser keeps readable text instead of invisible text.

The gradient must start and end on the same color, or the loop shows a seam.

Text reveal with clip-path or overflow hidden

The mask method wraps the text in a box with overflow: hidden and slides the inner span up from translateY(100%). Only transform animates.

The clip-path method animates clip-path: inset() from fully clipped to fully open, which gives wipes from any direction.

For shapes beyond inset(), such as diagonal wipes, a clip-path generator saves the trial and error. Clip-path is not on web.dev’s compositor-safe list, so profile it before putting it on a large hero.

The mask method wins for repeated headlines. Give the overflow box extra padding so descenders on g, y and p are not cut off.

Glitch and wave

A glitch effect stacks two pseudo-element copies of the text (content: attr(data-text)) over the original, offsets them a few pixels in contrasting colors and jitters them with steps() timing. An animated glitch text generator is a quick way to preview the timing before hand-coding it.

A wave moves each character up and down with translateY, offset by an index-based animation-delay. It needs per-letter spans, built the way the next section describes.

Both are motion-heavy. Glitch flickers and wave loops forever, and neither holds up on body text. Honestly, I’d skip them anywhere people have to read.

Marquee and scrolling text

The old marquee element is obsolete, so scrolling text now means a CSS animation.

Duplicate the content inside a track, translate the track from 0 to -50% with linear timing and an infinite iteration count, and the loop restarts without a visible jump.

Pausing on hover takes animation-play-state: paused. Because the whole thing runs on transform, it stays cheap.

How to animate text letter by letter

Wrap each character in a span, give each span an index as a custom property, then compute animation-delay from that index with calc(). CSS cannot select single characters, so the wrapping needs JavaScript or a build step.

Splitting.js and GSAP SplitText both do the wrapping. Splitting.js adds a –char-index custom property to every character span, which is all the stagger needs.

  1. Split the text: put data-splitting on the element and call Splitting(), or use SplitText.create() from GSAP.
  2. Read the index from –char-index on each span, so nothing is counted in CSS.
  3. Reuse a short keyframes rule, such as the rise animation from the fade-in effect.
  4. Apply the stagger as the animation-delay, and set display: inline-block on each span because transforms ignore inline boxes.
  5. Label the parent with aria-label and mark the spans aria-hidden.
.char {
  display: inline-block;
  animation: rise 500ms ease-out both;
  animation-delay: calc(var(--char-index) * 40ms);
}

Screen readers can announce a run of single-character spans as separate letters. The fix is an aria-label on the parent that carries the full string (an ARIA attribute built for this), plus aria-hidden on the spans.

GSAP’s SplitText documentation says its default aria setting, “auto”, builds that label from the element’s textContent and hides the character elements. SplitText has been free, commercial use included, since GSAP 3.13.

Not everyone accepts that as a fix. Adrian Roselli’s February 2026 write-up argues that aria-label is not allowed on every element role and may not be translated for users, reports that screen readers fail to expose SplitText output, and concludes that words should not be split into letters at all.

My rule is to animate words, not letters, unless the letter-level motion is the point.

Separate spans break pair kerning and ligatures such as fi. Arabic letters lose their joins when each one sits in an inline-block, and a 300-character paragraph becomes 300 animated nodes.

Triggering text animations on hover, page load and scroll

By default a text animation starts on page load. Hover goes through :hover, a class toggle comes from JavaScript, and scroll uses animation-timeline.

Load and hover triggers

On page load the animation runs the moment the styles apply, and animation-delay sequences several headings.

Hover is fussier. A transition on the base state reverses smoothly when the pointer leaves, while an animation on :hover snaps back. Class toggles usually arrive through IntersectionObserver, which reports when an element crosses into the viewport.

Hover has one trap. If the text moves out from under the cursor, the hover state ends and the effect flickers, so attach :hover to the parent and animate the child.

Buttons, cards and links follow the same transition logic, and a set of CSS hover effects shows how far it stretches.

Scroll-driven text with animation-timeline

MDN’s browser-compatibility data lists scroll-driven animations as shipping in Chrome and Edge 115 (July 2023), Safari 26 and Firefox 159, which is scheduled for October 27, 2026. Earlier Firefox versions keep them behind a preference flag, so Firefox is the one to recheck on MDN or Can I use before launch.

Scroll-driven animations swap time for scroll progress. The scroll() function ties progress to a scroller’s position, and view() ties it to the element’s own visibility.

.reveal { /* base style: fully visible */ }

@supports (animation-timeline: view()) {
  .reveal {
    animation: rise linear both;
    animation-timeline: view();
    animation-range: entry 0% entry 100%;
  }
}

Declare animation-timeline after the animation shorthand, because the shorthand resets it. The default range is cover, so entry 0% entry 100% finishes the reveal as the text enters instead of waiting until it has crossed the whole viewport.

Progress follows the scrollbar, so a headline can stall at 40 percent opacity when the reader stops scrolling. A short entry range keeps the text from spending long half-visible.

Keep the always-visible state in the base CSS and layer the animation inside @supports. Browsers without support skip the block, and the text simply shows.

Progress bars and parallax use the same timeline properties, and a broader guide to CSS scroll effects covers them.

Browser support for text animation features

Keyframe animations, transitions and prefers-reduced-motion work in every major browser. Scroll-driven animations are the exception, and Firefox is the last engine to ship them.

MDN’s Firefox 159 release notes say scroll-driven animations are now enabled by default, in a release scheduled for October 27, 2026. Until then they sat behind a preference, and the Firefox 152 release notes (June 16, 2026) still added scroll-driven pieces, such as range names inside @keyframes, behind that flag.

Some 2026 blog posts already called the feature Baseline or consistent across all major browsers. That was early, because web.dev defines Baseline as support across Chrome, Edge, Firefox and Safari, and the web-features data still marked it as not Baseline before Firefox 159.

FeatureStatusFallback
Keyframe animations and transitionsSupported in all major browsersNone needed
prefers-reduced-motionBaseline widely availableNone needed
animation-timeline (scroll-driven)Chrome and Edge 115, Safari 26, Firefox 159 (October 27, 2026)Visible base style inside @supports

web.dev puts the wait between a feature becoming newly available and reaching Baseline widely available at 30 months. MDN says prefers-reduced-motion became available across browsers in January 2020 (Safari 10.1, Firefox 63 and Chrome 74 shipped it earlier, and Edge 79 completed the set).

As of October 2026, treat scroll-driven text as progressive enhancement, since Firefox users on older versions will not see it. Test Firefox and Safari on real devices, because a cross-browser compatibility pass catches the gaps a support table misses.

How to keep CSS text animations smooth

web.dev’s guide to animations and performance also settles the CSS versus JavaScript question. When an animation triggers layout or paint, that overhead dwarfs any difference between the two.

The width-based typewriter shows how that plays out. Width is a layout property, but steps(24) over two seconds changes it only 24 times, so a single short line stays cheap.

Run the same trick on a paragraph and every step re-lays out a larger block.

Good CLS is 0.1 or less, measured at the 75th percentile of page loads, according to web.dev.

A layout shift happens when an existing element changes its start position. A text animation that changes width, height or letter-spacing can push neighbors around and count against that score.

Give the text container its final size before the animation starts. web.dev’s CLS guide recommends transform: scale() over changing width and height. Animating letter-spacing on a heading rewraps the line and moves everything below it, so watch for that one in particular.

MDN calls will-change a last resort for performance problems that already exist. Its warnings are to skip it on too many elements and to skip it as premature optimization.

A headline split into 60 character spans is the too-many-elements case. If profiling does show a stutter at the start of an effect, set the hint from script just before it runs, since MDN recommends scripting over a permanent stylesheet rule in most cases.

In Chrome DevTools, the Performance panel is where to record while the animation runs and look for layout work on the main thread, as web.dev’s animation guide walks through. Paint flashing highlights regions that repaint every frame.

The Animations panel slows down, replays and edits timing, delay and keyframe offsets live, for CSS animations, transitions and web animations.

Accessibility rules for animated text

Respect prefers-reduced-motion. Give any auto-starting motion that runs past five seconds a way to stop, and keep flashing at three flashes per second or fewer.

Motion triggers real symptoms. W3C’s guidance says people can get sick from motion effects, which is why web accessibility standards cover animation at all.

The prefers-reduced-motion feature is a CSS media query that reads the operating system setting. MDN describes the preference as a request to remove, reduce or replace motion, not to delete every effect.

@media (prefers-reduced-motion: reduce) {
  .rise { animation: fade 400ms ease-out both; }
  .typewriter, .shimmer { animation: none; }
}

@keyframes fade { from { opacity: 0; } }

The fade stays and the slide goes. Typewriter and shimmer drop to their finished state.

WCAG 2.2.2 (Level A) requires a pause, stop or hide mechanism for moving, blinking or scrolling content that starts automatically, lasts more than five seconds and sits beside other content, unless the movement is necessary to the activity, according to W3C’s Understanding document.

Every infinite loop above qualifies, so the marquee track and the shimmer count, and so does the typewriter caret. The caret runs 0.6 seconds per cycle with no end, so an iteration count of 8 stops it at 4.8 seconds, inside the limit.

animation: typing 2s steps(24) forwards,
  caret 0.6s step-end 8;

A marquee needs a visible button that sets animation-play-state: paused. Shimmer needs a finite iteration count that ends inside five seconds.

Motion that starts from hovering or scrolling still counts as automatic in W3C’s WCAG 2.2 Understanding document for 2.2.2, so a long looping hover effect needs a stop as well. WCAG 2.3.3 (Level AAA) targets interaction-triggered motion directly and lists the reduced-motion media query as technique C39.

WCAG 2.3.1 (Level A) allows no more than three flashes in any one-second period, unless the flash stays below the general and red flash thresholds. Glitch jitter is the effect most likely to cross that line, so slow its cycle or run it a few times only.

There’s some disagreement here. Hidde de Vries has argued that prefers-reduced-motion can count toward 2.2.2, but he says he is torn on whether it is sufficient. W3C files the media-query technique under 2.3.3 rather than 2.2.2, so a visible control remains the safer route.

When should you use JavaScript instead of CSS for text animation?

CSS handles short, fixed text animated on load or scroll position. JavaScript earns its place when the text length is unknown, or when a script has to pause, seek or reverse the animation. Effects that must run in sequence across several elements push the same way.

ApproachBest forMain limit
CSS keyframesShort fixed text, load and scroll effectsText length must be known, no line selector
Web Animations APIScript control over play, pause and reverseNeeds JavaScript
GSAP with SplitTextSequenced timelines, character, word and line splitsAdds a dependency
Typed.jsStrings of unknown length, backspacingJavaScript only
Animate.cssPrebuilt effects through classesFixed set of effects

The CSS typewriter hard-codes the steps() count and the ch width. One line of script can set a –chars custom property from the text, and the CSS reads it.

el.style.setProperty("--chars", el.textContent.length);
.typewriter {
  width: calc(var(--chars) * 1ch);
  animation: typing 2s steps(var(--chars)) forwards;
}

CSS cannot select lines of text, only the first one. GSAP’s SplitText documentation lists line splitting and responsive re-splitting on resize, which is the case where a library earns its place.

For longer sequences, JavaScript text animation techniques bring timelines and callbacks, and let you change things at runtime.

The built-in route is element.animate() from the Web Animations API, which MDN lists as Baseline widely available, with support across browsers since March 2020.

Typed.js handles strings of any length, with typing speed, backspacing and looping as options. Its README lists GitHub’s package registry page among the sites that have used it, and it describes a mode that reads strings from static HTML, which keeps the text in the page source.

Animate.css belongs to the family of CSS animation libraries. Add the animate__animated class plus an effect name and the keyframes are already written. The cost is size, since the unminified animate.css 4.1.1 file weighs 93.1 KiB (95,374 bytes), though a custom build trims it.

The project’s accessibility docs say the stylesheet supports prefers-reduced-motion out of the box.

One headline that fades in does not justify a library. Pull one in once the text or the sequencing outgrows what keyframes can express.

CSS text animations FAQ

How do you loop or reverse a text animation?

Set animation-iteration-count to infinite for a loop, or to a number for a fixed run. Animation-direction takes reverse to play backward and alternate to bounce forward, then back.

Fractional counts such as 2.5 stop the animation halfway through a cycle.

How can you replay a text animation without reloading the page?

A CSS animation runs once, when the animation property first applies. To replay it, remove the class, force a reflow by reading element.offsetWidth, then add the class back.

The alternative is calling element.animate() from the Web Animations API, which creates a fresh animation on every call.

Does animated text hurt SEO?

Not by itself. A keyframe animation leaves the markup untouched, so the text stays in the page source.

The risk sits in how the text gets there. Letter spans and script-typed strings change the DOM, so keep the full string in the HTML.

What duration and easing suit headline animations?

Roughly 400 to 800 ms with ease-out lets a headline decelerate into place without dragging. For scale, HTTP Archive’s 2020 Web Almanac measured a median transition duration of 300 ms on desktop and 250 ms on mobile, though that covers interface transitions, not text effects.

Typewriter effects run longer, since each character needs time to register.

Which Text Effect to Ship First

Among CSS text animations, a fade-in with a short upward slide goes live first, because the keyframes hold only a from state and the text stays readable if the animation never runs.

Ship the transform-and-opacity fade, then write the reduced-motion fallback. Testing in Firefox, Safari and Chrome DevTools comes last.

The fallback comes before any second effect because every added effect needs its own fallback, and the cases multiply with each one.

What you give up is spectacle. Glitch and wave stay off the page until the baseline passes review.

Revisit the scroll-driven option once Firefox 159, which enables animation-timeline by default, has reached most of your Firefox visitors. This position was verified in October 2026.