Dynamic CSS Masks with Custom Properties and GSAP

Learn how to animate CSS masks based on the cursor position using GSAP and custom properties for a unique spotlight effect.

I recently redesigned my personal website to include a fun effect in the hero area, where the user’s cursor movement reveals alternative styling on the title and background, reminiscent of a spotlight. In this article we’ll walk through how the effect was created, using masks, CSS custom properties and much more.

Duplicating the content

We start with HTML to create two identical hero sections, with the title repeated.

<div class="wrapper">
 <div class="hero">
  <h1 class="hero__heading">Welcome to my website</h1>
 </div>

 <div class="hero hero--secondary" aria-hidden="true">
  <p class="hero__heading">Welcome to my website</p>
 </div>
</div>

Duplicating content isn’t a great experience for someone accessing the website using a screenreader. In order to prevent screenreaders announcing it twice, we can use aria-hidden="true" on the second component. The second component is absolute-positioned with CSS to completely cover the first.

Hero section with bright gradient positioned over the dark section
The two sections with the same content are layered one on top of the other

Using pointer-events: none on the second component ensures that the text of the first will be selectable by users.

Styling

Now we can add some CSS to style the two components. I deliberately chose a bright, rich gradient for the “revealed” background, in contrast to the dark monochrome of the initial view.

Somewhat counterintuitively, the component with the bright background is actually the one that will cover the other. In a moment we’ll add the mask, so that parts of it will be hidden — which is what gives the impression of it being underneath.

Text effects

There are a couple of different text effects at play in this component. The first applies to the bright text on the dark background. This uses -webkit-text-stroke, a non-standard CSS property that is nonetheless supported in all modern browsers. It allows us to outline our text, and works great with bold, chunky fonts like the one we’re using here. It requires a prefix in all browsers, and can be used as shorthand for -webkit-stroke-width and -webkit-stroke-color.

In order to get the “glow” effect, we can set the text color to a transparent value and use the CSS drop-shadow filter with the same color value. (We’re using a CSS custom property for the color in this example):

.heading {
  -webkit-text-stroke: 2px var(--primary);
  color: transparent;
  filter: drop-shadow(0 0 .35rem var(--primary));
}

See the Pen Outlined text by Michelle Barker (@michellebarker) on CodePen.

The text on the colored panel has a different effect applied. The intention was, for it to feel a little like an x-ray revealing the skeleton underneath. The text fill has a dotted pattern, which is created using a repeated radial gradient. To get this effect on the text, we in fact apply it to the background of the element, and use background-clip: text, which also requires a prefix in most browsers (at the time of writing). Again, we need to set the text color to a transparent value in order to see the result of the background-clip property:

.hero--secondary .heading {
  background: radial-gradient(circle at center, white .11rem, transparent 0);
  background-size: .4rem .4rem;
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
}

See the Pen Dotted text by Michelle Barker (@michellebarker) on CodePen.

Creating the spotlight

We have two choices when it comes to creating the spotlight effect with CSS: clip-path and mask-image. These can produce very similar effects, but with some important differences.

Clipping

We can think of clipping a shape with clip-path as a bit like cutting it out with scissors. This is ideal for shapes with clean lines. In this case, we could create a circle shape for our spotlight, using the circle() function:

.hero--secondary {
  --clip: circle(20% at 70%);
  -webkit-clip-path: var(--clip);
  clip-path: var(--clip);
}

(clip-path still needs to be prefixed in Safari, so I like to use a custom property for this.)

clip-path can also take an ellipse, polygon, SVG path or a URL with an SVG path ID.

See the Pen Hero with clip-path by Michelle Barker (@michellebarker) on CodePen.

Masking

Unlike clip-path the mask-image property is not limited to shapes with clean lines. We can use a PNGs, SVGs or even GIFs to create a mask. We can even use gradients: the blacker parts of the image (or gradient) act as the mask whereas the element will be hidden by the transparent parts.

We can use a radial gradient to create a mask very similar to the clip-path circle:

.hero--secondary {
  --mask: radial-gradient(circle at 70%, black 25%, transparent 0);
  -webkit-clip-path: var(--mask);
  clip-path: var(--mask);
}

Another advantage is that there are additional mask properties than correspond to CSS background properties — so we can control the size and position of the mask, and whether or not it repeats in much the same way, with mask-size, mask-position and mask-repeat respectively.

See the Pen Hero with mask by Michelle Barker (@michellebarker) on CodePen.

There’s much more we could delve into with clipping and masking, but let’s leave that for another day! I chose to use a mask instead of a clip-path for this project — hopefully the reason will become clear a little later on.

Tracking the cursor

Now we have our mask, it’s a matter of tracking the position of the user’s cursor, for which we’ll need some Javascript. First we can set custom properties for the center co-ordinates of our gradient mask. We can use default values, to give the mask an initial position before the JS is executed. This will also ensure that non-mouse users see a static mask, rather than none at all.

.hero--secondary {
  --mask: radial-gradient(circle at var(--x, 70%) var(--y, 50%), black 25%, transparent 0);
}

In our JS, we can listen for the mousemove event, then update the custom properties for the x and y percentage position of the circle in accordance with the cursor position:

const hero = document.querySelector('[data-hero]')

window.addEventListener('mousemove', (e) => {
  const { clientX, clientY } = e
  const x = Math.round((clientX / window.innerWidth) * 100)
  const y = Math.round((clientY / window.innerHeight) * 100)
	
  hero.style.setProperty('--x', `${x}%`)
  hero.style.setProperty('--y', `${y}%`)
})

See the Pen Hero with cursor tracking by Michelle Barker (@michellebarker) on CodePen.

(For better performance, we might want to throttle or debounce that function, or use requestAnimationFrame, to prevent it repeating too frequently. If you’re not sure which to use, this article has you covered.)

Adding animation

At the moment there is no easing on the movement of the spotlight — it immediately updates its position when the mouse it moved, so feels a bit rigid. We could remedy that with a bit of animation.

If we were using clip-path we could animate the path position with a transition:

.hero--secondary {
  --clip: circle(25% at 70%);
  -webkit-clip-path: var(--clip);
  clip-path: var(--clip);
  transition: clip-path 300ms 20ms;
}

Animating a mask requires a different route.

Animating with CSS Houdini

In CSS we can transition or animate custom property values using Houdini – a set of low-level APIs that give developers access to the browser’s rendering engine. The upshot is we can animate properties (or, more accurately, values within properties, in this case) that aren’t traditionally animatable.

We first need to register the property, specifying the syntax, whether or not it inherits, and an initial value. The initial-value property is crucial, otherwise it will have no effect.

@property --x {
  syntax: '<percentage>';
  inherits: true;
  initial-value: 70%;
}

Then we can transition or animate the custom property just like any regular animatable CSS property. For our spotlight, we can transition the --x and --y values, with a slight delay, to make them feel more natural:

.hero--secondary {
  transition: --x 300ms 20ms ease-out, --y 300ms 20ms ease-out;
}

See the Pen Hero with cursor tracking (with Houdini animation) by Michelle Barker (@michellebarker) on CodePen.

Unfortunately, @property is only supported in Chromium browsers at the time of writing. If we want an improved animation in all browsers, we could instead reach for a JS library.

Animating with GSAP

In CSS we can transition or animate custom property values using Houdini –I love using the Greensock(GSAP) JS animation library. It has an intuitive API, and contains plenty of easing options, all of which makes animating UI elements easy and fun! As I was already using it for other parts of the project, it was a simple decision to use it here to bring some life to the spotlight. Instead of using setProperty we can let GSAP take care of setting our custom properties, and configure the easing using the built in options:

import gsap from 'gsap'

const hero = document.querySelector('[data-hero]')

window.addEventListener('mousemove', (e) => {
  const { clientX, clientY } = e
  const x = Math.round((clientX / window.innerWidth) * 100)
  const y = Math.round((clientY / window.innerHeight) * 100)
	
  gsap.to(hero, {
    '--x': `${x}%`,
    '--y': `${y}%`,
    duration: 0.3,
    ease: 'sine.out'
  })
})

See the Pen Hero with cursor tracking (GSAP) by Michelle Barker (@michellebarker) on CodePen.

Animating the mask with a timeline

The mask on my website’s hero section is slightly more elaborate than a simple spotlight. We start with a single circle, then suddenly another circle “pops” out of the first, surrounding it. To get an effect like this, we can once again turn to custom properties, and animate them on a GSAP timeline.

Our radial gradient mask becomes a little more complex: We’re creating a gradient of two concentric circles, but setting the initial values of the gradient stops to 0% (via the default values in our custom properties), so that their size can be animated with JS:

.hero {
  --mask: radial-gradient(
    circle at var(--x, 50%) var(--y, 50%),
    black var(--maskSize1, 0%), 
    transparent 0, 
    transparent var(--maskSize2, 0%),
    black var(--maskSize2, 0%), 
    black var(--maskSize3, 0%), 
    transparent 0);
}

Our mask will be invisible at this point, as the circle created with the gradient has a size of 0%. Now we can create a timeline with GSAP, so the central spot will spring to life, followed by the second circle. We’re also adding a delay of one second before the timeline starts to play.

const tl = gsap.timeline({ delay: 1 })

tl
  .to(hero, {
    '--maskSize1': '20%',
    duration: 0.5,
    ease: 'back.out(2)'
  })
  .to(hero, {
    '--maskSize2': '28%',
     '--maskSize3': 'calc(28% + 0.1rem)',
    duration: 0.5,
    delay: 0.5,
    ease: 'back.out(2)'
})

See the Pen Hero with cursor tracking (GSAP) by Michelle Barker (@michellebarker) on CodePen.

Using a timeline, our animations will execute one after the other. GSAP offers plenty of options for orchestrating the timing of animations with timelines, and I urge you to explore the documentation to get a taste of the possibilities. You won’t be disappointed!

Smoothing the gradient

For some screen resolutions, a gradient with hard color stops can result in jagged edges. To avoid this we can add some additional color stops with fractional percentage values:

.hero {
  --mask: radial-gradient(
    circle at var(--x, 50%) var(--y, 50%),
    black var(--maskSize1, 0%) 0,
    rgba(0, 0, 0, 0.1) calc(var(--maskSize1, 0%) + 0.1%),
    transparent 0,
    transparent var(--maskSize2, 0%),
    rgba(0, 0, 0, 0.1) calc(var(--maskSize2, 0%) + 0.1%),
    black var(--maskSize2, 0%),
    rgba(0, 0, 0, 0.1) calc(var(--maskSize3, 0%) - 0.1%),
    black var(--maskSize3, 0%),
    rgba(0, 0, 0, 0.1) calc(var(--maskSize3, 0%) + 0.1%),
    transparent 0
  );
}

This optional step results in a smoother-edged gradient. You can read more about this approach in this article by Mandy Michael.

A note on default values

While testing this approach, I initially used a default value of 0 for the custom properties. When creating the smoother gradient, it turned out that the browser didn’t compute those zero values with calc, so the mask wouldn’t be applied at all until the values were updated with JS. For this reason, I’m setting the defaults as 0% instead, which works just fine.

Creating the menu animation

There’s one more finishing touch to the hero section, which is a bit of visual trickery: When the user clicks on the menu button, the spotlight expands to reveal the full-screen menu, seemingly underneath it. To create this effect, we need to give the menu an identical background to the one on our masked element.

:root {
  --gradientBg: linear-gradient(45deg, turquoise, darkorchid, deeppink, orange);
}

.hero--secondary {
  background: var(--gradientBg);
}

.menu {
  background: var(--gradientBg);
}

The menu is absolute-positioned, the same as the masked hero element, so that it completely overlays the hero section.

Then we can use clip-path to clip the element to a circle 0% wide. The clip path is positioned to align with the menu button, at the top right of the viewport. We also need to add a transition, for when the menu is opened.

.menu {
  background: var(--gradientBg);
  clip-path: circle(0% at calc(100% - 2rem) 2rem);
  transition: clip-path 500ms;
}

When a user clicks the menu button, we’ll use JS to apply a class of .is-open to the menu.

const menuButton = document.querySelector('[data-btn="menu"]')
const menu = document.querySelector('[data-menu]')

menuButton.addEventListener('click', () => {
  menu.classList.toggle('is-open')
})

(In a real project there’s much more we would need to do to make our menu fully accessible, but that’s beyond the scope of this article.)

Then we need to add a little more CSS to expand our clip-path so that it reveals the menu in its entirety:

.menu.is-open {
  clip-path: circle(200% at calc(100% - 2rem) 2rem);
}

See the Pen Hero with cursor tracking and menu by Michelle Barker (@michellebarker) on CodePen.

Text animation

In the final demo, we’re also implementing a staggered animation on the heading, before animating the spotlight into view. This uses Splitting.js to split the text into <span> elements. As it assigns each character a custom property, it’s great for CSS animations. The GSAP timeline however, is a more convenient way to implement the staggered effect in this case, as it means we can let the timeline handle when to start the next animation after the text finishes animating. We’ll add that to the beginning of our timeline:

// Set initial text styles (before animation)
gsap.set(".hero--primary .char", {
  opacity: 0,
  y: 25,
});

/* Timeline */
const tl = gsap.timeline({ delay: 1 });

tl
  .to(".hero--primary .char", {
    opacity: 1,
    y: 0,
    duration: 0.75,
    stagger: 0.1,
  })
  .to(hero, {
    "--maskSize1": "20%",
    duration: 0.5,
    ease: "back.out(2)",
  })
  .to(hero, {
    "--maskSize2": "28%",
    "--maskSize3": "calc(28% + 0.1rem)",
    duration: 0.5,
    delay: 0.3,
    ease: "back.out(2)",
  });

I hope this inspires you to play around with CSS masks and the fun effects that can be created!

The full demo

Michelle Barker

Michelle is a Senior Front End Developer at Ada Mode, where she builds web apps and data visualisations for the renewable energy industry. She is the author of front-end blog CSS { In Real Life }, and has written articles for CSS Tricks, Smashing Magazine, and Web Designer Magazine, to name a few. She enjoys experimenting with new CSS features and helping others learn about them.

Stay in the loop: Get your dose of frontend twice a week

Fresh news, inspo, code demos, and UI animations—zero fluff, all quality. Make your Mondays and Thursdays creative!