Morphing Text
Blend a list of text strings with a blur and an SVG filter.
Installation
Copy the component into src/components/spark-ui/morphing-text/. Utility imports use @/lib/utils.
vue
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, useId, watch } from "vue";
import { cn } from "@/lib/utils";
interface MorphingTextProps {
texts: string[];
className?: string;
}
const props = defineProps<MorphingTextProps>();
const filterId = `morphing-threshold-${useId()}`;
const first = ref<HTMLSpanElement | null>(null);
const second = ref<HTMLSpanElement | null>(null);
const morphTime = 1.5;
const cooldownTime = 0.5;
let frame = 0;
let mounted = false;
function restart() {
cancelAnimationFrame(frame);
if (!first.value || !second.value) return;
let index = 0;
let morph = 0;
let cooldown = 0;
let previous = performance.now();
const left = first.value;
const right = second.value;
left.textContent = props.texts[0] ?? "";
right.textContent = "";
left.style.filter = "none";
left.style.opacity = "1";
right.style.opacity = "0";
if (props.texts.length < 2) return;
function animate(now: number) {
const elapsed = (now - previous) / 1000;
previous = now;
cooldown -= elapsed;
if (cooldown <= 0) {
morph -= cooldown;
cooldown = 0;
const fraction = Math.min(morph / morphTime, 1);
const inverse = 1 - fraction;
right.style.filter = `blur(${Math.min(8 / fraction - 8, 100)}px)`;
right.style.opacity = String(Math.pow(fraction, 0.4));
left.style.filter = `blur(${Math.min(8 / inverse - 8, 100)}px)`;
left.style.opacity = String(Math.pow(inverse, 0.4));
left.textContent = props.texts[index % props.texts.length];
right.textContent = props.texts[(index + 1) % props.texts.length];
if (fraction === 1) {
cooldown = cooldownTime;
index++;
}
} else {
morph = 0;
right.style.filter = "none";
right.style.opacity = "1";
left.style.filter = "none";
left.style.opacity = "0";
}
frame = requestAnimationFrame(animate);
}
frame = requestAnimationFrame(animate);
}
onMounted(() => {
mounted = true;
restart();
});
watch(
() => props.texts,
() => {
if (mounted) restart();
},
{ deep: true },
);
onBeforeUnmount(() => {
mounted = false;
cancelAnimationFrame(frame);
});
</script>
<template>
<div
:class="
cn(
'relative mx-auto h-16 w-full max-w-3xl text-center font-sans text-[40pt] font-bold leading-none md:h-24 lg:text-[6rem]',
props.className,
)
"
>
<span class="sr-only">{{ props.texts.join(", ") }}</span>
<div
class="absolute inset-0"
:style="{ filter: `url(#${filterId}) blur(0.6px)` }"
aria-hidden="true"
>
<span ref="first" class="absolute inset-x-0 top-0 m-auto inline-block w-full">{{
props.texts[0] ?? ""
}}</span>
<span ref="second" class="absolute inset-x-0 top-0 m-auto inline-block w-full" />
</div>
<svg class="pointer-events-none absolute h-0 w-0" aria-hidden="true" focusable="false">
<defs>
<filter :id="filterId">
<feColorMatrix
in="SourceGraphic"
type="matrix"
values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 255 -140"
/>
</filter>
</defs>
</svg>
</div>
</template>Usage
vue
<script setup lang="ts">
import MorphingText from "@/components/spark-ui/morphing-text/morphing-text.vue";
</script>
<template>
<MorphingText :texts="['Hello', 'World']" />
</template>Behavior
Each blend takes 1.5 seconds. The component then holds the text for 0.5 seconds before the next blend.
An empty list shows no text. A single string stays still. Changes to texts restart the sequence.
Screen readers receive the full list once, separate from the animated text. Each instance uses its own SVG filter. The animation stops when the component unmounts.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
texts | string[] | Required | Strings to show in order. |
className | string | undefined | Classes for the container. |
Source
Ported from Magic UI Morphing Text.