MorphingGradient component that will render a dynamic blob that automatically morphs and follows the mouse cursor with a very smooth microinteraction.Installing the required dependencies
To begin with, we need to install some deps. Specifically we are going to use motion-v to handle the cool animation.
Install the dependency with your package manager:
pnpm add motion-v
yarn add motion-v
npm install motion-v
bun add motion-v
Add it to your modules in your configuration:
export default defineNuxtConfig({
modules: ["motion-v/nuxt"]
})
And now you are good to go.
Creating the component
To start with, create a new component called MoprhingGradient inside your components folder. We will use this component to wrap the functionality of our morphing element.
Dynamically morphing the element
We are going to create a function called morph that is going to run at random intervals. This function will morph the element changing its scale, border radius and setting the blur:
<script lang="ts" setup>
const blobEl = useTemplateRef("blobEl");
const mounted = useMounted();
const props = withDefaults(defineProps<{ size?: string; blur?: number; scaleAmplitude?: number }>(), {
size: "200px",
scaleAmplitude: 0.2,
blur: 10,
});
onMounted(() => {
setTimeout(() => {
requestAnimationFrame(morph);
}, 400);
});
function morph() {
if (!blobEl.value) return;
blobEl.value.$el.style.scale = randomScale();
blobEl.value.$el.style.borderRadius = randomBorderRadius();
blobEl.value.$el.style.filter = `blur(${props.blur}px)`;
setTimeout(
() => {
requestAnimationFrame(morph);
}, Math.random() * 2000 + 2500);
}
function remap(v: number, domain: [number, number], newDomain: [number, number]): number {
return newDomain[0] + (v - domain[0]) * ((newDomain[1] - newDomain[0]) / (domain[1] - domain[0]));
}
function randomScale(): string {
if (!blobEl.value) return "1 1 1";
const magnitude = props.scaleAmplitude;
return `${remap(Math.random(), [0, 1], [1 - magnitude, 1 + magnitude])} ${remap(Math.random(), [0, 1], [1 - magnitude, 1 + magnitude])} ${remap(
Math.random(),
[0, 1],
[1 - magnitude, 1 + magnitude]
)}`;
}
function randomBorderRadius(): string {
if (!blobEl.value) return "0deg";
return `${remap(Math.random(), [0, 1], [10, 80])}% ${remap(Math.random(), [0, 1], [10, 80])}% ${remap(Math.random(), [0, 1], [10, 80])}% ${remap(
Math.random(),
[0, 1],
[10, 80]
)}% / ${remap(Math.random(), [0, 1], [10, 80])}% ${remap(Math.random(), [0, 1], [10, 80])}% ${remap(Math.random(), [0, 1], [10, 80])}% ${remap(
Math.random(),
[0, 1],
[10, 80]
)}%`;
}
</script>
$el property to access the actual element.The code might seem already pretty long, but really the two functions randomScale and randomBorderRadius are simply returning long css strings with random values.
Inside the morph function we are scheduling the next execution by waiting some random time. You can edit this value to make the gradient morph more or less frequently.
You can change how the scale and the border radius are selected to change the morphing effect of the element.
remap function is a simple utility funcion that given a value and its current domain, remaps it uniformally into the new domain.Creating the HTML
Our template is going to be very simple, we are basically going to wrap a motion.div element and style it with some css:
<template>
<div
v-if="mounted"
class="relative flex justify-center items-start pointer-events-none z-[-100]">
<motion.div
ref="blobEl"
class="blob"
:animate="{ transition: { duration: 2 } }">
</motion.div>
</div>
</template>
<style lang="css" scoped>
@reference "~/assets/css/main.css";
.blob {
transform-origin: center !important;
filter: blur(0px);
border-radius: 36%;
z-index: -100 !important;
animation-timing-function: cubic-bezier(0.65, 0, 0.35, 1);
background: linear-gradient(var(--color-violet-500), var(--ui-primary));
color: transparent;
width: v-bind(size);
height: v-bind(size);
transition:
filter 3s,
scale 3s,
border-radius 3s;
}
</style>
At this point, you should have your element correctly morphing automatically.
Rotating our element towards the mouse
Since we want our element to look at our mouse position, to begin with, let us hook into the mousemove event:
<script lang="ts" setup>
onMounted(() => {
window.addEventListener("mousemove", mouseMove);
});
onUnmounted(() => {
// Always clean your s**t!
window.removeEventListener("mousemove", mouseMove);
});
function mouseMove(ev: MouseEvent) {}
</script>
Then we can code the mouseMove function to set our ref variable and rotate the element by setting the rotation field on the :animate property of the motion.div:
<template>
<div
v-if="mounted"
class="relative flex justify-center items-start pointer-events-none z-[-100]">
<motion.div
ref="blobEl"
class="blob"
:animate="{ rotate: `${angle}rad`, transition: { duration: 2 } }">
</motion.div>
</div>
</template>
<script lang="ts" setup>
const angle = ref(0);
function mouseMove(ev: MouseEvent) {
if (!blobEl.value?.$el) return;
const rect = blobEl.value.$el.getBoundingClientRect();
const deltaX = ev.clientX - (rect.left + rect.width / 2);
const deltaY = ev.clientY - (rect.top + rect.height / 2);
angle.value = Math.atan2(deltaY, deltaX);
}
</script>
Now our element is rotating towards the mouse but there is a weird problem.
Smoothing the rotation
To fix this, we can create a very simple function called getSmoothAngle that will use some ref variables to keep track of the previous angle and the total rotation count.
We then use this function inside our mouseMove callback to set the smooth angle:
<script lang="ts" setup>
const previousAngle = ref(0);
const totalRotation = ref(0);
const angle = ref(0);
function getSmoothAngle(angle: number) {
let angleDiff = angle - previousAngle.value;
if (angleDiff > Math.PI) {
angleDiff -= 2 * Math.PI;
} else if (angleDiff < -Math.PI) {
angleDiff += 2 * Math.PI;
}
totalRotation.value += angleDiff;
previousAngle.value = angle;
return totalRotation.value;
}
function mouseMove(ev: MouseEvent) {
if (!blobEl.value?.$el) return;
const rect = blobEl.value.$el.getBoundingClientRect();
const deltaX = ev.clientX - (rect.left + rect.width / 2);
const deltaY = ev.clientY - (rect.top + rect.height / 2);
angle.value = getSmoothAngle(Math.atan2(deltaY, deltaX));
}
</script>
The final code
Putting everything togheter, you now have a very cool component that can be used in landing pages.
Here is the final component code. Enjoy!
<template>
<div
v-if="mounted"
class="relative flex justify-center items-start pointer-events-none z-[-100]">
<motion.div
ref="blobEl"
class="blob" :animate="{ rotate: `${angle}rad`, transition: { duration: 2 } }">
</motion.div>
</div>
</template>
<script lang="ts" setup>
import { motion } from "motion-v";
const blobEl = useTemplateRef("blobEl");
const mounted = useMounted();
const previousAngle = ref(0);
const totalRotation = ref(0);
const angle = ref(0);
const props = withDefaults(defineProps<{ size?: string; blur?: number; scaleAmplitude?: number }>(), {
size: "200px",
scaleAmplitude: 0.2,
blur: 10,
});
onMounted(() => {
setTimeout(() => {
requestAnimationFrame(morph);
}, 400);
window.addEventListener("mousemove", mouseMove);
});
onUnmounted(() => {
window.removeEventListener("mousemove", mouseMove);
});
function remap(v: number, domain: [number, number], newDomain: [number, number]): number {
return newDomain[0] + (v - domain[0]) * ((newDomain[1] - newDomain[0]) / (domain[1] - domain[0]));
}
function getSmoothAngle(angle: number) {
let angleDiff = angle - previousAngle.value;
if (angleDiff > Math.PI) {
angleDiff -= 2 * Math.PI;
} else if (angleDiff < -Math.PI) {
angleDiff += 2 * Math.PI;
}
totalRotation.value += angleDiff;
previousAngle.value = angle;
return totalRotation.value;
}
function mouseMove(ev: MouseEvent) {
if (!blobEl.value?.$el) return;
const rect = blobEl.value.$el.getBoundingClientRect();
const deltaX = ev.clientX - (rect.left + rect.width / 2);
const deltaY = ev.clientY - (rect.top + rect.height / 2);
angle.value = getSmoothAngle(Math.atan2(deltaY, deltaX));
}
function randomScale(): string {
if (!blobEl.value) return "1 1 1";
const magnitude = props.scaleAmplitude;
return `${remap(Math.random(), [0, 1], [1 - magnitude, 1 + magnitude])} ${remap(Math.random(), [0, 1], [1 - magnitude, 1 + magnitude])} ${remap(
Math.random(),
[0, 1],
[1 - magnitude, 1 + magnitude]
)}`;
}
function randomBorderRadius(): string {
if (!blobEl.value) return "0deg";
return `${remap(Math.random(), [0, 1], [10, 80])}% ${remap(Math.random(), [0, 1], [10, 80])}% ${remap(Math.random(), [0, 1], [10, 80])}% ${remap(
Math.random(),
[0, 1],
[10, 80]
)}% / ${remap(Math.random(), [0, 1], [10, 80])}% ${remap(Math.random(), [0, 1], [10, 80])}% ${remap(Math.random(), [0, 1], [10, 80])}% ${remap(
Math.random(),
[0, 1],
[10, 80]
)}%`;
}
function morph() {
if (!blobEl.value) return;
blobEl.value.$el.style.scale = randomScale();
blobEl.value.$el.style.borderRadius = randomBorderRadius();
blobEl.value.$el.style.filter = `blur(${props.blur}px)`;
setTimeout(
() => {
requestAnimationFrame(morph);
},
Math.random() * 2000 + 2500
);
}
</script>
<style lang="css" scoped>
@reference "~/assets/css/main.css";
.blob {
transform-origin: center !important;
filter: blur(0px);
border-radius: 36%;
z-index: -100 !important;
animation-timing-function: cubic-bezier(0.65, 0, 0.35, 1);
background: linear-gradient(var(--color-violet-500), var(--ui-primary));
color: transparent;
width: v-bind(size);
height: v-bind(size);
transition:
filter 3s,
scale 3s,
border-radius 3s;
}
</style>