Most demos need a newer browser

They use :has() and container queries. Update your browser, or on an iPhone update iOS to 16.4 or later.

CSS Text Shimmer Effect

CSS text shimmer effect using background-clip: text and animated background-position to slide a gradient across letters.

Level
Beginner
Published
Updated
Reading time
8 min

Try it

3 live demos

Pure CSS Golden Shimmer Rainbow Text Effect // loading content...

Four gradients, one sweep

Other ways to build it

One sweep, on hover

See the code
Anodised Aluminium

An angled shine that cannot make the text vanish

See the code

Build it

6 snippets to copy

CSS Text Shimmer Effect

HTML

<div class="shimmer-stage">
  <span class="shimmer shimmer-green">Pure CSS</span>
  <span class="shimmer shimmer-gold">Golden Shimmer</span>
  <span class="shimmer shimmer-rainbow">Rainbow Text Effect</span>
  <span class="shimmer shimmer-mono">// loading content...</span>
</div>

CSS

.shimmer-stage {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 1.25rem;
}

/* inline-block gives the gradient a box to sweep across */
.shimmer {
  display: inline-block;
  background-size: 200% auto;
  background-clip: text;
  -webkit-background-clip: text;
  -webkit-text-fill-color: transparent;
  animation: shimmer-sweep 2s linear infinite;
}

.shimmer-green {
  font-size: 2rem;
  font-weight: 800;
  background-image: linear-gradient(90deg,
    #f0f0f0 0%,
    #b8ff57 30%,
    #57d9a3 50%,
    #b8ff57 70%,
    #f0f0f0 100%
  );
}

.shimmer-gold {
  font-size: 1.4rem;
  font-weight: 700;
  background-image: linear-gradient(90deg,
    #88888f 0%,
    #ffd444 30%,
    #ff9040 50%,
    #ffd444 70%,
    #88888f 100%
  );
  animation-duration: 2.5s;
}

.shimmer-rainbow {
  font-size: 1.1rem;
  font-weight: 600;
  background-image: linear-gradient(90deg,
    #ff6b6b 0%,
    #ffd444 20%,
    #b8ff57 40%,
    #38bdf8 60%,
    #c084fc 80%,
    #ff6b6b 100%
  );
  background-size: 300% auto;
  animation-duration: 3s;
}

.shimmer-mono {
  font-family: "DM Mono", "Fira Code", Consolas, monospace;
  font-size: .95rem;
  font-weight: 500;
  background-image: linear-gradient(90deg,
    #88888f 0%,
    #f0f0f0 50%,
    #88888f 100%
  );
  animation-duration: 1.5s;
}

@keyframes shimmer-sweep {
  from { background-position: 200% center; }
  to   { background-position: -200% center; }
}

One sweep, on hover

A shimmer that never stops competes with everything around it. Applying the animation inside the :hover and :focus-visible rules instead of on the base class means it runs once, when the reader is already looking at the control. The band is narrow because the gradient holds its muted color from 0 to 40 percent and again from 60 to 100, so only the middle fifth is bright.

At rest the background sits at 100 percent, which aligns the image's right edge with the box and leaves the bright middle of the image off to the left, out of sight. Running the position down to -100 percent slides the image right, so the band enters from the left edge and leaves at the right.

HTML

<div class="shimmer-stage">
  <button type="button" class="ts-once">Start your free trial</button>
  <button type="button" class="ts-once ts-once-slow">Read the documentation</button>
</div>

CSS

@keyframes ts-sweep-once {
  from { background-position: 100% center; }
  to   { background-position: -100% center; }
}

.ts-once {
  display: inline-block;
  font: inherit;
  font-size: 1.15rem;
  font-weight: 700;
  border: 0;
  padding: 0;
  cursor: pointer;
  background-color: transparent;
  /* the bright band is only the middle fifth of the image */
  background-image: linear-gradient(90deg,
    #88888f 0%,
    #88888f 40%,
    #b8ff57 50%,
    #88888f 60%,
    #88888f 100%
  );
  background-size: 250% auto;
  /* right edge of the image on the right edge of the box, so the
     bright middle sits off to the left, out of sight */
  background-position: 100% center;
  background-clip: text;
  -webkit-background-clip: text;
  -webkit-text-fill-color: transparent;
}

/* the animation lives on the states, so it runs once per hover */
.ts-once:hover,
.ts-once:focus-visible {
  animation: ts-sweep-once .9s linear;
}

An angled shine that cannot make the text vanish

Two changes. The gradient angle moves to 115 degrees, which tilts the band and reads as light crossing brushed metal rather than a flat wipe, and the animation is untouched because the image is still traveling horizontally. More importantly, everything that could hide the text sits inside a feature query. The plain color outside it is what a browser renders if it cannot clip a background to glyphs, and the reduced motion block drops back to that same solid color rather than freezing the gradient at whatever point it happened to reach.

HTML

<div class="shimmer-stage">
  <span class="ts-metal">Anodised Aluminium</span>
</div>

CSS

/* uses @keyframes shimmer-sweep from the main example above; the rules here are
   what changes, not the whole file */
/* what every browser gets, whatever else it can or cannot do */
.ts-metal {
  display: inline-block;
  font-size: 1.75rem;
  font-weight: 800;
  letter-spacing: .02em;
  color: #f0f0f0;
}

@supports (background-clip: text) or (-webkit-background-clip: text) {
  .ts-metal {
    /* 115deg tilts the band; the animation is unchanged */
    background-image: linear-gradient(115deg,
      #88888f 0%,
      #f0f0f0 35%,
      #ffffff 50%,
      #f0f0f0 65%,
      #88888f 100%
    );
    background-size: 220% auto;
    background-clip: text;
    -webkit-background-clip: text;
    -webkit-text-fill-color: transparent;
    animation: shimmer-sweep 3.5s linear infinite;
  }

  /* selected text would otherwise be a block with no letters in it */
  .ts-metal::selection {
    color: #0c0c0d;
    background: #b8ff57;
  }

  @media (prefers-reduced-motion: reduce) {
    .ts-metal {
      animation: none;
      -webkit-text-fill-color: #f0f0f0;
    }
  }
}

Understand it

How it works and 7 properties

A CSS text shimmer paints a gradient behind a line of text, clips that gradient to the shape of the letters with background-clip: text, and then slides it sideways. The letters themselves never change. What moves is the picture behind them, and because the picture is only visible where the glyphs are, the effect reads as light traveling along the text.

Three declarations do the clipping and one keyframe does the motion, so the four examples above differ only in their gradient and their duration. Two more versions follow the code: a single sweep that fires on hover rather than looping, and an angled shine wrapped in a feature query, because the one way this technique fails badly is by leaving text invisible.

How it works

background-clip: text clips the background to the shape of the text characters. Combined with -webkit-text-fill-color: transparent, the background shows through the text instead of the text color. An @keyframes animation shifts background-position across a gradient with background-size: 200% auto, creating the illusion of a light sweeping across the text. The gradient can include any colors for different shimmer styles.

Two properties work together to make the text a window. background-clip: text limits where the element's background is painted to the glyph shapes, and -webkit-text-fill-color: transparent makes the glyphs themselves paint nothing, so the background is all that is left. Both are needed. Clip the background without clearing the fill and the solid text color sits on top, hiding everything. The prefixed -webkit-background-clip is still worth writing alongside the standard property, because it has worked in WebKit and Chromium for far longer than the unprefixed name has.

The sliding comes from percentage background positions, which are the part people get wrong. A percentage in background-position does not measure the box. It aligns a point on the image with the same point on the box, so 0% puts the image's left edge at the box's left edge and 100% puts its right edge at the box's right edge. The distance those two positions span is the image width minus the box width. With background-size: 200% the image is twice the box, so 100% of travel is exactly one box width, and animating from 200% to -200% moves the gradient four box widths across. Change background-size and every one of those numbers means something different.

display: inline-block is doing more than it looks. An inline element that wraps across two lines has two separate background painting areas, so the gradient restarts on the second line and the sweep breaks in half. An inline-block is a single box. The side effect is that the box is exactly as wide as its text, so a short phrase and a long one running the same two second animation travel very different distances, and the short one looks faster.

The timing function has to be linear here, and for a reason beyond taste. The animation loops, and its end state is not the same as its start state, so any easing curve produces a visible discontinuity at the seam where the sweep restarts. It would slow to a near stop, jump, then accelerate away. A timing function also applies to each keyframe segment separately rather than to the whole animation, so adding a stop to hold the shimmer still for a beat would give you two eased segments and two stalls rather than one pause.

This is a paint animation, not a composited one. transform and opacity can be handed to the compositor and run without the main thread; background-position cannot, so every frame repaints the text. Four lines of it costs nothing measurable. Forty shimmering rows in a list is a different matter, and it is worth watching a performance profile before shipping one on a page that already has work to do.

Accessibility notes

Contrast is measured against the actual painted pixels, and a shimmer changes them several times a second. The safe approach is to check the darkest color in the gradient against the background rather than an average, because for part of every cycle that is the color the reader is looking at. A shimmer that dips to a muted gray on a dark page will fail contrast for a fraction of every loop, and the reader who notices is the one who was already struggling.

An animation that never stops is covered by WCAG 2.2.2, which asks that anything moving for more than five seconds can be paused, stopped, or hidden. An infinite shimmer has no off switch, so prefers-reduced-motion: reduce is not a nicety here. Under that query, drop the animation and set a solid color along with -webkit-text-fill-color, so the text lands on one readable color rather than freezing mid gradient.

Selected text behaves oddly. Because the glyphs paint nothing, a selection highlight can appear as a colored block with no visible letters in it. A ::selection rule with color set to a solid value restores the letters in Chromium and Firefox. WebKit paints them blank either way.

Mistakes worth avoiding

  • Setting -webkit-text-fill-color: transparent outside a feature query. If the background fails for any reason, a typo in the gradient included, the text is not faint, it is completely gone, and the page looks like it failed to load.
  • Leaving the element display: inline. Text that wraps to a second line gets a second background box, the gradient restarts halfway through, and the sweep visibly breaks at the line break.
  • Using an easing curve on a looping sweep. The start and end states differ, so the curve produces a stall and a jump at the seam every cycle. linear is the only timing function that loops cleanly here.
  • Changing background-size without revisiting the keyframes. Every percentage in background-position is measured against the image, so doubling the size halves the apparent travel and the shimmer slows down for no visible reason.
  • Assuming the gradient's average color is what gets contrast checked. It is not. For part of every cycle the reader sees the darkest stop, and that is the one that has to pass.

Ship it

6 features in 4 browsers, 5 questions

Browser support

Feature Chrome Firefox Safari Edge
background-clip: text44915.512
-webkit-text-fill-color4493.115
CSS gradients103615.412
CSS animation455.112
CSS feature queries2822912
prefers-reduced-motion746310.179

Figures come from Can I Use. The Safari figure in the first row is when the unprefixed background-clip: text was accepted; -webkit-background-clip: text worked in WebKit long before that, which is why both belong in the rule. -webkit-text-fill-color is a non-standard WebKit property that every current engine implements anyway. That is exactly the situation a feature query exists for, and the second variant below shows the guard: declare a plain color first, then put the clipping and the animation inside @supports, so a browser that cannot do the effect shows solid colored text instead of nothing.

What you can build with it

  • Loading placeholders. The moving sheen on a skeleton loader, which is the same technique with no text clipping. There the motion is the signal that something is still happening.
  • Hero headlines. One line, once per page, where the shimmer marks the single most important phrase. More than one and they compete.
  • Premium or upgrade labels. A gold gradient on a short badge, borrowing a convention readers already recognize from games and subscription tiers.
  • Status text. A muted gray to white sweep on the word Processing, which reads as ongoing work without occupying the space a spinner needs.
  • Call to action links. A single sweep on hover rather than a loop, which draws the eye at the moment of intent instead of competing for it constantly.

Frequently asked questions

Why is my text invisible with background-clip: text?
The background is not painting. -webkit-text-fill-color: transparent has removed the glyph color and there is nothing behind it to show through, so the letters are transparent on transparent. Check that background-image is set on the same element, and that -webkit-background-clip: text is present alongside the standard property.
Does a CSS text shimmer work in Firefox?
Yes, a CSS text shimmer works in Firefox. Can I Use puts unprefixed background-clip: text in Firefox from version 49, and -webkit-text-fill-color is implemented there too despite the prefix. The property to be careful with is not support but the fallback, which is why the effect belongs inside @supports.
How do I make the shimmer move faster or slower?
Change animation-duration on the individual element rather than the shared class, which is how the four examples above run at four different speeds off one keyframe set. Bear in mind the box is only as wide as its text, so the same duration looks quicker on a short phrase.
Can I shimmer in a different direction?
Change the gradient angle. linear-gradient(115deg, ...) produces a diagonal band that reads as light on metal, and the animation stays exactly the same because it is still moving the image horizontally. The second variant below does this.
Is animating background-position expensive?
More than animating a transform. Background position cannot be handled by the compositor, so the element is repainted every frame. One headline is fine. A long list where every row shimmers is worth profiling before it ships.