<angle> rotates the gradient in place; nothing slides.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.
| 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 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.