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 100wave2: 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.