---
title: "Animated Gradient Text in CSS (and Keeping It Readable)"
description: "Animate gradient text with background-clip and @property, then keep it readable: contrast at every stop, forced colours, selection and reduced motion."
url: https://frontendhelpers.com/css-animated-gradient-text
updated: 2026-10-12
---

# Animated gradient text in CSS, and keeping it readable

Two ways to move colour through a headline, the contrast maths that decides which colours you can use, and the fallbacks that stop the text vanishing in high contrast mode, on selection or for people who turn motion off.

CSS recipe · By [AurumCodeWorks](https://frontendhelpers.com/about) · 7 min read · Updated 12 October 2026

Ship it today

**Background-position flow** — a gradient twice the width of the text slides through the letters and loops seamlessly.

Golden hour

**@property angle turn** — a registered `<angle>` rotates the gradient in place; nothing slides.

[Design your gradient stops](https://frontendhelpers.com/css-gradient-generator)

**Hover-only link shimmer** — a highlight crosses the link once on hover or keyboard focus, then rests.

## How gradient text works

CSS has no property for filling text with a gradient. Instead you paint a gradient as the element’s background, clip that background to the shapes of the letters, and make the letters themselves transparent so the background shows through. Three declarations do the work: `background-image` with a gradient, `background-clip: text`, and `color: transparent`. Skip the last one and you see solid text sitting on top of its own gradient, because, as MDN’s [background-clip reference](https://developer.mozilla.org/en-US/docs/Web/CSS/background-clip) puts it, the text value has little to no visual effect while the text is opaque.

Do you still need `-webkit-background-clip: text`? For current browsers, no. According to MDN’s compatibility data, Firefox has accepted the unprefixed value since version 49, Safari since 14, and Chrome and Edge since 120, released in December 2023. Every current release of the major engines understands the standard property. The prefixed line only rescues Chromium browsers older than 120, which still turn up in embedded WebViews, kiosks and devices that stopped updating. It costs one line, so we keep it, and write it before the standard declaration so the standard one wins wherever both parse. MDN also marks Firefox’s support as partial: the clip fails with `background-attachment: fixed` and in some multi-layer backgrounds, so keep gradient text to a single background layer.

Two layout details trip people up. First, the background covers the element’s whole box, not just the glyphs, so a block-level heading that stretches across the column spreads the gradient over the full width and the words only show a slice of it. Make the element `inline-block` or give it `width: fit-content` so the gradient is sized to the words. Second, descenders and accents that poke outside the box get no background and disappear. A tight `line-height` is the usual cause; a little vertical padding, as on `.gt-display` in the demos, fixes it.

## Animation one: slide an oversized gradient

The classic approach, and the one our [Gradient Flow library card](https://frontendhelpers.com/css-text-animations#anim-text-gradient) uses, makes the background larger than the text and moves it with `background-position`. Two things make the loop seamless. The gradient ends on the colour it starts with, and the animation moves the background by exactly one tile. With `background-size: 200%`, a position of `200%` shifts the image by one full tile width, and because backgrounds repeat by default, the last frame is identical to the first.

```css
.gt-flow { color: #a78bfa; }
@supports (background-clip: text) or (-webkit-background-clip: text) {
  .gt-flow {
    background-image: linear-gradient(90deg, #a78bfa, #22d3ee, #34d399, #a78bfa);
    background-size: 200% 100%;
    -webkit-background-clip: text;
    background-clip: text;
    color: transparent;
    animation: gt-flow 6s linear infinite;
  }
}
@keyframes gt-flow {
  to { background-position: 200% 0; }
}
```

It works everywhere, but it is not free. `background-position` cannot be handed to the compositor, so the browser repaints the text on the main thread every frame. For one headline that is negligible. For a dozen animated headings, or one that sits in a hero while the page is still busy loading scripts, it is wasted work that can stutter. Our guide to [CSS animation performance](https://frontendhelpers.com/css-animation-performance) explains the paint step in more detail.

## Animation two: animate a registered property

Custom properties are normally just strings to the browser, so it cannot interpolate them: an animation from one value to another flips at the halfway point. Registering the property with `@property` gives it a type, and a typed value can be animated smoothly. MDN’s [@property reference](https://developer.mozilla.org/en-US/docs/Web/CSS/@property) notes that `syntax` and `inherits` are required, and so is `initial-value` for any syntax other than `*`; leave one out and the whole rule is ignored. It has been Baseline since July 2024, when Firefox 128 joined Chrome 85 and Safari 16.4.

```css
@property --gt-angle {
  syntax: "<angle>";
  inherits: false;
  initial-value: 90deg;
}
.gt-turn { color: #f472b6; }
@supports (background-clip: text) or (-webkit-background-clip: text) {
  .gt-turn {
    --gt-angle: 90deg;
    background-image: linear-gradient(var(--gt-angle), #f472b6, #fbbf24, #38bdf8);
    -webkit-background-clip: text;
    background-clip: text;
    color: transparent;
    animation: gt-turn 8s linear infinite;
  }
}
@keyframes gt-turn {
  to { --gt-angle: 450deg; }
}
```

Instead of sliding, the gradient now turns in place, and the background never needs to be bigger than the text. The same pattern works for colour stops: register `--gt-start` with `syntax: "<color>"` and animate between two colours. Note the line `--gt-angle: 90deg` inside the rule. In a browser that does not understand `@property`, an unset custom property makes `var()` invalid, the whole `background-image` is dropped and the transparent text vanishes. With the value declared, an old browser gets a static gradient, and because 450 degrees points the same way as 90, its flip at the halfway point is invisible.

Be honest about the gain. Changing a gradient’s angle or colours still repaints the text every frame, exactly like sliding it. What `@property` buys you is control over what moves, not a cheaper animation.

## Contrast: check every point, not the average

WCAG success criterion 1.4.3 does not relax for decorative colour. The W3C’s [Understanding Contrast (Minimum)](https://www.w3.org/WAI/WCAG22/Understanding/contrast-minimum.html) sets 4.5:1 for normal text and 3:1 for large text, meaning at least 18 point (about 24px) or 14 point bold (about 18.5px). It also lists background images that do not give enough contrast with the text as a failure. With gradient text the variable colour is the text itself, so every point of the gradient has to clear the bar against the background. In practice that means testing the weakest stop: on a dark background, the darkest one.

Watch the space between stops too. Gradients blend through sRGB by default, and the halfway mix of two saturated, very different hues can be greyer and darker than either end. We measured a red-to-cyan pair, `#ef4444` to `#06b6d4`, against `#0b1020`: the ends score 5.03:1 and 7.80:1, but the midpoint `#7b7d8c` drops to 4.65:1. The figures below come from a Node script using the WCAG relative-luminance formula, measured against `#0b1020`, the solid background of each demo stage. We sampled each gradient at 1% steps and the weakest point fell on a stop every time. The headings are 30px or larger and count as large text; the link is 18px at weight 600, so it needs the full 4.5:1.

| Demo | Stop | Contrast vs `#0b1020` | AA large text (3:1) | AA normal text (4.5:1) |
| --- | --- | --- | --- | --- |
| Flow | `#a78bfa` violet | 6.95:1 | Pass | Pass |
| Flow | `#22d3ee` cyan | 10.47:1 | Pass | Pass |
| Flow | `#34d399` green | 9.84:1 | Pass | Pass |
| Angle turn | `#f472b6` pink | 7.14:1 | Pass | Pass |
| Angle turn | `#fbbf24` amber | 11.34:1 | Pass | Pass |
| Angle turn | `#38bdf8` sky | 8.83:1 | Pass | Pass |
| Link shimmer | `#67e8f9` resting colour | 13.06:1 | Pass | Pass |
| Link shimmer | `#ffffff` highlight | 18.93:1 | Pass | Pass |
| Rejected | `#8b5cf6` brand violet | 4.47:1 | Pass | Fail |
| Rejected | `#6d28d9` deep violet | 2.66:1 | Fail | Fail |

The two rejected rows show how quickly it goes wrong. Our own brand violet clears 3:1 for a big heading but misses 4.5:1 by a hair, so it could not be a stop in the link. A deeper violet that looks rich in a design file fails outright, and a gradient running through it would have an unreadable stretch in the middle of the word. Check candidate stops in the [contrast checker](https://frontendhelpers.com/contrast-checker) before committing to them, design the gradient itself in the [gradient generator](https://frontendhelpers.com/css-gradient-generator), and read our [WCAG colour contrast guide](https://frontendhelpers.com/wcag-colour-contrast-guide) for how the ratio is calculated.

## Windows High Contrast and forced colours

When someone turns on a contrast theme in Windows, the browser reports `forced-colors: active` and replaces your colours with the user’s palette. MDN’s [forced-colors reference](https://developer.mozilla.org/en-US/docs/Web/CSS/@media/forced-colors) lists what changes: `color`, `background-color` and the border colours are forced to system colours, shadows are removed, and `background-image` is set to none for any value that is not a URL. A gradient is not a URL, so the one thing painting your letters is gone. Whether the text survives then depends on how you hid it. `-webkit-text-fill-color` is not on the forced list, so a heading made transparent that way, as many snippets do, can disappear completely.

Do not leave it to chance. A short media query removes the background, which also resets the clip, and sets a system colour explicitly: `CanvasText` for headings and `LinkText` for links, so the link still looks like a link in the user’s theme.

```css
@media (forced-colors: active) {
  .gt-flow,
  .gt-turn,
  .gt-link {
    background: none;
    color: CanvasText;
    -webkit-text-fill-color: currentColor;
    animation: none;
    transition: none;
  }
  .gt-link {
    color: LinkText;
    text-decoration-color: currentColor;
  }
}
```

## Links, underlines and selection

Transparent text breaks everything that inherits `currentColor`. An underline, a focus outline, a border or an inline SVG icon set to `fill: currentColor` all turn invisible along with the glyphs. The link demo sets `text-decoration-color` and its focus outline to the resting colour so both stay visible. It also only animates on `:hover` and `:focus-visible`: keyboard users get the same feedback, and at rest the text is one solid, readable colour.

```css
.gt-link {
  font-size: 1.125rem;
  font-weight: 600;
  color: #67e8f9;
  text-decoration: underline;
  text-decoration-color: #67e8f9;
  text-underline-offset: 0.2em;
}
.gt-link:focus-visible {
  outline: 2px solid #67e8f9;
  outline-offset: 4px;
  border-radius: 2px;
}
@supports (background-clip: text) or (-webkit-background-clip: text) {
  .gt-link {
    background-image: linear-gradient(110deg, #67e8f9 40%, #ffffff 50%, #67e8f9 60%);
    background-size: 250% 100%;
    background-position: 100% 0;
    -webkit-background-clip: text;
    background-clip: text;
    color: transparent;
    transition: background-position 0.8s ease;
  }
  .gt-link:hover,
  .gt-link:focus-visible {
    background-position: 0 0;
  }
}

.gt-flow::selection,
.gt-turn::selection,
.gt-link::selection {
  color: #0b1020;
  background-color: #e2e8f0;
}
```

Selection is the other casualty. The selection highlight is painted over the gradient, and the glyphs on top of it are still transparent, so depending on the browser a selected heading can show as a tinted block with letters that are hard to make out. `::selection` accepts only a few properties, among them `color`, `background-color` and `-webkit-text-fill-color` (background images are ignored), which is enough to give selected text one solid, readable colour. Our pair of `#0b1020` on `#e2e8f0` measures 15.35:1. Restyle the selection only on the gradient elements, not the whole page, so the user’s normal highlight still applies everywhere else.

## Motion, scope and the fallback stack

A colour change that never stops pulls the eye, and some people find constant movement distracting or uncomfortable. Under `prefers-reduced-motion: reduce` the demos keep the gradient but stop it. The heading becomes a static gradient and the link a solid colour. That is a better fallback than removing the gradient, because the design stays intact.

```css
@media (prefers-reduced-motion: reduce) {
  .gt-flow,
  .gt-turn { animation: none; }
  .gt-link { transition: none; }
}
```

Keep the effect small. One heading, a short phrase inside a heading, or a single call-to-action link is where it works. Body copy is the wrong place: every colour along the gradient would have to meet 4.5:1, shifting colour makes letter shapes harder to follow along a line, and a dozen moving paragraphs is more motion than anyone wants. If the text matters, such as a price or a warning, give it a solid colour.

Finally, order the declarations so the worst case is still readable. Every demo sets a solid `color` first, using its weakest stop, and only applies the gradient inside an `@supports` block. The `or` in the query matters: Chromium before version 120 fails the unprefixed test but passes the prefixed one. An engine that understands neither, or a stylesheet that loses the gradient for any reason, shows plain coloured text instead of transparent text over nothing. MDN’s background-clip page gives the same advice: use a feature query and provide an accessible alternative where the clip is not supported.

## Frequently asked questions

### Do I still need -webkit-background-clip: text?

Not for any current version of Chrome, Edge, Firefox or Safari. Chrome and Edge accept the unprefixed value from version 120, Safari from 14 and Firefox from 49. The prefixed line only helps older Chromium browsers and embedded WebViews, so keep it as a cheap safety net and write it before the standard declaration.

### Why does my gradient text disappear in Windows High Contrast mode?

In forced colours mode the browser sets gradient background images to none, so the only thing that was painting the letters is gone. If the text was made transparent with -webkit-text-fill-color, nothing restores it. Add a forced-colors: active media query that removes the background and sets a system colour such as CanvasText or LinkText.

### Can I use gradient text for body copy?

Avoid it. Every colour along the gradient has to meet 4.5:1 for normal-sized text, the colour shifts make letter shapes harder to track across long lines, and a moving gradient behind a paragraph distracts readers. Keep it to headings, a short phrase or a single link.

### How do I check the contrast of gradient text?

Test the stop with the lowest contrast against the background, not the average. For dark backgrounds that is usually the darkest stop, for light backgrounds the lightest. Also check the midpoints between very different hues, because the blended colour can have lower contrast than either end.

## Keep going

- [CSS text animations](https://frontendhelpers.com/css-text-animations): Gradient flow, typewriter, wave and glitch effects with markup and CSS.
 
- [CSS gradient generator](https://frontendhelpers.com/css-gradient-generator): Build linear, radial and conic gradients and copy the CSS.
 
- [WCAG colour contrast](https://frontendhelpers.com/wcag-colour-contrast-guide): How contrast ratios are calculated and what AA and AAA require.
