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

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

@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) 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.

DemoStopContrast vs #0b1020AA large text (3:1)AA normal text (4.5:1)
Flow#a78bfa violet6.95:1PassPass
Flow#22d3ee cyan10.47:1PassPass
Flow#34d399 green9.84:1PassPass
Angle turn#f472b6 pink7.14:1PassPass
Angle turn#fbbf24 amber11.34:1PassPass
Angle turn#38bdf8 sky8.83:1PassPass
Link shimmer#67e8f9 resting colour13.06:1PassPass
Link shimmer#ffffff highlight18.93:1PassPass
Rejected#8b5cf6 brand violet4.47:1PassFail
Rejected#6d28d9 deep violet2.66:1FailFail

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 before committing to them, design the gradient itself in the gradient generator, and read our 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 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.

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

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

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