DEV Community

Sammii
Sammii

Posted on

Cursor-Reactive Gradients: Making CSS Respond to Mouse Position

Cursor-Reactive Gradients: Making CSS Respond to Mouse Position

The logo on my portfolio site changes colour as you move your mouse. Not with a library. Not with a pre-built animation. With about 15 lines of maths that convert cursor position into RGB values and generate a three-point radial gradient in real time.

And when you scroll, the same gradient system responds using sine and cosine wave functions.

Here's how the whole thing works.

The core function: gradientCreator

The entire gradient system lives in one function that takes two numbers and returns a CSS background:

export const gradientCreator = (xPc: number, yPc: number) => {
  const colourCreator = (number: number) => {
    const colour = Math.floor((255 / 100) * number);
    return colour < 255 ? Math.floor((255 / 100) * number) : 255;
  };

  const colour1 = colourCreator(xPc);
  const colour2 = colourCreator(yPc);
  const colour3 = 255 - colourCreator(xPc);

  return `radial-gradient(at 50% 0, rgb(${colour1}, ${colour3}, ${colour2}), transparent 50%),
    radial-gradient(at 6.7% 75%, rgb(${colour3}, ${colour2}, ${colour1}), transparent 50%),
    radial-gradient(at 93.3% 75%, rgb(${colour2}, ${colour1}, ${colour3}), transparent 50%),
    lavender`;
};
Enter fullscreen mode Exit fullscreen mode

Two inputs: xPc (cursor X as a percentage of the viewport) and yPc (cursor Y as a percentage).

colourCreator maps 0-100% to 0-255 RGB range. Simple linear interpolation: Math.floor((255 / 100) * number).

Three colours are derived from two inputs:

  • colour1 = colourCreator(xPc)
  • colour2 = colourCreator(yPc)
  • colour3 = 255 - colourCreator(xPc) -- the inverse of colour1

The channel rotation trick

Here's the part that makes it actually work. The three radial gradient points use the same three values but in different order:

Gradient point Position RGB order
Top centre 50% 0 (c1, c3, c2)
Bottom left 6.7% 75% (c3, c2, c1)
Bottom right 93.3% 75% (c2, c1, c3)

By rotating which channel gets which value at each point, moving in any direction produces a smooth colour shift. You get a full spectrum of transitions from just two input numbers.

The positions form an equilateral triangle on the viewport, which distributes the colour mixing evenly.

The fallback colour is lavender -- so if all three gradients are transparent at any point, you still get something pleasant.

CSS mask-image: gradient through text

The gradient doesn't paint a box. It paints through the shape of text:

.logo {
  -webkit-mask-image: url('/sammii.png');
  mask-image: url('/sammii.png');
  -webkit-mask-repeat: no-repeat;
  mask-repeat: no-repeat;
  -webkit-mask-size: contain;
  mask-size: contain;
}
Enter fullscreen mode Exit fullscreen mode

The PNG is a text shape -- the word "sammii". The gradient is the element's background, but mask-image clips it to only show through the mask shape.

The result: the text itself becomes the gradient canvas. Move your cursor and the letters shift through colours.

Scroll-driven: sine waves replacing cursor input

In the portfolio container, when the user scrolls, the same gradientCreator function responds -- but instead of cursor position, it gets values driven by trigonometric wave functions:

const scrollPercent = (scrollTop / (scrollHeight - clientHeight)) * 100;

const wave1 = Math.sin((scrollPercent / 100) * Math.PI * 6) * 50 + 50;
const wave2 = Math.cos((scrollPercent / 100) * Math.PI * 4) * 30;

const yValue = Math.max(0, Math.min(100, wave1 + wave2));
const xValue = Math.sin((scrollPercent / 100) * Math.PI * 2) * 25 + 50;
Enter fullscreen mode Exit fullscreen mode

Breaking this down:

  • wave1: a sine wave that oscillates 6 complete cycles over the full scroll distance, with an amplitude of 50 centred at 50 -- so it swings between 0 and 100
  • wave2: a cosine wave at a different frequency (4 cycles), with a smaller amplitude of 30 -- this adds variation so the colour change isn't predictable
  • The Y value is the sum of both waves, clamped to 0-100
  • The X value gets its own separate sine wave at yet another frequency (2 cycles)

The different frequencies mean the X and Y inputs never repeat the same pattern at the same scroll position.

Smoothing with lerp

Raw values from scroll events are choppy. The animation uses linear interpolation to smooth everything:

const lerp = (current: number, target: number, factor: number) => 
  current + (target - current) * factor;

currentX.current = lerp(currentX.current, targetX.current, 0.2);
currentY.current = lerp(currentY.current, targetY.current, 0.2);
Enter fullscreen mode Exit fullscreen mode

Each frame, the current value moves 20% of the remaining distance toward the target. This creates an ease-out effect -- fast initial response that gradually settles.

The animation runs on requestAnimationFrame, so it's synced to the display refresh rate.

Pointer vs scroll: conflict resolution

Both pointer movement and scroll drive the same gradient function. Without coordination, they'd fight:

const isScrollingRef = useRef(false);
const lastScrollTimeRef = useRef(0);

// In scroll handler:
isScrollingRef.current = true;
lastScrollTimeRef.current = Date.now();

// In pointer handler:
if (isScrollingRef.current && Date.now() - lastScrollTimeRef.current < 500) {
  return;
}
isScrollingRef.current = false;
Enter fullscreen mode Exit fullscreen mode

Scroll takes priority while active. Once scrolling stops for 500ms, mouse movement resumes control.

The whole thing in context

Fifteen lines of colour maths. A CSS mask. Some trigonometry. A lerp function. That's the entire system.

No animation library. No canvas. No WebGL. Just the browser's native CSS gradient engine doing what it's good at -- painting pixels fast -- driven by a bit of arithmetic that maps human input to colour space.

The best part: because gradientCreator is a pure function (two numbers in, CSS string out), you could drive it with anything. Microphone volume. Accelerometer data. API response times. The abstraction doesn't care where the numbers come from.


I'm Sammii, founder of Lunary -- an astrology app that teaches you to read your own birth chart. When I'm not calculating planetary transits, I'm building gradient systems and obsessing over keyboard navigation. Follow the build on Dev.to and Hashnode.

Top comments (0)