Skip to main content

Harshal V. LADHE

CSS Gradient Text Guide: Background-Clip, Animation & Advanced Tricks

Create stunning gradient text effects with CSS
Published at:
Last updated:
Estimated reading time:20 min read

CSS doesn't have a color: gradient value — color only ever accepts a single solid color. But with one clever trick, you can still paint a gradient across your text and create visually striking typography.

This guide explains how gradient text works, why each line of the trick is needed, and when to use each approach — starting from a basic linear gradient and working up to animated, stroked, blended, and video-filled text, plus the production pitfalls that catch most people out.

If gradients themselves are new to you, start with Mastering CSS Gradients: Types, Use Cases, and Best Practices — this post assumes you're comfortable writing a linear-gradient().

How Gradient Text Works

Creating gradient text in CSS follows a simple 4-step process:

  1. Declare a solid fallback color for browsers that can't clip backgrounds to text
  2. Apply a gradient as the element's background
  3. Clip the background to the text with background-clip: text
  4. Make the text's own fill transparent with -webkit-text-fill-color: transparent, so the clipped gradient shows through
.gradient-text {
  color: #ff7e5f; /* 1. Fallback */
  background: linear-gradient(90deg, #ff7e5f, #feb47b); /* 2. The gradient */
  -webkit-background-clip: text; /* 3. Clip it to the glyphs... */
  background-clip: text;
  -webkit-text-fill-color: transparent; /* 4. ...and let it show through */
}

Visual Illustration

How background-clip: text builds gradient textThree panels. First, an orange-to-pink gradient fills the entire element box, with the letters only outlined on top of it. Second, after background-clip: text, the gradient survives only inside the letter shapes, but the solid text fill still covers it, so the letters look plain. Third, with the text fill made transparent, the clipped gradient shows through and the letters appear in gradient color.AaAaAaAa① gradient backgroundbackground:linear-gradient(…)② background-clip: textgradient kept only underglyphs — fill still covers it③ fill made transparent-webkit-text-fill-color:transparentpaint the background → clip it to the glyphs → remove the fill on top

What Each Line Does

  • color — normally paints the text. Here it's the safety net for browsers that ignore the whole trick: the text still renders in a readable solid color. It can't help a browser that understands the transparent fill but can't clip — the fill still wins there and the text disappears, which is what @supports guards against.
  • background — the gradient is painted across the element's whole box, exactly like any other background.
  • background-clip: text — throws away every part of that background that isn't underneath a glyph. What's left is a gradient in the exact shape of your letters.
  • -webkit-text-fill-color: transparent — the text is still painted on top of its background, so it would cover the gradient completely. Making the fill transparent removes that top layer.

Essential Techniques

With the four-line recipe in place, each "flavor" of gradient text is mostly a matter of swapping the background.

Linear Gradient Text

The industry standard for production — a straight blend from one color to another.

.linear-gradient {
  color: #fda085; /* Fallback */
  background: linear-gradient(135deg, #f6d365 0%, #fda085 100%);
  -webkit-background-clip: text;
  background-clip: text;
  -webkit-text-fill-color: transparent;
}

When to use: blog headings, marketing pages, simple static gradient effects.

Radial & Conic Gradients

Swap the gradient function and nothing else changes. Radial gradients radiate out from a focal point, while conic gradients sweep around a center like a color wheel — best saved for decorative, experimental UI.

.radial-gradient {
  background: radial-gradient(circle at 30% 40%, #fff7ad, #ff6a00 70%);
}

.conic-gradient {
  background: conic-gradient(from 90deg, red, yellow, lime, cyan, blue, magenta, red);
}

/* Both share the same clipping recipe */
.radial-gradient,
.conic-gradient {
  -webkit-background-clip: text;
  background-clip: text;
  -webkit-text-fill-color: transparent;
}

Two details matter for conic text:

  • End on the first color. The sweep's last stop meets its first at the start angle, so if they differ you get a hard seam running through the letters. Repeating the first color (red at both ends here) closes the circle smoothly.
  • from <angle> rotates the sweep. It moves where the first color starts (0deg is straight up, turning clockwise), which decides which letters get which hues.

When to use: decorative headings, experimental UI designs, visual demos and showcases.

For how the focal point and sweep angle work, see the radial and conic sections of the gradients guide.

Animated Gradient Text

Perfect for high-impact hero sections. Browsers can't smoothly animate between two gradient images, so instead we make the background wider than the text and slide it along.

.animated-gradient {
  color: #ff6a00; /* Fallback */
  background: linear-gradient(90deg, #ff6a00, #ee0979, #00c6ff, #ff6a00);
  background-size: 200% auto;
  -webkit-background-clip: text;
  background-clip: text;
  -webkit-text-fill-color: transparent;
  animation: shine 3s linear infinite;
}

@keyframes shine {
  to {
    background-position: 200% center;
  }
}

Visual Illustration

Animated gradient text: panning a double-width stripA gradient strip from orange to pink to blue and back to orange, twice as wide as the text, shown at three background-position values. The text box stays still and shows only the half of the strip behind it. At 0% it shows the first half, orange through pink, at 100% the second half, blue back to orange, and at 200% the strip has moved one full copy so the text looks exactly as it did at 0%, which is why the loop has no visible jump.background-size: 200% — the text is a fixed window onto a double-width strip0%animation startsGradient100%halfwayGradient200%same as 0% — loopsGradienttile edge — matching end stops hide the jump from 200% back to 0%

Three details make this loop seamless:

  • background-size: 200% auto makes the gradient twice as wide as the text, so only half of it is visible at any moment — the animation pans that visible window along the strip.
  • The first and last color stops match (#ff6a00 at both ends). When the animation jumps back to the start, the colors line up exactly and the reset is invisible.
  • The gradient runs at 90deg. The keyframes slide the strip sideways, and a tilted gradient's left and right edges don't match, so any other angle shows a seam every time the pattern repeats. To animate a gradient at any angle, use @property instead.

When to use: hero titles, highlighted callouts, attention-grabbing UI elements.

For a refresher on @keyframes and the animation shorthand, see the CSS keyframes guide.

Gradient Stroke (Outline) Text

Instead of filling the letters, this technique paints the gradient only on their outline — great for logos and futuristic "neon" aesthetics.

The trick is that background-clip: text clips to the whole glyph, including its stroke. So a transparent stroke widens the clipped area, and the fill is set to the background color behind the text to cover the middle — leaving only the stroke ring showing the gradient.

.stroke-gradient {
  background: linear-gradient(90deg, #00dbde, #fc00ff);
  -webkit-background-clip: text;
  background-clip: text;
  -webkit-text-stroke: 3px transparent;
  -webkit-text-fill-color: #0f1220; /* Must match the background behind the text */
}

Visual Illustration

Why gradient stroke text needs a background-colored fillTwo panels showing the letters Ab with a transparent gradient stroke. On the left, with a transparent fill, the gradient shows through the entire letter and looks like ordinary filled gradient text — marked as not working. On the right, with the fill set to the background color, only a thin gradient outline is visible — marked as working.fill: transparentAb✗gradient shows through thewhole glyph — no outlinefill: the background colorAb✓fill covers the middle — onlythe stroke ring shows the gradient

Best use cases: logos, large display text, neon or futuristic designs.

Advanced Techniques

Once the core recipe is second nature, you can combine it with shadows, blend modes, media, and scroll effects.

Adding a Shadow

text-shadow doesn't play nicely with gradient text: the shadow is painted as part of the text layer, which sits above the clipped background, so it shows through the transparent fill and muddies the gradient.

The simplest fix is filter: drop-shadow(), which shadows the element's final rendered pixels — the gradient letters themselves:

.shadow-gradient {
  background: linear-gradient(to bottom, #fff, #64748b);
  -webkit-background-clip: text;
  background-clip: text;
  -webkit-text-fill-color: transparent;
  filter: drop-shadow(4px 4px 2px rgba(0, 0, 0, 0.5));
}

If you need a real text-shadow (for example, several stacked shadows for a retro effect), split the effect into two layers instead: the element draws the shadow, and a pseudo-element duplicates the text on top with the gradient.

<h1 class="layered-gradient" data-text="Layered">Layered</h1>
.layered-gradient {
  position: relative;
  color: transparent;
  text-shadow: 3px 3px 0 #1e293b, 6px 6px 0 #64748b;
}

.layered-gradient::after {
  content: attr(data-text);
  content: attr(data-text) / ""; /* Empty alt text: hide the duplicate from screen readers */
  position: absolute;
  inset: 0;
  background: linear-gradient(to bottom, #fff, #64748b);
  -webkit-background-clip: text;
  background-clip: text;
  -webkit-text-fill-color: transparent;
  text-shadow: none;
}

When to use: hero headings that need depth, retro or 3D-style display text.

Blend Modes

mix-blend-mode blends the element's pixels with whatever sits behind it. Combined with the clipping recipe, the gradient letters pick up the texture or photo underneath them.

.blend-container {
  background: url("carbon-fibre-texture.png");
  padding: 10px;
}

.blend-gradient {
  color: #00ffcc; /* Fallback */
  background: linear-gradient(to right, #00ffcc, #ff0055);
  -webkit-background-clip: text;
  background-clip: text;
  -webkit-text-fill-color: transparent;
  mix-blend-mode: color-dodge;
}

Which mode to reach for depends on the backdrop:

  • screen only ever lightens, so the gradient shows clearly over a dark texture and washes out over a light one.
  • color-dodge brightens the backdrop's mid-tones into a glow, but it leaves pure black black — so the letters need something other than solid black behind them.
  • hard-light keeps the gradient's own light and dark areas and lays the texture over them; overlay is the reverse, keeping the texture's light and dark areas and tinting them with the gradient.
  • difference inverts whatever is behind the letters — striking, but the colors are hard to predict.
  • luminosity keeps the gradient's brightness and takes its hue from the backdrop, so the text mostly loses the gradient's colors.

Image, GIF & Video Fills

Since background-clip: text clips any background, you aren't limited to gradients — an image or animated GIF works the same way:

.image-text {
  background: url("gradient-background.gif") center / cover;
  -webkit-background-clip: text;
  background-clip: text;
  -webkit-text-fill-color: transparent;
}

A <video> can't be used as a CSS background, so background-clip can't reach it. Instead, place the text over the video and let a blend mode "knock out" the letters. With mix-blend-mode: screen, white areas stay white and black areas become fully transparent — so black text on a white box turns into windows onto the video:

<div class="video-text">
  <video autoplay muted loop playsinline>
    <source src="/assets/videos/gradient.webm" type="video/webm">
    <source src="/assets/videos/gradient.mp4" type="video/mp4">
  </video>
  <h1>Gradient Text</h1>
</div>
.video-text {
  position: relative;
}

.video-text video {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

.video-text h1 {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  margin: 0;
  background: #fff; /* Stays white after blending */
  color: #000; /* Becomes a see-through window onto the video */
  mix-blend-mode: screen;
}

The white box stays visible around the letters, so this works best when the surrounding page is also white (use multiply with white text on a black box for dark pages).

Best for: landing pages, creative portfolios, experimental typography.

The "Window" Effect (Fixed Attachment)

A subtle, high-end effect often seen on premium landing pages. With background-attachment: fixed, the gradient is pinned to the viewport rather than the text. As the user scrolls, the text acts like a moving window, revealing different parts of one large, static gradient.

.window-gradient {
  background: linear-gradient(45deg, #00f2fe 0%, #4facfe 50%, #f093fb 100%);
  background-attachment: fixed; /* Pins the gradient to the viewport */
  background-size: cover; /* Stretches it across the whole screen */
  -webkit-background-clip: text;
  background-clip: text;
  -webkit-text-fill-color: transparent;
}

Visual Illustration

background-attachment: fixed turns text into a windowThe same browser viewport shown twice, before and after scrolling down 80 pixels. A faint cyan-to-blue-to-pink gradient fills the viewport and stays in the same place in both. The page's headings scroll over it, so the heading Chapter Two, low in the first viewport, shows mostly blue, while in the second viewport, nearer the top, it shows pinker colors from the same gradient.Scrolled to the topChapter OneChapter TwoChapter ThreeChapter Two sits low: mostly blueScrolled down 80pxChapter OneChapter TwoChapter ThreeSame heading, higher up: pinkerscrollThe gradient never moves — it's pinned to the viewport.The text scrolls across it, so the same heading shows a different slice.

Why it's a hidden gem:

  • Depth: it creates a sense of parallax motion without any JavaScript.
  • Consistency: multiple gradient headings on the same page all look like they're cut from one giant background.

Best use cases: long-form storytelling pages, portfolio sections with large bold headings, a "reveal" effect as the user scrolls.

Animating Colors and Angles with @property

The background-position trick only pans a gradient; it can't change its colors or rotate it. Normally a gradient can't be animated at all, because a custom property like --angle is just an untyped string to the browser. Registering it with @property gives it a type, and a typed property can be interpolated:

@property --angle {
  syntax: "<angle>";
  inherits: false;
  initial-value: 0deg;
}

.rotating-gradient {
  background: linear-gradient(var(--angle), #ff6a00, #ee0979, #00c6ff);
  -webkit-background-clip: text;
  background-clip: text;
  -webkit-text-fill-color: transparent;
  animation: rotate-gradient 4s linear infinite;
}

@keyframes rotate-gradient {
  to {
    --angle: 360deg;
  }
}

The same idea works for colors. Register each stop as a <color> property, then animate the two stops trading places — the gradient stays put while its hues shift in place, instead of panning like the background-position version:

@property --stop-a {
  syntax: "<color>";
  inherits: false;
  initial-value: #ff6a00;
}

@property --stop-b {
  syntax: "<color>";
  inherits: false;
  initial-value: #ee0979;
}

.shifting-gradient {
  color: #ff6a00; /* Fallback */
  background: linear-gradient(90deg, var(--stop-a), var(--stop-b));
  -webkit-background-clip: text;
  background-clip: text;
  -webkit-text-fill-color: transparent;
  animation: shift-hues 4s ease-in-out infinite;
}

@keyframes shift-hues {
  0%,
  100% {
    --stop-a: #ff6a00;
    --stop-b: #ee0979;
  }

  50% {
    --stop-a: #ee0979;
    --stop-b: #ff6a00;
  }
}

Because the stops are animated rather than the image's position, this version works at any gradient angle.

Responsive & Themeable Gradient Text

Fluid Sizing

Gradients scale naturally with text, so a fluid font-size is all you need for the effect to look right on every screen:

.responsive-gradient {
  font-size: clamp(2rem, 8vw, 5rem);
  line-height: 1.2;
  overflow-wrap: break-word;
  background: linear-gradient(90deg, #ff6a00, #ee0979);
  -webkit-background-clip: text;
  background-clip: text;
  -webkit-text-fill-color: transparent;
}

overflow-wrap: break-word stops a long word from overflowing on narrow screens, and a line-height of at least 1.2 keeps descenders inside the painted background (see Clipped Descenders and Italic Overhangs).

Dynamic Gradients with CSS Variables

CSS variables let you change a gradient per theme, per component, or at runtime:

:root {
  --gradient-start: #c2410c;
  --gradient-end: #be185d;
}

/* Brighter stops for dark backgrounds, so contrast stays high */
@media (prefers-color-scheme: dark) {
  :root {
    --gradient-start: #fb923c;
    --gradient-end: #f472b6;
  }
}

.variable-gradient {
  color: var(--gradient-start); /* Fallback */
  background: linear-gradient(90deg, var(--gradient-start), var(--gradient-end));
  -webkit-background-clip: text;
  background-clip: text;
  -webkit-text-fill-color: transparent;
}

Combined with JavaScript (element.style.setProperty("--gradient-start", "#22c55e")), this enables live gradient updates — exactly the trick the playground further down this post uses to update colors as you drag its controls.

Production Pitfalls and Fixes

Applying a gradient is easy; making it hold up across browsers, layouts, and edge cases is the hard part.

The Multi-Line Wrap Fix

The problem: when gradient text wraps onto several lines, the lines don't each get the full gradient. On a block element (like an <h1>), the gradient is painted once across the whole box, so each line only gets whatever slice of it happens to sit behind it. On an inline element (like a <span>), the line fragments are treated as one long strip laid end to end, so the first line gets the start of the gradient and the last line gets the end.

The solution: put the gradient on an inline element and add box-decoration-break: clone. This tells the browser to paint the background separately for each line fragment, so every line gets the complete gradient.

Visual Illustration

box-decoration-break: slice versus cloneTwo copies of a heading wrapped onto three lines. On the left, without the fix, the lines are cut from one long orange-to-pink-to-purple strip laid end to end, so the first line is mostly orange and the last mostly purple. On the right, with box-decoration-break: clone, every line shows the complete gradient from orange to purple.Without the fix (slice)With box-decoration-break: cloneline 1line 2line 3line 1line 2line 3Gradient textGradient textthat wrapsthat wrapsacross linesacross lineseach line gets one slice of the gradienteach line gets the full gradient
<h1><span class="wrap-fix">Gradient text that wraps across several lines</span></h1>
.wrap-fix {
  background: linear-gradient(90deg, #ff6a00, #ee0979);
  -webkit-background-clip: text;
  background-clip: text;
  -webkit-text-fill-color: transparent;
  -webkit-box-decoration-break: clone;
  box-decoration-break: clone;
}

You can see this exact toggle live in the playground below — force the demo text to wrap, then flip the fix on and off to watch each line switch between a slice of the gradient and the full thing.

Clipped Descenders and Italic Overhangs

The gradient only covers the element's box, but glyphs don't always stay inside it. With a tight line-height, descenders (the tails of g, j, p, q, y) hang below the box. Italic and script fonts often lean past its right edge. Any part of a glyph outside the box has no background behind it, so with a transparent fill it simply disappears.

Visual Illustration

Gradient text clipped by its own boxThe italic word Jumpy in gradient text, shown twice. On the left, with line-height 0.8, the dashed painted box stops just below the baseline and at the last letter's edge, so the tails of p and y and the top of the leaning y fall outside it and are drawn only as faint ghosts, because there is no background there to show through. On the right, with line-height 1.2 and a little padding, the box is taller and slightly wider and every glyph is fully painted.line-height: 0.8JumpyJumpytails and lean fall outside the boxline-height: 1.2 + 0.1em paddingJumpyJumpythe box now covers every glyphtails cut offlean cut offfaint = part of a glyph with no background behind it, so it disappears
.gradient-text {
  line-height: 1.2; /* Enough room for descenders */
  padding-block-end: 0.1em; /* Extra room for deep descenders */
  padding-inline-end: 0.1em; /* Room for italic overhang */
}

The background Shorthand Resets background-clip

The background shorthand resets every background sub-property it doesn't mention — including background-clip. So a later rule that "just changes the gradient" silently switches clipping back to border-box, and your text vanishes behind a solid gradient rectangle.

.gradient-text {
  background: linear-gradient(90deg, #ff6a00, #ee0979);
  -webkit-background-clip: text;
  background-clip: text;
  -webkit-text-fill-color: transparent;
}

/* ❌ Resets background-clip — the text disappears */
.gradient-text:hover {
  background: linear-gradient(90deg, #00c6ff, #ee0979);
}

/* ✅ Only replaces the image, so clipping survives */
.gradient-text:hover {
  background-image: linear-gradient(90deg, #00c6ff, #ee0979);
}

When overriding a gradient, reach for background-image instead of background.

Feature Detection with @supports

A fallback color covers most cases, but the most robust approach is to only apply the transparent fill when clipping is actually supported. Then a browser that can't clip never ends up with invisible text:

h1 {
  color: #6366f1; /* Everyone gets a readable solid color */
}

@supports (-webkit-background-clip: text) or (background-clip: text) {
  h1 {
    background: linear-gradient(to right, #6366f1, #ec4899);
    -webkit-background-clip: text;
    background-clip: text;
    -webkit-text-fill-color: transparent;
  }
}

The SVG Fail-Safe

For high-reliability branding (like a company logo), SVG is the most dependable option. The gradient is part of the graphic itself, so it doesn't depend on background clipping at all and renders identically across browsers.

<svg viewBox="0 0 400 60" role="img" aria-label="SVG Gradient Text">
  <defs>
    <linearGradient id="svgGrad" x1="0%" y1="50%" x2="100%" y2="50%">
      <stop offset="0%" stop-color="#ff7e5f" />
      <stop offset="100%" stop-color="#feb47b" />
    </linearGradient>
  </defs>
  <text x="0" y="45" fill="url(#svgGrad)" font-size="40" font-weight="800">SVG Gradient Text</text>
</svg>

SVG gradients don't take a CSS angle. Their direction is a line from (x1, y1) to (x2, y2), measured across the text's bounding box — so 0%, 50% → 100%, 50% runs left to right, the same as 90deg, and 0%, 100% → 100%, 0% runs bottom-left to top-right, close to 45deg. That's the default direction, but spelling it out makes it easy to change.

The trade-off: SVG <text> doesn't wrap onto new lines, and it's harder to style from your main stylesheet — so keep it for short, fixed pieces of text. If SVG is new to you, the SVG essentials guide is a good starting point.

Choosing a Technique

TechniquePerformanceBrowser SupportBest Use CaseComplexity
Linear gradientExcellentExcellentHeadings, UI highlightsEasy
Radial/conicExcellentExcellentDecorative UIEasy
Animated gradientGoodExcellentHero sectionsMedium
Stroke gradientGoodGoodLogos, display textMedium
Shadow layeringGoodExcellentHeadings with depthMedium
Blend modeFairGoodCreative designsHard
Image/GIF/videoFairGoodPortfolios, landing pagesMedium
@property animationGoodGoodRotating or shifting huesMedium
SVG gradientExcellentExcellentLogos, production assetsMedium

When in doubt, start with a linear gradient and the @supports fallback — it covers the vast majority of real use cases.

Accessibility and Performance

Check Contrast at Every Stop

A gradient's contrast isn't one number. Every color stop has to stand out against the background, and the lightest stop (on a light page) or the darkest stop (on a dark page) is the one that fails first. Check each stop with a contrast checker and aim for at least WCAG AA: 4.5:1 for body-size text, or 3:1 for large text.

Respect Reduced Motion

Some visitors turn on their operating system's "reduce motion" setting because constant movement causes them discomfort. Stopping an animated gradient for them costs one media query:

@media (prefers-reduced-motion: reduce) {
  .animated-gradient,
  .rotating-gradient,
  .shifting-gradient {
    animation: none;
  }
}

Include every animated variant — the @property versions move just as much as the panning one.

The keyframes guide covers this in more depth in Respecting Motion Preferences.

Plan for Forced Colors Mode

In Windows High Contrast (forced colors) mode, the browser replaces your colors with the user's chosen system palette. Depending on how a browser applies that, a transparent fill over a background image can end up invisible or low-contrast. An explicit reset makes the outcome predictable:

@media (forced-colors: active) {
  .gradient-text {
    background: none;
    -webkit-text-fill-color: currentColor;
  }
}

Keep It Real Text

Gradient text is still real text — it can be selected, translated, found with Ctrl+F, and read by screen readers. That's a big advantage over baking the effect into an image, so keep the words in the HTML rather than in a PNG.

Performance Considerations

  • Static gradient text is cheap. It's painted once, like any other background.
  • Animated gradients repaint on every frame. Animating background-position (or a @property value) can't be handed off to the GPU the way transform and opacity can, so the text is repainted continuously. Keep animations to short headings, never long blocks of text.
  • will-change won't make it free. will-change: background-position can move the element onto its own layer so each repaint touches less of the page, but the text still repaints every frame. Use it sparingly, and only on elements that are actually animating.

For why some properties are cheap to animate and others aren't, see Why GPU-Accelerated Properties Are Cheap and The will-change Property.

When Not to Use Gradient Text

While gradient text is visually appealing, it isn't always the right choice. Avoid it for:

  • Long paragraphs: gradients reduce readability and cause eye strain over long runs of text.
  • Low-contrast backgrounds: some part of the gradient will almost certainly fail contrast.
  • Critical UI elements: buttons, forms, and navigation should stay instantly readable.
  • Performance-sensitive pages: skip heavy animations on pages aimed at low-end devices.
  • Formal or professional interfaces: overuse can feel distracting.

Real-World Example: Gradient Text in My Hero Section

The techniques in this post aren't just demos — they're used in production in this blog's homepage hero section.

If you visit the homepage, you'll see an animated gradient applied to the main heading:

<section class="hero">
  <div class="hero-content">
    <h1 class="hero-heading">Hi, I'm Harshal LADHE 👋</h1>
    <div class="hero-subheading">A showcase of my technical blog posts, coding insights, and open source projects.</div>
    <p class="hero-typed">
      I'm ...
    </p>
  </div>
</section>

Animated Gradient on the Main Heading

The .hero-heading uses a flowing linear gradient with the background animation from Animated Gradient Text:

.hero-heading {
  background: linear-gradient(
    90deg,
    var(--color-fg-attention),
    var(--color-fg-success),
    var(--color-fg-danger),
    var(--color-fg-accent),
    var(--color-fg-attention)
  );
  background-size: 200% auto;
  -webkit-background-clip: text;
  background-clip: text;
  -webkit-text-fill-color: transparent;
  color: transparent;
  animation: bg-slide 3s linear infinite;
}

Notice that the first and last stops are both --color-fg-attention, which is what makes the loop seamless — a smooth sliding gradient that makes a strong first impression.

Gradient Highlight for Typed Roles

The dynamically typed roles use a separate gradient treatment:

.hero-highlight {
  background-image: linear-gradient(
    45deg,
    var(--color-fg-danger) 20%,
    var(--color-fg-success) 45%,
    var(--color-fg-attention) 70%
  );
  background-clip: text;
  -webkit-text-fill-color: transparent;
  color: transparent;
}

This version uses the site's color variables so it follows the light and dark themes, skips animation (the typing effect already provides motion), and keeps contrast strong for readability.

Interactive Playground

Try every technique from this post in one live example:

  • Essentials: linear, radial, conic, animated, and stroke gradient text.
  • Advanced: shadows (drop-shadow(), the layered text-shadow trick, and the broken plain text-shadow for comparison), blend modes over a texture, image and animated-image fills, the video knockout, the fixed "window" effect, and @property rotation and color shifting.
  • Pitfalls & Fixes: the multi-line wrap fix, clipped descenders, the background shorthand reset, an @supports guard tested against a simulated browser that can't clip, and the SVG fail-safe.

Drag the angle and color controls to see CSS variables update the gradient in real time. The chips under the preview check each color stop's contrast, and the panel below always shows the CSS for exactly what you're looking at. The Production CSS options can add CSS variables, fluid sizing, and a forced-colors reset before you copy it.

Gradient Text Playground

Switch between linear, animated, and stroke gradient text, tune the angle and colors live, and toggle the multi-line wrap fix to see box-decoration-break in action.

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Gradient Text Playground</title>
  </head>
  <body>
    <main class="playground">
      <header class="playground-header">
        <h1>Gradient Text Playground</h1>
        <p>Every technique from the post in one place — pick one, tune the angle and colors live, and copy the CSS for exactly what you see.</p>
      </header>

      <section class="demo-area" aria-label="Gradient text preview">
        <!-- Most techniques: one clipped heading. -->
        <div class="stage" id="text-stage">
          <h2 class="text-demo"><span class="gradient-demo" id="gradient-demo">Gradient Text</span></h2>
        </div>

        <!-- Window effect: several headings in a scroll box, all cut from one viewport-pinned gradient. -->
        <div class="stage" id="window-stage" hidden>
          <div class="window-scroll" tabindex="0" aria-label="Scroll to move the headings across the fixed gradient">
            <p>Scroll this box ↓</p>
            <h2 class="text-demo"><span class="gradient-demo mode-window">Window One</span></h2>
            <h2 class="text-demo"><span class="gradient-demo mode-window">Window Two</span></h2>
            <h2 class="text-demo"><span class="gradient-demo mode-window">Window Three</span></h2>
            <h2 class="text-demo"><span class="gradient-demo mode-window">Window Four</span></h2>
          </div>
        </div>

        <!-- Video knockout: the text sits over a real <video>, fed here by a canvas so no file is needed. -->
        <div class="stage" id="video-stage" hidden>
          <div class="video-text">
            <video id="knockout-video" autoplay muted loop playsinline></video>
            <h2>Gradient</h2>
          </div>
        </div>

        <!-- SVG fail-safe: the gradient is part of the graphic, no background clipping involved. -->
        <div class="stage" id="svg-stage" hidden>
          <svg class="svg-demo" viewBox="0 0 400 60" role="img" aria-label="SVG Gradient Text">
            <defs>
              <linearGradient id="svg-grad" gradientUnits="objectBoundingBox">
                <stop offset="0%" id="svg-stop-1" stop-color="#ff6a00" />
                <stop offset="100%" id="svg-stop-2" stop-color="#ee0979" />
              </linearGradient>
            </defs>
            <text x="200" y="45" text-anchor="middle" fill="url(#svg-grad)" font-size="40" font-weight="800" font-family="system-ui, sans-serif">SVG Gradient Text</text>
          </svg>
        </div>
      </section>

      <p class="stage-note" id="stage-note"></p>

      <ul class="contrast" id="contrast" aria-label="Contrast of each color stop against the preview background"></ul>

      <output id="gradient-text-output" class="readout" aria-live="polite"></output>

      <div class="tabs" role="tablist" aria-label="Technique group" id="group-switch">
        <button class="tab tab--active" type="button" data-mode="essentials" role="tab" aria-selected="true">Essentials</button>
        <button class="tab" type="button" data-mode="advanced" role="tab" aria-selected="false">Advanced</button>
        <button class="tab" type="button" data-mode="pitfalls" role="tab" aria-selected="false">Pitfalls &amp; Fixes</button>
      </div>

      <div id="essentials-demo" class="tab-panel tab-panel--active">
        <fieldset class="control-group">
          <legend>Technique</legend>
          <div class="segmented-control" role="radiogroup" aria-label="Essential technique">
            <label class="segment"><input type="radio" name="technique" value="linear" checked><span>Linear</span></label>
            <label class="segment"><input type="radio" name="technique" value="radial"><span>Radial</span></label>
            <label class="segment"><input type="radio" name="technique" value="conic"><span>Conic</span></label>
            <label class="segment"><input type="radio" name="technique" value="animated"><span>Animated</span></label>
            <label class="segment"><input type="radio" name="technique" value="stroke"><span>Stroke</span></label>
          </div>
        </fieldset>
      </div>

      <div id="advanced-demo" class="tab-panel">
        <fieldset class="control-group">
          <legend>Technique</legend>
          <div class="segmented-control" role="radiogroup" aria-label="Advanced technique">
            <label class="segment"><input type="radio" name="technique" value="shadow"><span>Shadow</span></label>
            <label class="segment"><input type="radio" name="technique" value="blend"><span>Blend</span></label>
            <label class="segment"><input type="radio" name="technique" value="image"><span>Image</span></label>
            <label class="segment"><input type="radio" name="technique" value="video"><span>Video</span></label>
            <label class="segment"><input type="radio" name="technique" value="window"><span>Window</span></label>
            <label class="segment"><input type="radio" name="technique" value="property"><span>@property</span></label>
          </div>
        </fieldset>
      </div>

      <div id="pitfalls-demo" class="tab-panel">
        <fieldset class="control-group">
          <legend>Pitfall</legend>
          <div class="segmented-control" role="radiogroup" aria-label="Pitfall">
            <label class="segment"><input type="radio" name="technique" value="wrap"><span>Multi-line</span></label>
            <label class="segment"><input type="radio" name="technique" value="descenders"><span>Descenders</span></label>
            <label class="segment"><input type="radio" name="technique" value="shorthand"><span>Shorthand</span></label>
            <label class="segment"><input type="radio" name="technique" value="supports"><span>@supports</span></label>
            <label class="segment"><input type="radio" name="technique" value="svg"><span>SVG</span></label>
          </div>
        </fieldset>
      </div>

      <fieldset class="control-group" id="options-group">
        <legend>Options</legend>

        <div class="segmented-control" id="shadow-variant" role="radiogroup" aria-label="Shadow approach" hidden>
          <label class="segment"><input type="radio" name="shadow-variant" value="drop-shadow" checked><span>drop-shadow()</span></label>
          <label class="segment"><input type="radio" name="shadow-variant" value="layered"><span>Layered</span></label>
          <label class="segment"><input type="radio" name="shadow-variant" value="text-shadow"><span>text-shadow ✗</span></label>
        </div>

        <div class="segmented-control" id="image-variant" role="radiogroup" aria-label="Image type" hidden>
          <label class="segment"><input type="radio" name="image-variant" value="static" checked><span>Static image</span></label>
          <label class="segment"><input type="radio" name="image-variant" value="animated"><span>Animated (GIF-style)</span></label>
        </div>

        <div class="segmented-control" id="property-variant" role="radiogroup" aria-label="Animated property" hidden>
          <label class="segment"><input type="radio" name="property-variant" value="rotate" checked><span>Rotate angle</span></label>
          <label class="segment"><input type="radio" name="property-variant" value="hues"><span>Shift colors</span></label>
        </div>

        <div class="segmented-control" id="shorthand-variant" role="radiogroup" aria-label="Hover override" hidden>
          <label class="segment"><input type="radio" name="shorthand-variant" value="image" checked><span>background-image ✓</span></label>
          <label class="segment"><input type="radio" name="shorthand-variant" value="shorthand"><span>background ✗</span></label>
        </div>

        <div class="segmented-control" id="supports-variant" role="radiogroup" aria-label="Simulated browser" hidden>
          <label class="segment"><input type="radio" name="supports-variant" value="supported" checked><span>Modern browser</span></label>
          <label class="segment"><input type="radio" name="supports-variant" value="unsupported"><span>No background-clip: text</span></label>
        </div>

        <div class="fields-grid">
          <div class="field field--full" id="angle-field">
            <label for="angle-input" id="angle-label">Angle</label>
            <div class="range-row">
              <span class="range-edge">0deg</span>
              <div class="range-track">
                <input type="range" id="angle-input" min="0" max="360" step="1" value="90">
                <output class="range-bubble" id="angle-value" for="angle-input">90deg</output>
              </div>
              <span class="range-edge">360deg</span>
            </div>
          </div>

          <div class="field" id="focal-x-field" hidden>
            <label for="focal-x-input">Focal point X</label>
            <div class="range-row">
              <span class="range-edge">0%</span>
              <div class="range-track">
                <input type="range" id="focal-x-input" min="0" max="100" step="1" value="30">
                <output class="range-bubble" id="focal-x-value" for="focal-x-input">30%</output>
              </div>
              <span class="range-edge">100%</span>
            </div>
          </div>
          <div class="field" id="focal-y-field" hidden>
            <label for="focal-y-input">Focal point Y</label>
            <div class="range-row">
              <span class="range-edge">0%</span>
              <div class="range-track">
                <input type="range" id="focal-y-input" min="0" max="100" step="1" value="40">
                <output class="range-bubble" id="focal-y-value" for="focal-y-input">40%</output>
              </div>
              <span class="range-edge">100%</span>
            </div>
          </div>

          <div class="field">
            <label for="color-1">Color 1</label>
            <input type="color" id="color-1" value="#ff6a00">
          </div>
          <div class="field">
            <label for="color-2">Color 2</label>
            <input type="color" id="color-2" value="#ee0979">
          </div>

          <div class="field field--full" id="stroke-width-field" hidden>
            <label for="stroke-width-input">Stroke width</label>
            <div class="range-row">
              <span class="range-edge">1px</span>
              <div class="range-track">
                <input type="range" id="stroke-width-input" min="1" max="6" step="1" value="2">
                <output class="range-bubble" id="stroke-width-value" for="stroke-width-input">2px</output>
              </div>
              <span class="range-edge">6px</span>
            </div>
          </div>

          <div class="field field--full" id="shadow-offset-field" hidden>
            <label for="shadow-offset-input">Shadow offset</label>
            <div class="range-row">
              <span class="range-edge">1px</span>
              <div class="range-track">
                <input type="range" id="shadow-offset-input" min="1" max="8" step="1" value="4">
                <output class="range-bubble" id="shadow-offset-value" for="shadow-offset-input">4px</output>
              </div>
              <span class="range-edge">8px</span>
            </div>
          </div>

          <div class="field field--full" id="blend-mode-field" hidden>
            <label for="blend-mode-select">mix-blend-mode</label>
            <select id="blend-mode-select">
              <option value="color-dodge" selected>color-dodge</option>
              <option value="screen">screen</option>
              <option value="overlay">overlay</option>
              <option value="hard-light">hard-light</option>
              <option value="difference">difference</option>
              <option value="luminosity">luminosity</option>
            </select>
          </div>
        </div>

        <label class="checkbox-field" for="multiline-toggle" id="multiline-field" hidden>
          <input type="checkbox" id="multiline-toggle" checked>
          <span>Force multi-line demo text</span>
        </label>
        <label class="checkbox-field" for="wrapfix-toggle" id="wrapfix-field" hidden>
          <input type="checkbox" id="wrapfix-toggle">
          <span>Apply <code>box-decoration-break: clone</code></span>
        </label>
        <label class="checkbox-field" for="descender-fix-toggle" id="descender-fix-field" hidden>
          <input type="checkbox" id="descender-fix-toggle">
          <span>Add <code>line-height</code> + padding fix</span>
        </label>
        <label class="checkbox-field" for="hover-toggle" id="hover-field" hidden>
          <input type="checkbox" id="hover-toggle" checked>
          <span>Hold the <code>:hover</code> state on</span>
        </label>
        <label class="checkbox-field" for="guard-toggle" id="guard-field" hidden>
          <input type="checkbox" id="guard-toggle">
          <span>Guard the gradient with <code>@supports</code></span>
        </label>
      </fieldset>

      <fieldset class="control-group" id="output-group">
        <legend>Production CSS</legend>
        <label class="checkbox-field" for="vars-toggle">
          <input type="checkbox" id="vars-toggle">
          <span>Use CSS variables for the color stops</span>
        </label>
        <label class="checkbox-field" for="fluid-toggle">
          <input type="checkbox" id="fluid-toggle">
          <span>Fluid <code>font-size</code> with <code>clamp()</code></span>
        </label>
        <label class="checkbox-field" for="forced-toggle">
          <input type="checkbox" id="forced-toggle">
          <span>Add a <code>forced-colors</code> reset</span>
        </label>
        <p class="tip">Animated techniques always get a <code>prefers-reduced-motion</code> rule. The chips above check each color stop against the preview background (WCAG AA for large text needs 3:1).</p>
      </fieldset>
    </main>

    <script src="./index.js"></script>
  </body>
</html>

Ln –, Col –HTML13.8 KBUTF-8

Starting sandbox…

No console output yet.

No original version of /index.html to compare against.

Frequently Asked Questions

Conclusion

Mastering gradient text isn't about a single CSS property; it's about understanding how backgrounds, clipping, and fallbacks layer together. Combine background-clip: text with CSS variables, clamp(), @supports, and a reduced-motion guard, and your typography will be both visually striking and resilient.

Start with a simple linear gradient, and only add motion, strokes, or blend modes when they genuinely improve the experience.

If you want the same layering trick applied to borders instead of text, the companion post CSS Gradient Borders builds on the same background-clip idea with padding-box and border-box layers.

The four-line recipe was the easy part — the real work was proving every variant actually renders the way the prose claims. The stroke, blend-mode, and video examples all had to be rebuilt after testing them in a real browser, and the playground's wrap-fix toggle only started doing anything once the gradient moved onto an inline span the flex layout couldn't blockify. I hope flipping that toggle, and watching the readout print the exact CSS behind each change, makes the whole clipping trick click. Thanks for reading all the way to the last gradient stop. 🌈