CSS Text Shimmer

A moving gradient shimmer across text using background-clip: text and CSS keyframes.

Published July 26, 2026 Beginner 8 min read

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.

Four gradients, one sweep

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

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; }
}

Other ways to build it

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.

Anodised Aluminium

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 {
    -webkit-text-fill-color: #0c0c0d;
    background: #b8ff57;
  }

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

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.

CSS properties used

background-clip
text restricts background painting to the glyph shapes. Write -webkit-background-clip: text next to it, since the prefixed form has worked far longer than the standard one.
-webkit-text-fill-color
transparent stops the glyphs painting their own color so the clipped background shows through. It overrides color, which is what makes a color fallback possible.
background-size
200% auto makes the gradient twice the width of its box, which is what gives the sweep somewhere to travel. Every position percentage in the keyframes is relative to this value.
background-position
The animated property. Percentages align a point on the image with the same point on the box, so the travel distance is the image width minus the box width.
linear-gradient
The shimmer itself. Color stops decide how wide the bright band is, and the first and last stops should match so the loop has no seam.
animation-duration
Set per gradient rather than on the shared class, which is how four examples share one keyframe set and still move at different speeds.
@supports
Guards the whole effect. Without a feature query, a browser that clears the text fill but cannot clip the background renders nothing at all.

Browser support

FeatureChromeFirefoxSafariEdge
background-clip: text44915.512
CSS gradients103615.412
CSS animation455.112
CSS feature queries2822912
prefers-reduced-motion746310.179

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 has no Can I Use entry of its own, since it 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.

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. Adding a ::selection rule that restores -webkit-text-fill-color to a solid value fixes it, and it is worth doing on anything a reader might want to copy.

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.

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.

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