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`; }; 

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; } 

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; 

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); 

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; 

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.