Merge pull request #137 from Utkarsh-Singhal-26/refact/text-animations

[ REVAMP ] : Text Animations
This commit is contained in:
David
2026-02-24 23:12:16 +02:00
committed by GitHub
25 changed files with 1206 additions and 887 deletions

View File

@@ -1,81 +1,99 @@
<template>
<p ref="rootRef" :class="['blur-text', className, 'flex', 'flex-wrap']">
<p ref="rootRef" class="flex flex-wrap blur-text" :class="className">
<Motion
v-for="(segment, index) in elements"
:key="`${animationKey}-${index}`"
:key="index"
tag="span"
:initial="fromSnapshot"
:animate="inView ? getAnimateKeyframes() : fromSnapshot"
:animate="inView ? buildKeyframes(fromSnapshot, toSnapshots) : fromSnapshot"
:transition="getTransition(index)"
:style="{
display: 'inline-block',
willChange: 'transform, filter, opacity'
}"
@animation-complete="() => handleAnimationComplete(index)"
@animation-complete="handleAnimationComplete(index)"
style="display: inline-block; will-change: transform, filter, opacity"
>
{{ segment === ' ' ? '\u00A0' : segment
}}{{ animateBy === 'words' && index < elements.length - 1 ? '\u00A0' : '' }}
{{ segment === ' ' ? '\u00A0' : segment }}
<template v-if="animateBy === 'words' && index < elements.length - 1">&nbsp;</template>
</Motion>
</p>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch, useTemplateRef } from 'vue';
import { Motion } from 'motion-v';
import { Motion, type Transition } from 'motion-v';
import { computed, onBeforeUnmount, onMounted, ref, useTemplateRef } from 'vue';
type AnimateBy = 'words' | 'letters';
type Direction = 'top' | 'bottom';
type AnimationSnapshot = Record<string, string | number>;
interface BlurTextProps {
type BlurTextProps = {
text?: string;
delay?: number;
className?: string;
animateBy?: AnimateBy;
direction?: Direction;
animateBy?: 'words' | 'letters';
direction?: 'top' | 'bottom';
threshold?: number;
rootMargin?: string;
animationFrom?: AnimationSnapshot;
animationTo?: AnimationSnapshot[];
animationFrom?: Record<string, string | number>;
animationTo?: Array<Record<string, string | number>>;
easing?: (t: number) => number;
onAnimationComplete?: () => void;
stepDuration?: number;
}
};
const buildKeyframes = (
from: Record<string, string | number>,
steps: Array<Record<string, string | number>>
): Record<string, Array<string | number>> => {
const keys = new Set<string>([...Object.keys(from), ...steps.flatMap(s => Object.keys(s))]);
const keyframes: Record<string, Array<string | number>> = {};
keys.forEach(k => {
keyframes[k] = [from[k], ...steps.map(s => s[k])];
});
return keyframes;
};
const props = withDefaults(defineProps<BlurTextProps>(), {
text: '',
delay: 200,
className: '',
animateBy: 'words' as AnimateBy,
direction: 'top' as Direction,
animateBy: 'words',
direction: 'top',
threshold: 0.1,
rootMargin: '0px',
easing: (t: number) => t,
stepDuration: 0.35
});
const buildKeyframes = (
from: AnimationSnapshot,
steps: AnimationSnapshot[]
): Record<string, Array<string | number>> => {
const keys = new Set<string>([...Object.keys(from), ...steps.flatMap(step => Object.keys(step))]);
const inView = ref(false);
const rootRef = useTemplateRef<HTMLParagraphElement>('rootRef');
let observer: IntersectionObserver | null = null;
const keyframes: Record<string, Array<string | number>> = {};
onMounted(() => {
if (!rootRef.value) return;
for (const key of keys) {
keyframes[key] = [from[key], ...steps.map(step => step[key])];
}
observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
inView.value = true;
observer?.unobserve(rootRef.value as Element);
}
},
{
threshold: props.threshold,
rootMargin: props.rootMargin
}
);
return keyframes;
};
observer.observe(rootRef.value);
});
onBeforeUnmount(() => {
observer?.disconnect();
});
const elements = computed(() => (props.animateBy === 'words' ? props.text.split(' ') : props.text.split('')));
const defaultFrom = computed<AnimationSnapshot>(() =>
const defaultFrom = computed(() =>
props.direction === 'top' ? { filter: 'blur(10px)', opacity: 0, y: -50 } : { filter: 'blur(10px)', opacity: 0, y: 50 }
);
const defaultTo = computed<AnimationSnapshot[]>(() => [
const defaultTo = computed(() => [
{
filter: 'blur(5px)',
opacity: 0.5,
@@ -93,71 +111,21 @@ const toSnapshots = computed(() => props.animationTo ?? defaultTo.value);
const stepCount = computed(() => toSnapshots.value.length + 1);
const totalDuration = computed(() => props.stepDuration * (stepCount.value - 1));
const times = computed(() =>
Array.from({ length: stepCount.value }, (_, i) => (stepCount.value === 1 ? 0 : i / (stepCount.value - 1)))
);
const inView = ref(false);
const animationKey = ref(0);
const completionFired = ref(false);
const rootRef = useTemplateRef<HTMLParagraphElement>('rootRef');
let observer: IntersectionObserver | null = null;
const setupObserver = () => {
if (!rootRef.value) return;
observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
inView.value = true;
observer?.unobserve(rootRef.value as Element);
}
},
{
threshold: props.threshold,
rootMargin: props.rootMargin
}
);
observer.observe(rootRef.value);
};
const getAnimateKeyframes = () => {
return buildKeyframes(fromSnapshot.value, toSnapshots.value);
};
const getTransition = (index: number) => {
return {
duration: totalDuration.value,
times: times.value,
delay: (index * props.delay) / 1000,
ease: props.easing
};
};
const getTransition = (index: number): Transition => ({
duration: totalDuration.value,
times: times.value,
delay: (index * props.delay) / 1000,
ease: props.easing
});
const handleAnimationComplete = (index: number) => {
if (index === elements.value.length - 1 && !completionFired.value && props.onAnimationComplete) {
completionFired.value = true;
props.onAnimationComplete();
if (index === elements.value.length - 1) {
props.onAnimationComplete?.();
}
};
onMounted(() => {
setupObserver();
});
onUnmounted(() => {
observer?.disconnect();
});
watch([() => props.threshold, () => props.rootMargin], () => {
observer?.disconnect();
setupObserver();
});
watch([() => props.delay, () => props.stepDuration, () => props.animateBy, () => props.direction], () => {
animationKey.value++;
completionFired.value = false;
});
</script>

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, ref, watchEffect, onUnmounted } from 'vue';
import { Motion } from 'motion-v';
import { animate, Motion, MotionValue, useMotionValue } from 'motion-v';
import { computed, onMounted, watch } from 'vue';
interface CircularTextProps {
text: string;
@@ -10,87 +10,59 @@ interface CircularTextProps {
}
const props = withDefaults(defineProps<CircularTextProps>(), {
text: '',
spinDuration: 20,
onHover: 'speedUp',
className: ''
});
const letters = computed(() => Array.from(props.text));
const isHovered = ref(false);
const rotation: MotionValue<number> = useMotionValue(0);
const currentRotation = ref(0);
const animationId = ref<number | null>(null);
const lastTime = ref<number>(Date.now());
const rotationSpeed = ref<number>(0);
let currentAnimation: ReturnType<typeof animate> | null = null;
const getCurrentSpeed = () => {
if (isHovered.value && props.onHover === 'pause') return 0;
const startRotation = (duration: number) => {
currentAnimation?.stop();
const start = rotation.get();
const baseDuration = props.spinDuration;
const baseSpeed = 360 / baseDuration;
currentAnimation = animate(rotation, start + 360, {
duration,
ease: 'linear',
repeat: Infinity
});
};
if (!isHovered.value) return baseSpeed;
onMounted(() => {
startRotation(props.spinDuration);
});
watch(
() => [props.spinDuration, props.text],
() => {
startRotation(props.spinDuration);
}
);
const handleHoverStart = () => {
if (!props.onHover) return;
switch (props.onHover) {
case 'slowDown':
return baseSpeed / 2;
startRotation(props.spinDuration * 2);
break;
case 'speedUp':
return baseSpeed * 4;
startRotation(props.spinDuration / 4);
break;
case 'pause':
currentAnimation?.stop();
break;
case 'goBonkers':
return baseSpeed * 20;
default:
return baseSpeed;
startRotation(props.spinDuration / 20);
break;
}
};
const getCurrentScale = () => {
return isHovered.value && props.onHover === 'goBonkers' ? 0.8 : 1;
};
const animate = () => {
const now = Date.now();
const deltaTime = (now - lastTime.value) / 1000;
lastTime.value = now;
const targetSpeed = getCurrentSpeed();
const speedDiff = targetSpeed - rotationSpeed.value;
const smoothingFactor = Math.min(1, deltaTime * 5);
rotationSpeed.value += speedDiff * smoothingFactor;
currentRotation.value = (currentRotation.value + rotationSpeed.value * deltaTime) % 360;
animationId.value = requestAnimationFrame(animate);
};
const startAnimation = () => {
if (animationId.value) {
cancelAnimationFrame(animationId.value);
}
lastTime.value = Date.now();
rotationSpeed.value = getCurrentSpeed();
animate();
};
watchEffect(() => {
startAnimation();
});
startAnimation();
onUnmounted(() => {
if (animationId.value) {
cancelAnimationFrame(animationId.value);
}
});
const handleHoverStart = () => {
isHovered.value = true;
};
const handleHoverEnd = () => {
isHovered.value = false;
startRotation(props.spinDuration);
};
const getLetterTransform = (index: number) => {
@@ -104,28 +76,24 @@ const getLetterTransform = (index: number) => {
<template>
<Motion
:animate="{
rotate: currentRotation,
scale: getCurrentScale()
tag="div"
:class="[
'm-0 mx-auto rounded-full w-[200px] h-[200px] relative font-black text-white text-center cursor-pointer origin-center',
className
]"
:style="{
rotate: rotation
}"
:transition="{
rotate: {
duration: 0
},
scale: {
type: 'spring',
damping: 20,
stiffness: 300
}
:initial="{
rotate: 0
}"
:class="`m-0 mx-auto rounded-full w-[200px] h-[200px] relative font-black text-white text-center cursor-pointer origin-center ${props.className}`"
@mouseenter="handleHoverStart"
@mouseleave="handleHoverEnd"
>
<span
v-for="(letter, i) in letters"
:key="i"
class="absolute inline-block inset-0 text-2xl transition-all duration-500 ease-[cubic-bezier(0,0,0,1)]"
class="inline-block absolute inset-0 text-2xl transition-all duration-500 ease-[cubic-bezier(0,0,0,1)]"
:style="{
transform: getLetterTransform(i),
WebkitTransform: getLetterTransform(i)

View File

@@ -1,8 +1,7 @@
<script setup lang="ts">
import { onMounted, onUnmounted, watch, nextTick, useTemplateRef } from 'vue';
import { computed, onBeforeUnmount, onMounted, useSlots, useTemplateRef, watch } from 'vue';
interface FuzzyTextProps {
text: string;
fontSize?: number | string;
fontWeight?: string | number;
fontFamily?: string;
@@ -10,78 +9,52 @@ interface FuzzyTextProps {
enableHover?: boolean;
baseIntensity?: number;
hoverIntensity?: number;
fuzzRange?: number;
fps?: number;
direction?: 'horizontal' | 'vertical' | 'both';
transitionDuration?: number;
clickEffect?: boolean;
glitchMode?: boolean;
glitchInterval?: number;
glitchDuration?: number;
gradient?: string[] | null;
letterSpacing?: number;
className?: string;
}
const props = withDefaults(defineProps<FuzzyTextProps>(), {
text: '',
fontSize: 'clamp(2rem, 8vw, 8rem)',
fontWeight: 900,
fontFamily: 'inherit',
color: '#fff',
enableHover: true,
baseIntensity: 0.18,
hoverIntensity: 0.5
hoverIntensity: 0.5,
fuzzRange: 30,
fps: 60,
direction: 'horizontal',
transitionDuration: 0,
clickEffect: false,
glitchMode: false,
glitchInterval: 2000,
glitchDuration: 200,
gradient: null,
letterSpacing: 0,
className: ''
});
const canvasRef = useTemplateRef<HTMLCanvasElement>('canvasRef');
const canvasRef = useTemplateRef<HTMLCanvasElement & { cleanupFuzzyText?: () => void }>('canvasRef');
const slots = useSlots();
let animationFrameId: number;
let isCancelled = false;
let cleanup: (() => void) | null = null;
let glitchTimeoutId: ReturnType<typeof setTimeout>;
let glitchEndTimeoutId: ReturnType<typeof setTimeout>;
let clickTimeoutId: ReturnType<typeof setTimeout>;
let cancelled = false;
const waitForFont = async (fontFamily: string, fontWeight: string | number, fontSize: string): Promise<boolean> => {
if (document.fonts?.check) {
const fontString = `${fontWeight} ${fontSize} ${fontFamily}`;
if (document.fonts.check(fontString)) {
return true;
}
try {
await document.fonts.load(fontString);
return document.fonts.check(fontString);
} catch (error) {
console.warn('Font loading failed:', error);
return false;
}
}
return new Promise(resolve => {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) {
resolve(false);
return;
}
ctx.font = `${fontWeight} ${fontSize} ${fontFamily}`;
const testWidth = ctx.measureText('M').width;
let attempts = 0;
const checkFont = () => {
ctx.font = `${fontWeight} ${fontSize} ${fontFamily}`;
const newWidth = ctx.measureText('M').width;
if (newWidth !== testWidth && newWidth > 0) {
resolve(true);
} else if (attempts < 20) {
attempts++;
setTimeout(checkFont, 50);
} else {
resolve(false);
}
};
setTimeout(checkFont, 10);
});
};
const initCanvas = async () => {
if (document.fonts?.ready) {
await document.fonts.ready;
}
if (isCancelled) return;
const text = computed(() => (slots.default?.() ?? []).map(v => v.children).join(''));
const init = async () => {
const canvas = canvasRef.value;
if (!canvas) return;
@@ -92,184 +65,185 @@ const initCanvas = async () => {
props.fontFamily === 'inherit' ? window.getComputedStyle(canvas).fontFamily || 'sans-serif' : props.fontFamily;
const fontSizeStr = typeof props.fontSize === 'number' ? `${props.fontSize}px` : props.fontSize;
let numericFontSize: number;
const fontString = `${props.fontWeight} ${fontSizeStr} ${computedFontFamily}`;
try {
await document.fonts.load(fontString);
} catch {
await document.fonts.ready;
}
if (cancelled) return;
let numericFontSize: number;
if (typeof props.fontSize === 'number') {
numericFontSize = props.fontSize;
} else {
const temp = document.createElement('span');
temp.style.fontSize = props.fontSize;
temp.style.fontFamily = computedFontFamily;
document.body.appendChild(temp);
const computedSize = window.getComputedStyle(temp).fontSize;
numericFontSize = parseFloat(computedSize);
numericFontSize = parseFloat(getComputedStyle(temp).fontSize);
document.body.removeChild(temp);
}
const fontLoaded = await waitForFont(computedFontFamily, props.fontWeight, fontSizeStr);
if (!fontLoaded) {
console.warn(`Font not loaded: ${computedFontFamily}`);
}
const text = props.text;
const offscreen = document.createElement('canvas');
const offCtx = offscreen.getContext('2d');
if (!offCtx) return;
const fontString = `${props.fontWeight} ${fontSizeStr} ${computedFontFamily}`;
const offCtx = offscreen.getContext('2d')!;
offCtx.font = fontString;
offCtx.textBaseline = 'alphabetic';
const testMetrics = offCtx.measureText('M');
if (testMetrics.width === 0) {
setTimeout(() => {
if (!isCancelled) {
initCanvas();
}
}, 100);
return;
let totalWidth = 0;
if (props.letterSpacing !== 0) {
for (const char of text.value) {
totalWidth += offCtx.measureText(char).width + props.letterSpacing;
}
totalWidth -= props.letterSpacing;
} else {
totalWidth = offCtx.measureText(text.value).width;
}
const metrics = offCtx.measureText(text.value);
const ascent = metrics.actualBoundingBoxAscent ?? numericFontSize;
const descent = metrics.actualBoundingBoxDescent ?? numericFontSize * 0.2;
const height = Math.ceil(ascent + descent);
offscreen.width = Math.ceil(totalWidth) + 20;
offscreen.height = height;
offCtx.font = fontString;
offCtx.textBaseline = 'alphabetic';
const metrics = offCtx.measureText(text);
const actualLeft = metrics.actualBoundingBoxLeft ?? 0;
const actualRight = metrics.actualBoundingBoxRight ?? metrics.width;
const actualAscent = metrics.actualBoundingBoxAscent ?? numericFontSize;
const actualDescent = metrics.actualBoundingBoxDescent ?? numericFontSize * 0.2;
if (props.gradient && props.gradient.length >= 2) {
const grad = offCtx.createLinearGradient(0, 0, offscreen.width, 0);
props.gradient.forEach((c, i) => grad.addColorStop(i / (props.gradient!.length - 1), c));
offCtx.fillStyle = grad;
} else {
offCtx.fillStyle = props.color;
}
const textBoundingWidth = Math.ceil(actualLeft + actualRight);
const tightHeight = Math.ceil(actualAscent + actualDescent);
let x = 10;
for (const char of text.value) {
offCtx.fillText(char, x, ascent);
x += offCtx.measureText(char).width + props.letterSpacing;
}
const extraWidthBuffer = 10;
const offscreenWidth = textBoundingWidth + extraWidthBuffer;
const marginX = props.fuzzRange + 20;
const marginY = props.direction === 'vertical' || props.direction === 'both' ? props.fuzzRange + 10 : 0;
offscreen.width = offscreenWidth;
offscreen.height = tightHeight;
const xOffset = extraWidthBuffer / 2;
offCtx.font = `${props.fontWeight} ${fontSizeStr} ${computedFontFamily}`;
offCtx.textBaseline = 'alphabetic';
offCtx.fillStyle = props.color;
offCtx.fillText(text, xOffset - actualLeft, actualAscent);
const horizontalMargin = 50;
const verticalMargin = 0;
canvas.width = offscreenWidth + horizontalMargin * 2;
canvas.height = tightHeight + verticalMargin * 2;
ctx.translate(horizontalMargin, verticalMargin);
const interactiveLeft = horizontalMargin + xOffset;
const interactiveTop = verticalMargin;
const interactiveRight = interactiveLeft + textBoundingWidth;
const interactiveBottom = interactiveTop + tightHeight;
canvas.width = offscreen.width + marginX * 2;
canvas.height = offscreen.height + marginY * 2;
ctx.translate(marginX, marginY);
let isHovering = false;
const fuzzRange = 30;
let isClicking = false;
let isGlitching = false;
let currentIntensity = props.baseIntensity;
let targetIntensity = props.baseIntensity;
let lastFrameTime = 0;
const frameDuration = 1000 / props.fps;
const run = () => {
if (isCancelled) return;
ctx.clearRect(-fuzzRange, -fuzzRange, offscreenWidth + 2 * fuzzRange, tightHeight + 2 * fuzzRange);
const intensity = isHovering ? props.hoverIntensity : props.baseIntensity;
for (let j = 0; j < tightHeight; j++) {
const dx = Math.floor(intensity * (Math.random() - 0.5) * fuzzRange);
ctx.drawImage(offscreen, 0, j, offscreenWidth, 1, dx, j, offscreenWidth, 1);
const startGlitch = () => {
if (!props.glitchMode || cancelled) return;
glitchTimeoutId = setTimeout(() => {
isGlitching = true;
glitchEndTimeoutId = setTimeout(() => {
isGlitching = false;
startGlitch();
}, props.glitchDuration);
}, props.glitchInterval);
};
if (props.glitchMode) startGlitch();
const run = (ts: number) => {
if (cancelled) return;
if (ts - lastFrameTime < frameDuration) {
animationFrameId = requestAnimationFrame(run);
return;
}
animationFrameId = window.requestAnimationFrame(run);
lastFrameTime = ts;
ctx.clearRect(-marginX, -marginY, offscreen.width + marginX * 2, offscreen.height + marginY * 2);
targetIntensity = isClicking || isGlitching ? 1 : isHovering ? props.hoverIntensity : props.baseIntensity;
if (props.transitionDuration > 0) {
const step = 1 / (props.transitionDuration / frameDuration);
currentIntensity += Math.sign(targetIntensity - currentIntensity) * step;
currentIntensity = Math.min(
Math.max(currentIntensity, Math.min(targetIntensity, currentIntensity)),
Math.max(targetIntensity, currentIntensity)
);
} else {
currentIntensity = targetIntensity;
}
for (let y = 0; y < offscreen.height; y++) {
const dx = props.direction !== 'vertical' ? (Math.random() - 0.5) * currentIntensity * props.fuzzRange : 0;
const dy =
props.direction !== 'horizontal' ? (Math.random() - 0.5) * currentIntensity * props.fuzzRange * 0.5 : 0;
ctx.drawImage(offscreen, 0, y, offscreen.width, 1, dx, y + dy, offscreen.width, 1);
}
animationFrameId = requestAnimationFrame(run);
};
run();
animationFrameId = requestAnimationFrame(run);
const isInsideTextArea = (x: number, y: number) =>
x >= interactiveLeft && x <= interactiveRight && y >= interactiveTop && y <= interactiveBottom;
const rectCheck = (x: number, y: number) =>
x >= marginX && x <= marginX + offscreen.width && y >= marginY && y <= marginY + offscreen.height;
const handleMouseMove = (e: MouseEvent) => {
const mouseMove = (e: MouseEvent) => {
if (!props.enableHover) return;
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
isHovering = isInsideTextArea(x, y);
isHovering = rectCheck(e.clientX - rect.left, e.clientY - rect.top);
};
const handleMouseLeave = () => {
isHovering = false;
};
const mouseLeave = () => (isHovering = false);
const handleTouchMove = (e: TouchEvent) => {
if (!props.enableHover) return;
e.preventDefault();
const rect = canvas.getBoundingClientRect();
const touch = e.touches[0];
const x = touch.clientX - rect.left;
const y = touch.clientY - rect.top;
isHovering = isInsideTextArea(x, y);
};
const handleTouchEnd = () => {
isHovering = false;
const click = () => {
if (!props.clickEffect) return;
isClicking = true;
clearTimeout(clickTimeoutId);
clickTimeoutId = setTimeout(() => (isClicking = false), 150);
};
if (props.enableHover) {
canvas.addEventListener('mousemove', handleMouseMove);
canvas.addEventListener('mouseleave', handleMouseLeave);
canvas.addEventListener('touchmove', handleTouchMove, { passive: false });
canvas.addEventListener('touchend', handleTouchEnd);
canvas.addEventListener('mousemove', mouseMove);
canvas.addEventListener('mouseleave', mouseLeave);
}
cleanup = () => {
window.cancelAnimationFrame(animationFrameId);
if (props.enableHover) {
canvas.removeEventListener('mousemove', handleMouseMove);
canvas.removeEventListener('mouseleave', handleMouseLeave);
canvas.removeEventListener('touchmove', handleTouchMove);
canvas.removeEventListener('touchend', handleTouchEnd);
}
};
if (props.clickEffect) {
canvas.addEventListener('click', click);
}
onBeforeUnmount(() => {
cancelled = true;
cancelAnimationFrame(animationFrameId);
clearTimeout(glitchTimeoutId);
clearTimeout(glitchEndTimeoutId);
clearTimeout(clickTimeoutId);
canvas.removeEventListener('mousemove', mouseMove);
canvas.removeEventListener('mouseleave', mouseLeave);
canvas.removeEventListener('click', click);
});
};
onMounted(() => {
nextTick(() => {
initCanvas();
});
});
onUnmounted(() => {
isCancelled = true;
if (animationFrameId) {
window.cancelAnimationFrame(animationFrameId);
}
if (cleanup) {
cleanup();
}
});
onMounted(init);
watch(
[
() => props.text,
() => props.fontSize,
() => props.fontWeight,
() => props.fontFamily,
() => props.color,
() => props.enableHover,
() => props.baseIntensity,
() => props.hoverIntensity
],
() => ({ ...props, text: text.value }),
() => {
isCancelled = true;
if (animationFrameId) {
window.cancelAnimationFrame(animationFrameId);
}
if (cleanup) {
cleanup();
}
isCancelled = false;
nextTick(() => {
initCanvas();
});
cancelled = true;
cancelAnimationFrame(animationFrameId);
cancelled = false;
init();
}
);
</script>
<template>
<canvas ref="canvasRef" />
<canvas ref="canvasRef" :class="className" />
</template>

View File

@@ -1,75 +1,140 @@
<script setup lang="ts">
import { computed } from 'vue';
import { Motion, useAnimationFrame, useMotionValue, useTransform } from 'motion-v';
import { computed, ref, useSlots } from 'vue';
interface GradientTextProps {
text: string;
className?: string;
colors?: string[];
animationSpeed?: number;
showBorder?: boolean;
direction?: 'horizontal' | 'vertical' | 'diagonal';
pauseOnHover?: boolean;
yoyo?: boolean;
}
const props = withDefaults(defineProps<GradientTextProps>(), {
text: '',
className: '',
colors: () => ['#ffaa40', '#9c40ff', '#ffaa40'],
colors: () => ['#27FF64', '#27FF64', '#A0FFBC'],
animationSpeed: 8,
showBorder: false
showBorder: false,
direction: 'horizontal',
pauseOnHover: false,
yoyo: true
});
const slots = useSlots();
const text = computed(() => (slots.default?.() ?? []).map(v => v.children).join(''));
const isPaused = ref(false);
const progress = useMotionValue(0);
const elapsedRef = ref(0);
const lastTimeRef = ref<number | null>(null);
const animationDuration = props.animationSpeed * 1000;
useAnimationFrame(time => {
if (isPaused.value) {
lastTimeRef.value = null;
return;
}
if (lastTimeRef.value === null) {
lastTimeRef.value = time;
return;
}
const deltaTime = time - lastTimeRef.value;
lastTimeRef.value = time;
elapsedRef.value += deltaTime;
if (props.yoyo) {
const fullCycle = animationDuration * 2;
const cycleTime = elapsedRef.value % fullCycle;
if (cycleTime < animationDuration) {
progress.set((cycleTime / animationDuration) * 100);
} else {
progress.set(100 - ((cycleTime - animationDuration) / animationDuration) * 100);
}
} else {
// Continuously increase position for seamless looping
progress.set((elapsedRef.value / animationDuration) * 100);
}
});
const backgroundPosition = useTransform(progress, p => {
if (props.direction === 'horizontal') {
return `${p}% 50%`;
} else if (props.direction === 'vertical') {
return `50% ${p}%`;
} else {
// For diagonal, move only horizontally to avoid interference patterns
return `${p}% 50%`;
}
});
const handleMouseEnter = () => {
if (props.pauseOnHover) isPaused.value = true;
};
const handleMouseLeave = () => {
if (props.pauseOnHover) isPaused.value = false;
};
const gradientAngle = computed(() =>
props.direction === 'horizontal' ? 'to right' : props.direction === 'vertical' ? 'to bottom' : 'to bottom right'
);
// Duplicate first color at the end for seamless looping
const gradientColors = computed(() => [...props.colors, props.colors[0]].join(', '));
const gradientStyle = computed(() => ({
backgroundImage: `linear-gradient(to right, ${props.colors.join(', ')})`,
animationDuration: `${props.animationSpeed}s`,
backgroundSize: '300% 100%',
'--animation-duration': `${props.animationSpeed}s`
}));
const borderStyle = computed(() => ({
...gradientStyle.value
}));
const textStyle = computed(() => ({
...gradientStyle.value,
backgroundClip: 'text',
WebkitBackgroundClip: 'text'
backgroundImage: `linear-gradient(${gradientAngle.value}, ${gradientColors.value})`,
backgroundSize:
props.direction === 'horizontal' ? '300% 100%' : props.direction === 'vertical' ? '100% 300%' : '300% 300%',
backgroundRepeat: 'repeat'
}));
</script>
<template>
<div
:class="`relative mx-auto flex max-w-fit flex-row items-center justify-center rounded-[1.25rem] font-medium backdrop-blur transition-shadow duration-500 overflow-hidden cursor-pointer ${className}`"
<Motion
tag="div"
:class="[
'relative mx-auto flex max-w-fit flex-row items-center justify-center rounded-[1.25rem] font-medium backdrop-blur transition-shadow duration-500 overflow-hidden cursor-pointer',
className,
showBorder && 'py-1 px-2'
]"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
>
<div
<Motion
tag="div"
v-if="showBorder"
class="absolute inset-0 bg-cover z-0 pointer-events-none animate-gradient"
:style="borderStyle"
class="z-0 absolute inset-0 rounded-[1.25rem] pointer-events-none"
:style="{ ...gradientStyle, backgroundPosition }"
>
<div
class="absolute inset-0 bg-black rounded-[1.25rem] z-[-1]"
style="width: calc(100% - 2px); height: calc(100% - 2px); left: 50%; top: 50%; transform: translate(-50%, -50%)"
class="z-[-1] absolute bg-black rounded-[1.25rem]"
:style="{
width: 'calc(100% - 2px)',
height: 'calc(100% - 2px)',
left: '50%',
top: '50%',
transform: 'translate(-50%, -50%)'
}"
/>
</div>
</Motion>
<div class="inline-block relative z-2 text-transparent bg-cover animate-gradient" :style="textStyle">
<Motion
tag="div"
class="inline-block z-2 relative bg-clip-text text-transparent"
:style="{
...gradientStyle,
backgroundPosition,
WebkitBackgroundClip: 'text'
}"
>
{{ text }}
</div>
</div>
</Motion>
</Motion>
</template>
<style scoped>
@keyframes gradient {
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}
.animate-gradient {
animation: gradient var(--animation-duration, 8s) linear infinite;
}
</style>

View File

@@ -1,49 +1,135 @@
<script setup lang="ts">
import { computed } from 'vue';
import { Motion, useAnimationFrame, useMotionValue, useTransform } from 'motion-v';
import { computed, ref, watch } from 'vue';
interface ShinyTextProps {
text: string;
disabled?: boolean;
speed?: number;
className?: string;
color?: string;
shineColor?: string;
spread?: number;
yoyo?: boolean;
pauseOnHover?: boolean;
direction?: 'left' | 'right';
delay?: number;
}
const props = withDefaults(defineProps<ShinyTextProps>(), {
text: '',
disabled: false,
speed: 5,
className: ''
speed: 2,
className: '',
color: '#b5b5b5',
shineColor: '#ffffff',
spread: 120,
yoyo: false,
pauseOnHover: false,
direction: 'left',
delay: 0
});
const animationDuration = computed(() => `${props.speed}s`);
const isPaused = ref(false);
const progress = useMotionValue(0);
const elapsedRef = ref(0);
const lastTimeRef = ref<number | null>(null);
const directionRef = ref(props.direction === 'left' ? 1 : -1);
const animationDuration = computed(() => props.speed * 1000);
const delayDuration = computed(() => props.delay * 1000);
useAnimationFrame(time => {
if (props.disabled || isPaused.value) {
lastTimeRef.value = null;
return;
}
if (lastTimeRef.value === null) {
lastTimeRef.value = time;
return;
}
const deltaTime = time - lastTimeRef.value;
lastTimeRef.value = time;
elapsedRef.value += deltaTime;
// Animation goes from 0 to 100
if (props.yoyo) {
const cycleDuration = animationDuration.value + delayDuration.value;
const fullCycle = cycleDuration * 2;
const cycleTime = elapsedRef.value % fullCycle;
if (cycleTime < animationDuration.value) {
// Forward animation: 0 -> 100
const p = (cycleTime / animationDuration.value) * 100;
progress.set(directionRef.value === 1 ? p : 100 - p);
} else if (cycleTime < cycleDuration) {
// Delay at end
progress.set(directionRef.value === 1 ? 100 : 0);
} else if (cycleTime < cycleDuration + animationDuration.value) {
// Reverse animation: 100 -> 0
const reverseTime = cycleTime - cycleDuration;
const p = 100 - (reverseTime / animationDuration.value) * 100;
progress.set(directionRef.value === 1 ? p : 100 - p);
} else {
// Delay at start
progress.set(directionRef.value === 1 ? 0 : 100);
}
} else {
const cycleDuration = animationDuration.value + delayDuration.value;
const cycleTime = elapsedRef.value % cycleDuration;
if (cycleTime < animationDuration.value) {
// Animation phase: 0 -> 100
const p = (cycleTime / animationDuration.value) * 100;
progress.set(directionRef.value === 1 ? p : 100 - p);
} else {
// Delay phase - hold at end (shine off-screen)
progress.set(directionRef.value === 1 ? 100 : 0);
}
}
});
watch(
() => props.direction,
() => {
directionRef.value = props.direction === 'left' ? 1 : -1;
elapsedRef.value = 0;
progress.set(0);
},
{
immediate: true
}
);
const backgroundPosition = useTransform(progress, p => `${150 - p * 2}% center`);
const handleMouseEnter = () => {
if (props.pauseOnHover) isPaused.value = true;
};
const handleMouseLeave = () => {
if (props.pauseOnHover) isPaused.value = false;
};
const gradientStyle = computed(() => ({
backgroundImage: `linear-gradient(${props.spread}deg, ${props.color} 0%, ${props.color} 35%, ${props.shineColor} 50%, ${props.color} 65%, ${props.color} 100%)`,
backgroundSize: '200% auto',
WebkitBackgroundClip: 'text',
backgroundClip: 'text',
WebkitTextFillColor: 'transparent'
}));
</script>
<template>
<div
:class="`text-[#b5b5b5a4] bg-clip-text inline-block ${!props.disabled ? 'animate-shine' : ''} ${props.className}`"
:style="{
backgroundImage:
'linear-gradient(120deg, rgba(255, 255, 255, 0) 40%, rgba(255, 255, 255, 0.8) 50%, rgba(255, 255, 255, 0) 60%)',
backgroundSize: '200% 100%',
WebkitBackgroundClip: 'text',
animationDuration: animationDuration
}"
<Motion
tag="span"
:class="['inline-block', className]"
:style="{ ...gradientStyle, backgroundPosition }"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
>
{{ props.text }}
</div>
{{ text }}
</Motion>
</template>
<style scoped>
@keyframes shine {
0% {
background-position: 100%;
}
100% {
background-position: -100%;
}
}
.animate-shine {
animation: shine 5s linear infinite;
}
</style>

View File

@@ -1,18 +1,11 @@
<template>
<p
ref="textRef"
:class="`split-parent overflow-hidden inline-block whitespace-normal ${className}`"
:style="{
textAlign,
wordWrap: 'break-word'
}"
>
<component :is="tag" ref="elRef" :style="styles" :class="classes">
{{ text }}
</p>
</component>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch, nextTick, useTemplateRef } from 'vue';
import { ref, onMounted, watch, type CSSProperties, onBeforeUnmount, computed } from 'vue';
import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
import { SplitText as GSAPSplitText } from 'gsap/SplitText';
@@ -25,25 +18,27 @@ export interface SplitTextProps {
delay?: number;
duration?: number;
ease?: string | ((t: number) => number);
splitType?: 'chars' | 'words' | 'lines' | 'words, chars';
splitType?: 'chars' | 'words' | 'lines';
from?: gsap.TweenVars;
to?: gsap.TweenVars;
threshold?: number;
rootMargin?: string;
textAlign?: 'left' | 'center' | 'right' | 'justify';
tag?: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'p' | 'span';
textAlign?: CSSProperties['textAlign'];
onLetterAnimationComplete?: () => void;
}
const props = withDefaults(defineProps<SplitTextProps>(), {
className: '',
delay: 100,
duration: 0.6,
delay: 50,
duration: 1.25,
ease: 'power3.out',
splitType: 'chars',
from: () => ({ opacity: 0, y: 40 }),
to: () => ({ opacity: 1, y: 0 }),
threshold: 0.1,
rootMargin: '-100px',
tag: 'p',
textAlign: 'center'
});
@@ -51,144 +46,134 @@ const emit = defineEmits<{
'animation-complete': [];
}>();
const textRef = useTemplateRef<HTMLParagraphElement>('textRef');
const animationCompletedRef = ref(false);
const scrollTriggerRef = ref<ScrollTrigger | null>(null);
const timelineRef = ref<gsap.core.Timeline | null>(null);
const splitterRef = ref<GSAPSplitText | null>(null);
const elRef = ref<HTMLElement | null>(null);
const fontsLoaded = ref(false);
const animationCompleted = ref(false);
const initializeAnimation = async () => {
if (typeof window === 'undefined' || !textRef.value || !props.text) return;
let splitInstance: GSAPSplitText | null = null;
await nextTick();
const el = textRef.value;
animationCompletedRef.value = false;
const absoluteLines = props.splitType === 'lines';
if (absoluteLines) el.style.position = 'relative';
let splitter: GSAPSplitText;
try {
splitter = new GSAPSplitText(el, {
type: props.splitType,
absolute: absoluteLines,
linesClass: 'split-line'
onMounted(() => {
if (document.fonts.status === 'loaded') {
fontsLoaded.value = true;
} else {
document.fonts.ready.then(() => {
fontsLoaded.value = true;
});
splitterRef.value = splitter;
} catch (error) {
console.error('Failed to create SplitText:', error);
return;
}
});
let targets: Element[];
switch (props.splitType) {
case 'lines':
targets = splitter.lines;
break;
case 'words':
targets = splitter.words;
break;
case 'chars':
targets = splitter.chars;
break;
default:
targets = splitter.chars;
const runAnimation = () => {
if (!elRef.value || !props.text || !fontsLoaded.value) return;
if (animationCompleted.value) return;
const el = elRef.value as HTMLElement & {
_rbsplitInstance?: GSAPSplitText;
};
// cleanup previous
if (el._rbsplitInstance) {
try {
el._rbsplitInstance.revert();
} catch {}
el._rbsplitInstance = undefined;
}
if (!targets || targets.length === 0) {
console.warn('No targets found for SplitText animation');
splitter.revert();
return;
}
targets.forEach(t => {
(t as HTMLElement).style.willChange = 'transform, opacity';
});
const startPct = (1 - props.threshold) * 100;
const marginMatch = /^(-?\d+(?:\.\d+)?)(px|em|rem|%)?$/.exec(props.rootMargin);
const marginValue = marginMatch ? parseFloat(marginMatch[1]) : 0;
const marginUnit = marginMatch ? marginMatch[2] || 'px' : 'px';
const sign = marginValue < 0 ? `-=${Math.abs(marginValue)}${marginUnit}` : `+=${marginValue}${marginUnit}`;
const marginUnit = marginMatch?.[2] || 'px';
const sign =
marginValue === 0
? ''
: marginValue < 0
? `-=${Math.abs(marginValue)}${marginUnit}`
: `+=${marginValue}${marginUnit}`;
const start = `top ${startPct}%${sign}`;
const tl = gsap.timeline({
scrollTrigger: {
trigger: el,
start,
toggleActions: 'play none none none',
once: true,
onToggle: self => {
scrollTriggerRef.value = self;
}
},
smoothChildTiming: true,
onComplete: () => {
animationCompletedRef.value = true;
gsap.set(targets, {
...props.to,
clearProps: 'willChange',
immediateRender: true
});
props.onLetterAnimationComplete?.();
emit('animation-complete');
let targets: Element[] = [];
const assignTargets = (self: GSAPSplitText) => {
if (props.splitType.includes('chars') && self.chars?.length) targets = self.chars;
if (!targets.length && props.splitType.includes('words') && self.words?.length) targets = self.words;
if (!targets.length && props.splitType.includes('lines') && self.lines?.length) targets = self.lines;
if (!targets.length) targets = self.chars || self.words || self.lines;
};
splitInstance = new GSAPSplitText(el, {
type: props.splitType,
smartWrap: true,
autoSplit: props.splitType === 'lines',
linesClass: 'split-line',
wordsClass: 'split-word',
charsClass: 'split-char',
reduceWhiteSpace: false,
onSplit(self) {
assignTargets(self);
return gsap.fromTo(
targets,
{ ...props.from },
{
...props.to,
duration: props.duration,
ease: props.ease,
stagger: props.delay / 1000,
scrollTrigger: {
trigger: el,
start,
once: true,
fastScrollEnd: true,
anticipatePin: 0.4
},
onComplete() {
animationCompleted.value = true;
props.onLetterAnimationComplete?.();
emit('animation-complete');
},
willChange: 'transform, opacity',
force3D: true
}
);
}
});
timelineRef.value = tl;
tl.set(targets, { ...props.from, immediateRender: false, force3D: true });
tl.to(targets, {
...props.to,
duration: props.duration,
ease: props.ease,
stagger: props.delay / 1000,
force3D: true
});
el._rbsplitInstance = splitInstance;
};
const cleanup = () => {
if (timelineRef.value) {
timelineRef.value.kill();
timelineRef.value = null;
}
if (scrollTriggerRef.value) {
scrollTriggerRef.value.kill();
scrollTriggerRef.value = null;
}
if (splitterRef.value) {
gsap.killTweensOf(textRef.value);
splitterRef.value.revert();
splitterRef.value = null;
}
};
onMounted(() => {
initializeAnimation();
});
onUnmounted(() => {
cleanup();
});
watch(
[
() => props.text,
() => props.delay,
() => props.duration,
() => props.ease,
() => props.splitType,
() => props.from,
() => props.to,
() => props.threshold,
() => props.rootMargin,
() => props.onLetterAnimationComplete
() => [
props.text,
props.delay,
props.duration,
props.ease,
props.splitType,
JSON.stringify(props.from),
JSON.stringify(props.to),
props.threshold,
props.rootMargin,
fontsLoaded.value
],
() => {
cleanup();
initializeAnimation();
}
runAnimation,
{ deep: true }
);
onBeforeUnmount(() => {
ScrollTrigger.getAll().forEach(st => {
if (st.trigger === elRef.value) st.kill();
});
try {
splitInstance?.revert();
} catch {}
});
const styles = computed(() => ({
textAlign: props.textAlign,
wordWrap: 'break-word',
willChange: 'transform, opacity'
}));
const classes = computed(() => `split-parent overflow-hidden inline-block whitespace-normal ${props.className}`);
</script>