mirror of
https://github.com/DavidHDev/vue-bits.git
synced 2026-03-09 00:19:31 -06:00
Merge pull request #137 from Utkarsh-Singhal-26/refact/text-animations
[ REVAMP ] : Text Animations
This commit is contained in:
@@ -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"> </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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user