Matteo Beltrame

Matteo Beltrame

Web Dev

Create an abstract morphing element for your landing pages

Create and use this morphing gradient with microinteractions to elevate your landing pages to the next level.
Matteo Beltrame

Matteo Beltrame

nuxtmotionui/ux

04 Nov 2025

7 min read

We will create a MorphingGradient component that will render a dynamic blob that automatically morphs and follows the mouse cursor with a very smooth microinteraction.
Move the mouse cursor and the element will smoothly rotate towards it!
This tutorial uses Nuxt as a web metaframework. However the code can be easily adapted to your preferred web framework.

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.

Check out the YouTube video in which we integrate motion-v in Nuxt for the first time.

Install the dependency with your package manager:

pnpm add motion-v

Add it to your modules in your configuration:

nuxt.config.ts
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:

morphing_gradient.vue
<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>
Remember: when accessing a template ref of a motion element, you need to access the $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.

The 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:

morphing_gradient.vue
<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:

morphing_gradient.vue
<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:

morphing_gradient.vue
<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.

Whenever we rotate around the element more than 180°, the element starts rotating the other way around. This is because the angle becomes negative when we rotate more than π/2\pi/2.

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:

morphing_gradient.vue
<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.

Change the code as you want to better fit your brand and needs!
Be sure to give a star to my free Nuxt template for other very cool components and implemented features.

Free Nuxt 4 template

A dashboard with multi-column layout.

YouTube Tutorials

I post cool stuff and tutorials on my YouTube channel!

Here is the final component code. Enjoy!

morphing_gradient.vue
<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>

Related articles