· Updated on

Create a Gradient Text Reveal on Scroll with Tailwind CSS and JS

Preview of the gradient text reveal effect that we're going to create

For this tutorial, we took inspiration from a beautiful pen created by Jhey Tompkins, where a section made up of text emerges from the darkness upon scrolling. Jhey showed how to combine the background-clip property with scroll-driven animations to create this gradient text unveiling effect.

However, at the time of writing, this technique only works in Chrome and Edge, as the animation-timeline: scroll() property is’t supported in other browsers yet. For this reason – and to have more control over the animation – we have recreated a similar effect with JavaScript and Tailwind CSS.

Creating the HTML

A point of clarification before going on: the text we’re revealing resides in a scrollable section but is fixed on the screen. This means that the reveal effect activates when the parent section intersects the fixed element that contains the text.

With that in mind, let’s define the HTML structure, using the Tailwind classes for styling:

<section class="h-screen flex items-center">
    <div class="fixed w-full top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2">
        <div class="px-4 md:px-6">
            <div class="max-w-5xl mx-auto">
                <div class="font-extrabold text-3xl md:text-4xl text-white">
                    Life is shaped by the total of our actions, a multifaceted tapestry where each and every detail holds and contributes to our individual journey. The books you've read, the kilometers you've run, the moments of joy and connection during every night out, and even the simple act of rehydrating with a glass of water the morning after all intertwine and meld into the intricate fabric of existence. Everything, without exception, counts, and it is this collective mosaic of experiences that invariably molds and sculpts us into the unique individuals we become.
                </div>
                <div class="mt-8 md:flex items-center justify-between space-y-4 md:space-y-0">
                    <div class="flex items-center space-x-4">
                        <img class="rounded-full" src="./author.jpg" width="36" height="36" alt="Author">
                        <div class="text-slate-600"><a class="text-slate-500 font-medium hover:text-slate-400" href="#0">Beatrice Watson</a> · <span class="text-slate-500 italic">Jun 17</span></div>
                    </div>
                    <div class="flex items-center space-x-8">
                        <div class="flex items-center space-x-1 whitespace-nowrap">
                            <button class="text-slate-300 hover:text-slate-50 p-2" aria-label="Like">
                                <svg class="fill-current" xmlns="http://www.w3.org/2000/svg" width="18" height="17">
                                    <path d="M16.428 1.57a5.363 5.363 0 0 1 0 7.586l-7.43 7.429-.707-.707-6.72-6.722A5.363 5.363 0 0 1 8.999 1.42a5.364 5.364 0 0 1 7.429.15Zm-1.415 6.172a3.363 3.363 0 1 0-5.18-4.237l-.834 1.256-.833-1.256a3.363 3.363 0 1 0-5.18 4.237l6.013 6.014 6.014-6.014Z" />
                                </svg>
                            </button>
                            <div class="text-slate-400 font-medium">4.7K Reactions</div>
                        </div>
                        <div class="flex items-center space-x-1 whitespace-nowrap">
                            <button class="text-slate-300 hover:text-slate-50 p-2" aria-label="Comment">
                                <svg class="fill-current" xmlns="http://www.w3.org/2000/svg" width="18" height="18">
                                    <path d="m16.732 17.108-5.546-2.256A8.607 8.607 0 0 1 9 15.09c-4.952 0-9-3.31-9-7.546C0 3.31 4.048 0 9 0s9 3.31 9 7.57a6.972 6.972 0 0 1-1.842 4.556l.574 4.982Zm-2.674-5.73.368-.345A4.96 4.96 0 0 0 16 7.545C16 4.513 12.926 2 9 2S2 4.513 2 7.545c0 3.033 3.074 5.546 7.02 5.546a6.671 6.671 0 0 0 1.961-.253l.331-.094 3.047 1.24-.301-2.606Z" />
                                </svg>
                            </button>
                            <div class="text-slate-400 font-medium">112 Comments</div>
                        </div>
                    </div>
                </div>
            </div>
        </div>
    </div>
</section>

We have a section – we’ll call it the “parent” element from now on -that spans the entire viewport height (h-screen). This section contains a div – i.e. the “child” element – which is positioned in the center of the screen.

Right now, this text is always visible on the screen; we’ll later see how to implement the reveal effect with JavaScript. For the moment, let’s focus on replacing the text’s white color with a more dynamic radial gradient.

Text gradient with Tailwind CSS

To make a text gradient with Tailwind, we would normally add a couple of Tailwind utility classes. However, our gradient is a bit more complex. Not only is it radial – so we need to write a custom class using arbitrary values – but we also have to increase the background-size in order to make the gradient translate during scrolling – and this requires custom stop-colors.

Okay, the classes we’ll use are:

  • bg-clip-text and text-transparent for defining the text color with a background.
  • bg-[radial-gradient(50%_50%_at_50%_50%,theme(colors.purple.50),theme(colors.purple.500)_20%,transparent_50%)] defines a radial gradient that starts with “Purple 50”, changes to “Purple 500” at 20%, and ends being transparent at 50%.
  • bg-[50%_50%] aligns the gradient at the center.
  • bg-[length:400%_800%] allows us to scale up the background.

So, the div element will be modified as follow:

<div class="font-extrabold text-3xl md:text-4xl bg-clip-text text-transparent bg-[radial-gradient(50%_50%_at_50%_50%,theme(colors.purple.50),theme(colors.purple.500)_20%,transparent_50%)] bg-[length:400%_800%] bg-[50%_50%]">

Implementing JavaScript

Now, the trickiest part: adding some JavaScript for the reveal effect. We will create a TextReveal class inside a file named gradient-text-reveal.js, which we’ll then include in our HTML.

The JS class, once instantiated, will take a DOM element as input:

class TextReveal {
  constructor(element) {
    this.element = element;
    this.child = this.element.firstElementChild;
    this.setupReveal = this.setupReveal.bind(this);
    this.init();
  }

  setupReveal() {
    // Code to reveal the text
  }

  init() {
    this.setupReveal();
    window.addEventListener('resize', this.setupReveal);
  }
}

// Init TextReveal
const els = document.querySelectorAll('[data-text-reveal]');
els.forEach((el) => {
  new TextReveal(el);
});

The setupReveal method will manage the reveal effect, on both page load and on window resize via the init method.

Now, to trigger the above code, we need to add a data-text-reveal attribute to the parent section:

<section class="h-[200vh] flex items-center" data-text-reveal>

Checking whether the animation is feasible

Given that our text remains fixed on the screen, what if the fixed div overflows the viewport’s vertical dimensions? This would result in our text being clipped, rendering it non-scrollable, and leading to a less than ideal user experience.

That is why we need a function that checks whether the conditions to perform the animation are met:

class TextReveal {
  constructor(element) {
    this.element = element;
    this.child = this.element.firstElementChild;
    this.canReveal = this.canReveal.bind(this);
    this.getRects = this.getRects.bind(this);
    this.setupReveal = this.setupReveal.bind(this);
    this.init();
  }

  canReveal() {
    const { childRect } = this.getRects();
    // Returns false if the child container is taller than the viewport.
    return childRect.height <= window.innerHeight;
  }

  getRects() {
    return {
      rect: this.element.getBoundingClientRect(),
      childRect: this.child.getBoundingClientRect()
    };
  }  

  setupReveal() {
    if (this.canReveal()) {
      console.log('doable');
    } else {
      console.log('not doable');
    }
  }
  
  init() {
    this.setupReveal();
    window.addEventListener('resize', this.setupReveal);
  }
}

// Init TextReveal
const els = document.querySelectorAll('[data-text-reveal]');
els.forEach((el) => {
  new TextReveal(el);
});

We created two methods:

  • getRects() returns the coordinates of both the parent section and the fixed div in relation to the viewport.
  • canReveal() returns true if the fixed div’s height is less than or equal to the viewport’s height.

Now, when resizing the browser window, the console will tell us whether the animation is feasible or not. If the animation isn’t possible, we’ll change the positioning of the container element from fixed to relative.

Let’s update the setupReveal method:

setupReveal() {
  if (this.canReveal()) {
    this.handleScroll();
    window.addEventListener('scroll', this.handleScroll);
    console.log('doable');
    // Remove the inline styles if previously set
    this.child.style.position = '';
    this.child.style.top = '';
    this.child.style.left = '';
    this.child.style.transform = '';
    this.element.style.height = '';      
  } else {
    window.removeEventListener('scroll', this.handleScroll);
    console.log('not doable');
    // Set some inline styles if the effect isn't doable
    this.child.style.position = 'relative';
    this.child.style.top = '0';
    this.child.style.left = '0';
    this.child.style.transform = 'translate3d(0,0,0)';
    this.element.style.height = 'auto';      
  }
}

Computing a “reveal value”

The next step is writing a JavaScript function that, in response to scrolling, computes a “reveal value”. This numeric value will be used to set a CSS variable determining the gradient background’s positioning.

Let’s create this method:

calculateRevealValue() {
  const { rect, childRect } = this.getRects(); 

  if (!this.canReveal()) return 1;

  // Calculate the intersection value based on the provided conditions
  if (rect.top <= childRect.top && rect.bottom >= childRect.top) {
    const totalHeightDifference = rect.height - childRect.height;
    const currentHeightDifference = childRect.top - rect.top;
    this.revealValue = currentHeightDifference / totalHeightDifference;
  } else if (rect.bottom < childRect.top || Math.abs(childRect.top - rect.bottom) < 0.01) {
    this.revealValue = 1;
  } else {
    this.revealValue = 0;
  }

  // Clamp the value between 0 and 1
  this.revealValue = Math.max(0, Math.min(1, this.revealValue));

  return this.revealValue;
}

This function evaluates the position of both the fixed-positioned element and its parent container, relative to the viewport. Through a series of conditions, it returns a revealValue, which is a number oscillating between 0 to 1.

This value needs to be updated every time there’s a scroll event. Thus, we’ll create a new method:

handleScroll() {
  this.revealValue = this.calculateRevealValue();
  console.log(this.revealValue);
}

Assembling all we’ve done so far:

class TextReveal {
  constructor(element) {
    this.element = element;
    this.child = this.element.firstElementChild;
    this.revealValue = 0;
    this.opacityValue = 0;
    this.canReveal = this.canReveal.bind(this);
    this.getRects = this.getRects.bind(this);
    this.calculateRevealValue = this.calculateRevealValue.bind(this);
    this.handleScroll = this.handleScroll.bind(this);
    this.setupReveal = this.setupReveal.bind(this);
    this.init();
  }

  canReveal() {
    const { childRect } = this.getRects();
    // Returns false if the child container is taller than the viewport.
    return childRect.height <= window.innerHeight;
  }

  getRects() {
    return {
      rect: this.element.getBoundingClientRect(),
      childRect: this.child.getBoundingClientRect()
    };
  }

  calculateRevealValue() {
    const { rect, childRect } = this.getRects();

    if (!this.canReveal()) return 1;

    // Calculate the intersection value based on the provided conditions
    if (rect.top <= childRect.top && rect.bottom >= childRect.top) {
      const totalHeightDifference = rect.height - childRect.height;
      const currentHeightDifference = childRect.top - rect.top;
      this.revealValue = currentHeightDifference / totalHeightDifference;
    } else if (rect.bottom < childRect.top || Math.abs(childRect.top - rect.bottom) < 0.01) {
      this.revealValue = 1;
    } else {
      this.revealValue = 0;
    }

    // Clamp the value between 0 and 1
    this.revealValue = Math.max(0, Math.min(1, this.revealValue));

    return this.revealValue;
  }

  handleScroll() {
    this.revealValue = this.calculateRevealValue();
    console.log(this.revealValue);
  }

  setupReveal() {
    if (this.canReveal()) {
      this.handleScroll();
      window.addEventListener('scroll', this.handleScroll);
      console.log('doable');
      // Remove the inline styles if previously set
      this.child.style.position = '';
      this.child.style.top = '';
      this.child.style.left = '';
      this.child.style.transform = '';
      this.element.style.height = '';      
    } else {
      window.removeEventListener('scroll', this.handleScroll);
      console.log('not doable');
      // Set some inline styles if the effect isn't doable
      this.child.style.position = 'relative';
      this.child.style.top = '0';
      this.child.style.left = '0';
      this.child.style.transform = 'translate3d(0,0,0)';
      this.element.style.height = 'auto';      
    }
  }

  init() {
    this.setupReveal();
    window.addEventListener('resize', this.setupReveal);
  }
}

// Init TextReveal
const els = document.querySelectorAll('[data-text-reveal]');
els.forEach((el) => {
  new TextReveal(el);
});

Test it out! If you scroll in the browser while monitoring the console, you’ll see the revealValue gradually shifting from 0 to 1. It starts at 0 on page load. As the fixed element interacts with its container, the value increases until it maxes out at 11 is reached when the child element crosses the parent element’s lower boundary.

Creating a CSS-driven animation

To modigy the position of the gradient background during scrolling, we’ll set a CSS variable named --reveal-value, linked to revealValue:

handleScroll() {
  this.revealValue = this.calculateRevealValue();
  this.element.style.setProperty('--reveal-value', this.revealValue);
}

setupReveal() {
  if (this.canReveal()) {
    this.handleScroll();
    window.addEventListener('scroll', this.handleScroll);
    // Remove the inline styles if previously set
    this.child.style.position = '';
    this.child.style.top = '';
    this.child.style.left = '';
    this.child.style.transform = '';
    this.element.style.height = '';      
  } else {
    window.removeEventListener('scroll', this.handleScroll);
    this.element.style.setProperty('--reveal-value', 0.5);
    // Set some inline styles if the effect isn't doable
    this.child.style.position = 'relative';
    this.child.style.top = '0';
    this.child.style.left = '0';
    this.child.style.transform = 'translate3d(0,0,0)';
    this.element.style.height = 'auto';      
  }
}

By default, when animation isn’t achievable, --reveal-value will default to 0.5. Otherwise, it’ll reflect revealValue.

Now, to animate the gradient, we’ll have to tweak our HTML. Replace the class defining the background position from bg-[50%_50%] to bg-[50%_calc(100%*var(--reveal-value))]. By doing this, the value of --reveal-value will control the background’s placement.

This results in:

  • If --reveal-value is 0, the background-position becomes 50% 0%, pushing the background’s center downward.
  • If --reveal-value is 1, the background-position is 50% 100%, moving the background’s center upward.
  • If --reveal-value is 0.5, the background-position stands at 50% 50%, centering the background perfectly with the text element.

And voilà! You now have an animated background effect.

One last tweak: to keep the text in a fixed position for a more extended scroll, just double the container element’s height by replacing h-screen with h-[200vh].

To make the overall effect more interesting, we will also animate the opacity of the footer elements. To do this, we’ll introduce a new variable, --opacity-value, and make necessary changes in the setupReveal and handleScroll methods:

handleScroll() {
  this.revealValue = this.calculateRevealValue();
  this.element.style.setProperty('--reveal-value', this.revealValue);

  if (this.revealValue >= 0.3 && this.revealValue <= 0.7) {
    this.opacityValue = 1;
  } else if (this.revealValue >= 0.2 && this.revealValue < 0.3) {
    this.opacityValue = 10 * (this.revealValue - 0.2);
  } else if (this.revealValue > 0.7 && this.revealValue <= 0.8) {
    this.opacityValue = 1 - 10 * (this.revealValue - 0.7);
  } else {
    this.opacityValue = 0;
  }
  this.element.style.setProperty('--opacity-value', this.opacityValue);
}

setupReveal() {
  if (this.canReveal()) {
    this.handleScroll();
    window.addEventListener('scroll', this.handleScroll);
    // Remove the inline styles if previously set
    this.child.style.position = '';
    this.child.style.top = '';
    this.child.style.left = '';
    this.child.style.transform = '';
    this.element.style.height = '';
  } else {
    window.removeEventListener('scroll', this.handleScroll);
    this.element.style.setProperty('--reveal-value', 0.5);
    this.element.style.setProperty('--opacity-value', 1);
    // Set some inline styles if the effect isn't doable
    this.child.style.position = 'relative';
    this.child.style.top = '0';
    this.child.style.left = '0';
    this.child.style.transform = 'translate3d(0,0,0)';
    this.element.style.height = 'auto';
  }
}

We’ve used the current revealValue variable to determine the value of --opacity-value, adding conditions to give more dynamism to the overall effect.

Lastly, update your HTML by appending the class opacity-[var(--opacity-value)] to the div containing the footer elements.

With that we are done. Here is the final HTML code:

<section class="h-[200vh] flex items-center" data-text-reveal>
    <div class="fixed w-full top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2">
        <div class="px-4 md:px-6">
            <div class="max-w-5xl mx-auto">
                <div class="font-extrabold text-3xl md:text-4xl bg-clip-text text-transparent bg-[radial-gradient(50%_50%_at_50%_50%,theme(colors.purple.50),theme(colors.purple.500)_20%,transparent_50%)] bg-[length:400%_800%] bg-[50%_calc(100%*var(--reveal-value))]">
                    Life is shaped by the total of our actions, a multifaceted tapestry where each and every detail holds and contributes to our individual journey. The books you've read, the kilometers you've run, the moments of joy and connection during every night out, and even the simple act of rehydrating with a glass of water the morning after all intertwine and meld into the intricate fabric of existence. Everything, without exception, counts, and it is this collective mosaic of experiences that invariably molds and sculpts us into the unique individuals we become.
                </div>
                <div class="mt-8 md:flex items-center justify-between space-y-4 md:space-y-0 opacity-[var(--opacity-value)]">
                    <div class="flex items-center space-x-4">
                        <img class="rounded-full" src="./author.jpg" width="36" height="36" alt="Author">
                        <div class="text-slate-600"><a class="text-slate-500 font-medium hover:text-slate-400" href="#0">Beatrice Watson</a> · <span class="text-slate-500 italic">Jun 17</span></div>
                    </div>
                    <div class="flex items-center space-x-8">
                        <div class="flex items-center space-x-1 whitespace-nowrap">
                            <button class="text-slate-300 hover:text-slate-50 p-2" aria-label="Like">
                                <svg class="fill-current" xmlns="http://www.w3.org/2000/svg" width="18" height="17">
                                    <path d="M16.428 1.57a5.363 5.363 0 0 1 0 7.586l-7.43 7.429-.707-.707-6.72-6.722A5.363 5.363 0 0 1 8.999 1.42a5.364 5.364 0 0 1 7.429.15Zm-1.415 6.172a3.363 3.363 0 1 0-5.18-4.237l-.834 1.256-.833-1.256a3.363 3.363 0 1 0-5.18 4.237l6.013 6.014 6.014-6.014Z" />
                                </svg>
                            </button>
                            <div class="text-slate-400 font-medium">4.7K Reactions</div>
                        </div>
                        <div class="flex items-center space-x-1 whitespace-nowrap">
                            <button class="text-slate-300 hover:text-slate-50 p-2" aria-label="Comment">
                                <svg class="fill-current" xmlns="http://www.w3.org/2000/svg" width="18" height="18">
                                    <path d="m16.732 17.108-5.546-2.256A8.607 8.607 0 0 1 9 15.09c-4.952 0-9-3.31-9-7.546C0 3.31 4.048 0 9 0s9 3.31 9 7.57a6.972 6.972 0 0 1-1.842 4.556l.574 4.982Zm-2.674-5.73.368-.345A4.96 4.96 0 0 0 16 7.545C16 4.513 12.926 2 9 2S2 4.513 2 7.545c0 3.033 3.074 5.546 7.02 5.546a6.671 6.671 0 0 0 1.961-.253l.331-.094 3.047 1.24-.301-2.606Z" />
                                </svg>
                            </button>
                            <div class="text-slate-400 font-medium">112 Comments</div>
                        </div>
                    </div>
                </div>
            </div>
        </div>
    </div>
</section>

And, the accompanying JavaScript:

class TextReveal {
  constructor(element) {
    this.element = element;
    this.child = this.element.firstElementChild;
    this.revealValue = 0;
    this.opacityValue = 0;
    this.canReveal = this.canReveal.bind(this);
    this.getRects = this.getRects.bind(this);
    this.calculateRevealValue = this.calculateRevealValue.bind(this);
    this.handleScroll = this.handleScroll.bind(this);
    this.setupReveal = this.setupReveal.bind(this);
    this.init();
  }

  canReveal() {
    const { childRect } = this.getRects();
    // Returns false if the child container is taller than the viewport.
    return childRect.height <= window.innerHeight;
  }

  getRects() {
    return {
      rect: this.element.getBoundingClientRect(),
      childRect: this.child.getBoundingClientRect()
    };
  }

  calculateRevealValue() {
    const { rect, childRect } = this.getRects();

    if (!this.canReveal()) return 1;

    // Calculate the intersection value based on the provided conditions
    if (rect.top <= childRect.top && rect.bottom >= childRect.top) {
      const totalHeightDifference = rect.height - childRect.height;
      const currentHeightDifference = childRect.top - rect.top;
      this.revealValue = currentHeightDifference / totalHeightDifference;
    } else if (rect.bottom < childRect.top || Math.abs(childRect.top - rect.bottom) < 0.01) {
      this.revealValue = 1;
    } else {
      this.revealValue = 0;
    }

    // Clamp the value between 0 and 1
    this.revealValue = Math.max(0, Math.min(1, this.revealValue));

    return this.revealValue;
  }

  handleScroll() {
    this.revealValue = this.calculateRevealValue();
    this.element.style.setProperty('--reveal-value', this.revealValue);

    if (this.revealValue >= 0.3 && this.revealValue <= 0.7) {
      this.opacityValue = 1;
    } else if (this.revealValue >= 0.2 && this.revealValue < 0.3) {
      this.opacityValue = 10 * (this.revealValue - 0.2);
    } else if (this.revealValue > 0.7 && this.revealValue <= 0.8) {
      this.opacityValue = 1 - 10 * (this.revealValue - 0.7);
    } else {
      this.opacityValue = 0;
    }
    this.element.style.setProperty('--opacity-value', this.opacityValue);
  }

  setupReveal() {
    if (this.canReveal()) {
      this.handleScroll();
      window.addEventListener('scroll', this.handleScroll);
      // Remove the inline styles if previously set
      this.child.style.position = '';
      this.child.style.top = '';
      this.child.style.left = '';
      this.child.style.transform = '';
      this.element.style.height = '';
    } else {
      window.removeEventListener('scroll', this.handleScroll);
      this.element.style.setProperty('--reveal-value', 0.5);
      this.element.style.setProperty('--opacity-value', 1);
      // Set some inline styles if the effect isn't doable
      this.child.style.position = 'relative';
      this.child.style.top = '0';
      this.child.style.left = '0';
      this.child.style.transform = 'translate3d(0,0,0)';
      this.element.style.height = 'auto';
    }
  }

  init() {
    this.setupReveal();
    window.addEventListener('resize', this.setupReveal);
  }
}

// Init TextReveal
const els = document.querySelectorAll('[data-text-reveal]');
els.forEach((el) => {
  new TextReveal(el);
});

Conclusions

If you want your website to stand out from the others, you need to create interesting and engaging experiences. That’s why we’ve been taking a more experimental approach in our latest tutorials. We hope you’re enjoying this fresh perspective! Oh, and tell us if you’d like to see something similar in one of our Tailwind templates.