Merge branch 'main' into add-scramble-text

This commit is contained in:
Max
2025-07-12 18:49:03 +02:00
committed by GitHub
239 changed files with 18121 additions and 8750 deletions

View File

@@ -1,20 +1,20 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { Motion } from 'motion-v'
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
import { Motion } from 'motion-v';
interface BlurTextProps {
text?: string
delay?: number
className?: string
animateBy?: 'words' | 'letters'
direction?: 'top' | 'bottom'
threshold?: number
rootMargin?: string
animationFrom?: Record<string, string | number>
animationTo?: Array<Record<string, string | number>>
easing?: (t: number) => number
onAnimationComplete?: () => void
stepDuration?: number
text?: string;
delay?: number;
className?: string;
animateBy?: 'words' | 'letters';
direction?: 'top' | 'bottom';
threshold?: number;
rootMargin?: string;
animationFrom?: Record<string, string | number>;
animationTo?: Array<Record<string, string | number>>;
easing?: (t: number) => number;
onAnimationComplete?: () => void;
stepDuration?: number;
}
const props = withDefaults(defineProps<BlurTextProps>(), {
@@ -27,37 +27,30 @@ const props = withDefaults(defineProps<BlurTextProps>(), {
rootMargin: '0px',
easing: (t: number) => t,
stepDuration: 0.35
})
});
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 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 keyframes: Record<string, Array<string | number>> = {};
keys.forEach(k => {
keyframes[k] = [from[k], ...steps.map(s => s[k])];
});
return keyframes;
};
const elements = computed(() =>
props.animateBy === 'words' ? props.text.split(' ') : props.text.split('')
)
const elements = computed(() => (props.animateBy === 'words' ? props.text.split(' ') : props.text.split('')));
const inView = ref(false)
const rootRef = ref<HTMLParagraphElement | null>(null)
let observer: IntersectionObserver | null = null
const inView = ref(false);
const rootRef = ref<HTMLParagraphElement | null>(null);
let observer: IntersectionObserver | null = null;
const defaultFrom = computed(() =>
props.direction === 'top'
? { filter: 'blur(10px)', opacity: 0, y: -50 }
: { filter: 'blur(10px)', opacity: 0, y: 50 }
)
props.direction === 'top' ? { filter: 'blur(10px)', opacity: 0, y: -50 } : { filter: 'blur(10px)', opacity: 0, y: 50 }
);
const defaultTo = computed(() => [
{
@@ -66,51 +59,49 @@ const defaultTo = computed(() => [
y: props.direction === 'top' ? 5 : -5
},
{ filter: 'blur(0px)', opacity: 1, y: 0 }
])
]);
const fromSnapshot = computed(() => props.animationFrom ?? defaultFrom.value)
const toSnapshots = computed(() => props.animationTo ?? defaultTo.value)
const fromSnapshot = computed(() => props.animationFrom ?? defaultFrom.value);
const toSnapshots = computed(() => props.animationTo ?? defaultTo.value);
const stepCount = computed(() => toSnapshots.value.length + 1)
const totalDuration = computed(() => props.stepDuration * (stepCount.value - 1))
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)
)
)
Array.from({ length: stepCount.value }, (_, i) => (stepCount.value === 1 ? 0 : i / (stepCount.value - 1)))
);
const setupObserver = () => {
if (!rootRef.value) return
if (!rootRef.value) return;
observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
inView.value = true
observer?.unobserve(rootRef.value as Element)
inView.value = true;
observer?.unobserve(rootRef.value as Element);
}
},
{ threshold: props.threshold, rootMargin: props.rootMargin }
)
);
observer.observe(rootRef.value)
}
observer.observe(rootRef.value);
};
onMounted(() => {
setupObserver()
})
setupObserver();
});
onUnmounted(() => {
observer?.disconnect()
})
observer?.disconnect();
});
watch([() => props.threshold, () => props.rootMargin], () => {
observer?.disconnect()
setupObserver()
})
observer?.disconnect();
setupObserver();
});
const getAnimateKeyframes = () => {
return buildKeyframes(fromSnapshot.value, toSnapshots.value)
}
return buildKeyframes(fromSnapshot.value, toSnapshots.value);
};
const getTransition = (index: number) => {
return {
@@ -118,24 +109,33 @@ const getTransition = (index: number) => {
times: times.value,
delay: (index * props.delay) / 1000,
ease: props.easing
}
}
};
};
const handleAnimationComplete = (index: number) => {
if (index === elements.value.length - 1 && props.onAnimationComplete) {
props.onAnimationComplete()
props.onAnimationComplete();
}
}
};
</script>
<template>
<p ref="rootRef" :class="`blur-text ${className} flex flex-wrap`">
<Motion v-for="(segment, index) in elements" :key="index" tag="span" :initial="fromSnapshot"
:animate="inView ? getAnimateKeyframes() : fromSnapshot" :transition="getTransition(index)" :style="{
<Motion
v-for="(segment, index) in elements"
:key="index"
tag="span"
:initial="fromSnapshot"
:animate="inView ? getAnimateKeyframes() : fromSnapshot"
:transition="getTransition(index)"
:style="{
display: 'inline-block',
willChange: 'transform, filter, opacity'
}" @animation-complete="handleAnimationComplete(index)">
{{ segment === ' ' ? '\u00A0' : segment }}{{ animateBy === 'words' && index < elements.length - 1 ? '\u00A0' : ''
}} </Motion>
}"
@animation-complete="handleAnimationComplete(index)"
>
{{ segment === ' ' ? '\u00A0' : segment
}}{{ animateBy === 'words' && index < elements.length - 1 ? '\u00A0' : '' }}
</Motion>
</p>
</template>

View File

@@ -1,12 +1,12 @@
<script setup lang="ts">
import { computed, ref, watchEffect, onUnmounted } from 'vue'
import { Motion } from 'motion-v'
import { computed, ref, watchEffect, onUnmounted } from 'vue';
import { Motion } from 'motion-v';
interface CircularTextProps {
text: string
spinDuration?: number
onHover?: 'slowDown' | 'speedUp' | 'pause' | 'goBonkers'
className?: string
text: string;
spinDuration?: number;
onHover?: 'slowDown' | 'speedUp' | 'pause' | 'goBonkers';
className?: string;
}
const props = withDefaults(defineProps<CircularTextProps>(), {
@@ -14,99 +14,101 @@ const props = withDefaults(defineProps<CircularTextProps>(), {
spinDuration: 20,
onHover: 'speedUp',
className: ''
})
});
const letters = computed(() => Array.from(props.text))
const isHovered = ref(false)
const letters = computed(() => Array.from(props.text));
const isHovered = ref(false);
const currentRotation = ref(0)
const animationId = ref<number | null>(null)
const lastTime = ref<number>(Date.now())
const rotationSpeed = ref<number>(0)
const currentRotation = ref(0);
const animationId = ref<number | null>(null);
const lastTime = ref<number>(Date.now());
const rotationSpeed = ref<number>(0);
const getCurrentSpeed = () => {
if (isHovered.value && props.onHover === 'pause') return 0
if (isHovered.value && props.onHover === 'pause') return 0;
const baseDuration = props.spinDuration
const baseSpeed = 360 / baseDuration
const baseDuration = props.spinDuration;
const baseSpeed = 360 / baseDuration;
if (!isHovered.value) return baseSpeed
if (!isHovered.value) return baseSpeed;
switch (props.onHover) {
case 'slowDown':
return baseSpeed / 2
return baseSpeed / 2;
case 'speedUp':
return baseSpeed * 4
return baseSpeed * 4;
case 'goBonkers':
return baseSpeed * 20
return baseSpeed * 20;
default:
return baseSpeed
return baseSpeed;
}
}
};
const getCurrentScale = () => {
return (isHovered.value && props.onHover === 'goBonkers') ? 0.8 : 1
}
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 now = Date.now();
const deltaTime = (now - lastTime.value) / 1000;
lastTime.value = now;
const targetSpeed = getCurrentSpeed()
const targetSpeed = getCurrentSpeed();
const speedDiff = targetSpeed - rotationSpeed.value
const smoothingFactor = Math.min(1, deltaTime * 5)
rotationSpeed.value += speedDiff * smoothingFactor
const speedDiff = targetSpeed - rotationSpeed.value;
const smoothingFactor = Math.min(1, deltaTime * 5);
rotationSpeed.value += speedDiff * smoothingFactor;
currentRotation.value = (currentRotation.value + rotationSpeed.value * deltaTime) % 360
currentRotation.value = (currentRotation.value + rotationSpeed.value * deltaTime) % 360;
animationId.value = requestAnimationFrame(animate)
}
animationId.value = requestAnimationFrame(animate);
};
const startAnimation = () => {
if (animationId.value) {
cancelAnimationFrame(animationId.value)
cancelAnimationFrame(animationId.value);
}
lastTime.value = Date.now()
rotationSpeed.value = getCurrentSpeed()
animate()
}
lastTime.value = Date.now();
rotationSpeed.value = getCurrentSpeed();
animate();
};
watchEffect(() => {
startAnimation()
})
startAnimation();
});
startAnimation()
startAnimation();
onUnmounted(() => {
if (animationId.value) {
cancelAnimationFrame(animationId.value)
cancelAnimationFrame(animationId.value);
}
})
});
const handleHoverStart = () => {
isHovered.value = true
}
isHovered.value = true;
};
const handleHoverEnd = () => {
isHovered.value = false
}
isHovered.value = false;
};
const getLetterTransform = (index: number) => {
const rotationDeg = (360 / letters.value.length) * index
const factor = Math.PI / letters.value.length
const x = factor * index
const y = factor * index
return `rotateZ(${rotationDeg}deg) translate3d(${x}px, ${y}px, 0)`
}
const rotationDeg = (360 / letters.value.length) * index;
const factor = Math.PI / letters.value.length;
const x = factor * index;
const y = factor * index;
return `rotateZ(${rotationDeg}deg) translate3d(${x}px, ${y}px, 0)`;
};
</script>
<template>
<Motion :animate="{
rotate: currentRotation,
scale: getCurrentScale()
}" :transition="{
<Motion
:animate="{
rotate: currentRotation,
scale: getCurrentScale()
}"
:transition="{
rotate: {
duration: 0
},
@@ -117,13 +119,19 @@ const getLetterTransform = (index: number) => {
}
}"
: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)]" :style="{
@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)]"
:style="{
transform: getLetterTransform(i),
WebkitTransform: getLetterTransform(i)
}">
}"
>
{{ letter }}
</span>
</Motion>
</template>
</template>

View File

@@ -1,13 +1,13 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch, computed, nextTick } from 'vue'
import { ref, onMounted, onUnmounted, watch, computed, nextTick } from 'vue';
interface CurvedLoopProps {
marqueeText?: string
speed?: number
className?: string
curveAmount?: number
direction?: 'left' | 'right'
interactive?: boolean
marqueeText?: string;
speed?: number;
className?: string;
curveAmount?: number;
direction?: 'left' | 'right';
interactive?: boolean;
}
const props = withDefaults(defineProps<CurvedLoopProps>(), {
@@ -17,161 +17,166 @@ const props = withDefaults(defineProps<CurvedLoopProps>(), {
curveAmount: 400,
direction: 'left',
interactive: true
})
});
const text = computed(() => {
const hasTrailing = /\s|\u00A0$/.test(props.marqueeText)
return (
(hasTrailing ? props.marqueeText.replace(/\s+$/, '') : props.marqueeText) + '\u00A0'
)
})
const hasTrailing = /\s|\u00A0$/.test(props.marqueeText);
return (hasTrailing ? props.marqueeText.replace(/\s+$/, '') : props.marqueeText) + '\u00A0';
});
const measureRef = ref<SVGTextElement | null>(null)
const tspansRef = ref<SVGTSpanElement[]>([])
const pathRef = ref<SVGPathElement | null>(null)
const pathLength = ref(0)
const spacing = ref(0)
const uid = Math.random().toString(36).substr(2, 9)
const pathId = `curve-${uid}`
const measureRef = ref<SVGTextElement | null>(null);
const tspansRef = ref<SVGTSpanElement[]>([]);
const pathRef = ref<SVGPathElement | null>(null);
const pathLength = ref(0);
const spacing = ref(0);
const uid = Math.random().toString(36).substr(2, 9);
const pathId = `curve-${uid}`;
const pathD = computed(() => `M-100,40 Q500,${40 + props.curveAmount} 1540,40`)
const pathD = computed(() => `M-100,40 Q500,${40 + props.curveAmount} 1540,40`);
const dragRef = ref(false)
const lastXRef = ref(0)
const dirRef = ref<'left' | 'right'>(props.direction)
const velRef = ref(0)
const dragRef = ref(false);
const lastXRef = ref(0);
const dirRef = ref<'left' | 'right'>(props.direction);
const velRef = ref(0);
let animationFrame: number | null = null
let animationFrame: number | null = null;
const updateSpacing = () => {
if (measureRef.value) {
spacing.value = measureRef.value.getComputedTextLength()
spacing.value = measureRef.value.getComputedTextLength();
}
}
};
const updatePathLength = () => {
if (pathRef.value) {
pathLength.value = pathRef.value.getTotalLength()
pathLength.value = pathRef.value.getTotalLength();
}
}
};
const animate = () => {
if (!spacing.value) return
if (!spacing.value) return;
const step = () => {
tspansRef.value.forEach((t) => {
if (!t) return
let x = parseFloat(t.getAttribute('x') || '0')
tspansRef.value.forEach(t => {
if (!t) return;
let x = parseFloat(t.getAttribute('x') || '0');
if (!dragRef.value) {
const delta = dirRef.value === 'right' ? Math.abs(props.speed) : -Math.abs(props.speed)
x += delta
const delta = dirRef.value === 'right' ? Math.abs(props.speed) : -Math.abs(props.speed);
x += delta;
}
const maxX = (tspansRef.value.length - 1) * spacing.value
if (x < -spacing.value) x = maxX
if (x > maxX) x = -spacing.value
t.setAttribute('x', x.toString())
})
animationFrame = requestAnimationFrame(step)
}
step()
}
const maxX = (tspansRef.value.length - 1) * spacing.value;
if (x < -spacing.value) x = maxX;
if (x > maxX) x = -spacing.value;
t.setAttribute('x', x.toString());
});
animationFrame = requestAnimationFrame(step);
};
step();
};
const stopAnimation = () => {
if (animationFrame) {
cancelAnimationFrame(animationFrame)
animationFrame = null
cancelAnimationFrame(animationFrame);
animationFrame = null;
}
}
};
const repeats = computed(() => {
return pathLength.value && spacing.value ? Math.ceil(pathLength.value / spacing.value) + 2 : 0
})
return pathLength.value && spacing.value ? Math.ceil(pathLength.value / spacing.value) + 2 : 0;
});
const ready = computed(() => pathLength.value > 0 && spacing.value > 0)
const ready = computed(() => pathLength.value > 0 && spacing.value > 0);
const onPointerDown = (e: PointerEvent) => {
if (!props.interactive) return
dragRef.value = true
lastXRef.value = e.clientX
velRef.value = 0
; (e.target as HTMLElement).setPointerCapture(e.pointerId)
}
if (!props.interactive) return;
dragRef.value = true;
lastXRef.value = e.clientX;
velRef.value = 0;
(e.target as HTMLElement).setPointerCapture(e.pointerId);
};
const onPointerMove = (e: PointerEvent) => {
if (!props.interactive || !dragRef.value) return
const dx = e.clientX - lastXRef.value
lastXRef.value = e.clientX
velRef.value = dx
tspansRef.value.forEach((t) => {
if (!t) return
let x = parseFloat(t.getAttribute('x') || '0')
x += dx
const maxX = (tspansRef.value.length - 1) * spacing.value
if (x < -spacing.value) x = maxX
if (x > maxX) x = -spacing.value
t.setAttribute('x', x.toString())
})
}
if (!props.interactive || !dragRef.value) return;
const dx = e.clientX - lastXRef.value;
lastXRef.value = e.clientX;
velRef.value = dx;
tspansRef.value.forEach(t => {
if (!t) return;
let x = parseFloat(t.getAttribute('x') || '0');
x += dx;
const maxX = (tspansRef.value.length - 1) * spacing.value;
if (x < -spacing.value) x = maxX;
if (x > maxX) x = -spacing.value;
t.setAttribute('x', x.toString());
});
};
const endDrag = () => {
if (!props.interactive) return
dragRef.value = false
dirRef.value = velRef.value > 0 ? 'right' : 'left'
}
if (!props.interactive) return;
dragRef.value = false;
dirRef.value = velRef.value > 0 ? 'right' : 'left';
};
const cursorStyle = computed(() => {
return props.interactive
? dragRef.value
? 'grabbing'
: 'grab'
: 'auto'
})
return props.interactive ? (dragRef.value ? 'grabbing' : 'grab') : 'auto';
});
onMounted(() => {
nextTick(() => {
updateSpacing()
updatePathLength()
animate()
})
})
updateSpacing();
updatePathLength();
animate();
});
});
onUnmounted(() => {
stopAnimation()
})
stopAnimation();
});
watch([text, () => props.className], () => {
nextTick(() => {
updateSpacing()
})
})
updateSpacing();
});
});
watch(() => props.curveAmount, () => {
nextTick(() => {
updatePathLength()
})
})
watch(
() => props.curveAmount,
() => {
nextTick(() => {
updatePathLength();
});
}
);
watch([spacing, () => props.speed], () => {
stopAnimation()
stopAnimation();
if (spacing.value) {
animate()
animate();
}
})
});
watch(repeats, () => {
tspansRef.value = []
})
tspansRef.value = [];
});
</script>
<template>
<div class="min-h-screen flex items-center justify-center w-full" :style="{
visibility: ready ? 'visible' : 'hidden',
cursor: cursorStyle
}" @pointerdown="onPointerDown" @pointermove="onPointerMove" @pointerup="endDrag" @pointerleave="endDrag">
<div
class="min-h-screen flex items-center justify-center w-full"
:style="{
visibility: ready ? 'visible' : 'hidden',
cursor: cursorStyle
}"
@pointerdown="onPointerDown"
@pointermove="onPointerMove"
@pointerup="endDrag"
@pointerleave="endDrag"
>
<svg
class="select-none w-full overflow-visible block aspect-[100/12] text-[6rem] font-bold tracking-[5px] uppercase leading-none"
viewBox="0 0 1440 120">
<text ref="measureRef" xml:space="preserve" style="visibility: hidden; opacity: 0; pointer-events: none;">
viewBox="0 0 1440 120"
>
<text ref="measureRef" xml:space="preserve" style="visibility: hidden; opacity: 0; pointer-events: none">
{{ text }}
</text>
@@ -181,9 +186,16 @@ watch(repeats, () => {
<text v-if="ready" xml:space="preserve" :class="`fill-white ${className}`">
<textPath :href="`#${pathId}`" xml:space="preserve">
<tspan v-for="i in repeats" :key="i" :x="(i - 1) * spacing" :ref="(el) => {
if (el) tspansRef[i - 1] = el as SVGTSpanElement
}">
<tspan
v-for="i in repeats"
:key="i"
:x="(i - 1) * spacing"
:ref="
el => {
if (el) tspansRef[i - 1] = el as SVGTSpanElement;
}
"
>
{{ text }}
</tspan>
</textPath>

View File

@@ -1,18 +1,18 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue';
interface DecryptedTextProps {
text: string
speed?: number
maxIterations?: number
sequential?: boolean
revealDirection?: 'start' | 'end' | 'center'
useOriginalCharsOnly?: boolean
characters?: string
className?: string
encryptedClassName?: string
parentClassName?: string
animateOn?: 'view' | 'hover'
text: string;
speed?: number;
maxIterations?: number;
sequential?: boolean;
revealDirection?: 'start' | 'end' | 'center';
useOriginalCharsOnly?: boolean;
characters?: string;
className?: string;
encryptedClassName?: string;
parentClassName?: string;
animateOn?: 'view' | 'hover';
}
const props = withDefaults(defineProps<DecryptedTextProps>(), {
@@ -27,201 +27,205 @@ const props = withDefaults(defineProps<DecryptedTextProps>(), {
parentClassName: '',
encryptedClassName: '',
animateOn: 'hover'
})
});
const emit = defineEmits<{
animationComplete: []
}>()
animationComplete: [];
}>();
const containerRef = ref<HTMLSpanElement>()
const displayText = ref(props.text)
const isHovering = ref(false)
const isScrambling = ref(false)
const revealedIndices = ref(new Set<number>())
const hasAnimated = ref(false)
const containerRef = ref<HTMLSpanElement>();
const displayText = ref(props.text);
const isHovering = ref(false);
const isScrambling = ref(false);
const revealedIndices = ref(new Set<number>());
const hasAnimated = ref(false);
let interval: number | null = null
let intersectionObserver: IntersectionObserver | null = null
let interval: number | null = null;
let intersectionObserver: IntersectionObserver | null = null;
watch([
() => isHovering.value,
() => props.text,
() => props.speed,
() => props.maxIterations,
() => props.sequential,
() => props.revealDirection,
() => props.characters,
() => props.useOriginalCharsOnly
], () => {
let currentIteration = 0
watch(
[
() => isHovering.value,
() => props.text,
() => props.speed,
() => props.maxIterations,
() => props.sequential,
() => props.revealDirection,
() => props.characters,
() => props.useOriginalCharsOnly
],
() => {
let currentIteration = 0;
const getNextIndex = (revealedSet: Set<number>): number => {
const textLength = props.text.length
switch (props.revealDirection) {
case 'start':
return revealedSet.size
case 'end':
return textLength - 1 - revealedSet.size
case 'center': {
const middle = Math.floor(textLength / 2)
const offset = Math.floor(revealedSet.size / 2)
const nextIndex =
revealedSet.size % 2 === 0
? middle + offset
: middle - offset - 1
const getNextIndex = (revealedSet: Set<number>): number => {
const textLength = props.text.length;
switch (props.revealDirection) {
case 'start':
return revealedSet.size;
case 'end':
return textLength - 1 - revealedSet.size;
case 'center': {
const middle = Math.floor(textLength / 2);
const offset = Math.floor(revealedSet.size / 2);
const nextIndex = revealedSet.size % 2 === 0 ? middle + offset : middle - offset - 1;
if (nextIndex >= 0 && nextIndex < textLength && !revealedSet.has(nextIndex)) {
return nextIndex
if (nextIndex >= 0 && nextIndex < textLength && !revealedSet.has(nextIndex)) {
return nextIndex;
}
for (let i = 0; i < textLength; i++) {
if (!revealedSet.has(i)) return i;
}
return 0;
}
for (let i = 0; i < textLength; i++) {
if (!revealedSet.has(i)) return i
}
return 0
default:
return revealedSet.size;
}
default:
return revealedSet.size
}
}
};
const availableChars = props.useOriginalCharsOnly
? Array.from(new Set(props.text.split(''))).filter((char) => char !== ' ')
: props.characters.split('')
const availableChars = props.useOriginalCharsOnly
? Array.from(new Set(props.text.split(''))).filter(char => char !== ' ')
: props.characters.split('');
const shuffleText = (originalText: string, currentRevealed: Set<number>): string => {
if (props.useOriginalCharsOnly) {
const positions = originalText.split('').map((char, i) => ({
char,
isSpace: char === ' ',
index: i,
isRevealed: currentRevealed.has(i)
}))
const shuffleText = (originalText: string, currentRevealed: Set<number>): string => {
if (props.useOriginalCharsOnly) {
const positions = originalText.split('').map((char, i) => ({
char,
isSpace: char === ' ',
index: i,
isRevealed: currentRevealed.has(i)
}));
const nonSpaceChars = positions
.filter((p) => !p.isSpace && !p.isRevealed)
.map((p) => p.char)
const nonSpaceChars = positions.filter(p => !p.isSpace && !p.isRevealed).map(p => p.char);
for (let i = nonSpaceChars.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[nonSpaceChars[i], nonSpaceChars[j]] = [nonSpaceChars[j], nonSpaceChars[i]]
}
let charIndex = 0
return positions
.map((p) => {
if (p.isSpace) return ' '
if (p.isRevealed) return originalText[p.index]
return nonSpaceChars[charIndex++]
})
.join('')
} else {
return originalText
.split('')
.map((char, i) => {
if (char === ' ') return ' '
if (currentRevealed.has(i)) return originalText[i]
return availableChars[Math.floor(Math.random() * availableChars.length)]
})
.join('')
}
}
if (interval) {
clearInterval(interval)
interval = null
}
if (isHovering.value) {
isScrambling.value = true
interval = setInterval(() => {
if (props.sequential) {
if (revealedIndices.value.size < props.text.length) {
const nextIndex = getNextIndex(revealedIndices.value)
const newRevealed = new Set(revealedIndices.value)
newRevealed.add(nextIndex)
revealedIndices.value = newRevealed
displayText.value = shuffleText(props.text, newRevealed)
} else {
clearInterval(interval!)
interval = null
isScrambling.value = false
emit('animationComplete')
for (let i = nonSpaceChars.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[nonSpaceChars[i], nonSpaceChars[j]] = [nonSpaceChars[j], nonSpaceChars[i]];
}
let charIndex = 0;
return positions
.map(p => {
if (p.isSpace) return ' ';
if (p.isRevealed) return originalText[p.index];
return nonSpaceChars[charIndex++];
})
.join('');
} else {
displayText.value = shuffleText(props.text, revealedIndices.value)
currentIteration++
if (currentIteration >= props.maxIterations) {
clearInterval(interval!)
interval = null
isScrambling.value = false
displayText.value = props.text
emit('animationComplete')
}
return originalText
.split('')
.map((char, i) => {
if (char === ' ') return ' ';
if (currentRevealed.has(i)) return originalText[i];
return availableChars[Math.floor(Math.random() * availableChars.length)];
})
.join('');
}
}, props.speed)
} else {
displayText.value = props.text
revealedIndices.value = new Set()
isScrambling.value = false
};
if (interval) {
clearInterval(interval);
interval = null;
}
if (isHovering.value) {
isScrambling.value = true;
interval = setInterval(() => {
if (props.sequential) {
if (revealedIndices.value.size < props.text.length) {
const nextIndex = getNextIndex(revealedIndices.value);
const newRevealed = new Set(revealedIndices.value);
newRevealed.add(nextIndex);
revealedIndices.value = newRevealed;
displayText.value = shuffleText(props.text, newRevealed);
} else {
clearInterval(interval!);
interval = null;
isScrambling.value = false;
emit('animationComplete');
}
} else {
displayText.value = shuffleText(props.text, revealedIndices.value);
currentIteration++;
if (currentIteration >= props.maxIterations) {
clearInterval(interval!);
interval = null;
isScrambling.value = false;
displayText.value = props.text;
emit('animationComplete');
}
}
}, props.speed);
} else {
displayText.value = props.text;
revealedIndices.value = new Set();
isScrambling.value = false;
}
}
})
);
const handleMouseEnter = () => {
if (props.animateOn === 'hover') {
isHovering.value = true
isHovering.value = true;
}
}
};
const handleMouseLeave = () => {
if (props.animateOn === 'hover') {
isHovering.value = false
isHovering.value = false;
}
}
};
onMounted(async () => {
if (props.animateOn === 'view') {
await nextTick()
await nextTick();
const observerCallback = (entries: IntersectionObserverEntry[]) => {
entries.forEach((entry) => {
entries.forEach(entry => {
if (entry.isIntersecting && !hasAnimated.value) {
isHovering.value = true
hasAnimated.value = true
isHovering.value = true;
hasAnimated.value = true;
}
})
}
});
};
const observerOptions = {
root: null,
rootMargin: '0px',
threshold: 0.1
}
};
intersectionObserver = new IntersectionObserver(observerCallback, observerOptions)
intersectionObserver = new IntersectionObserver(observerCallback, observerOptions);
if (containerRef.value) {
intersectionObserver.observe(containerRef.value)
intersectionObserver.observe(containerRef.value);
}
}
})
});
onUnmounted(() => {
if (interval) {
clearInterval(interval)
clearInterval(interval);
}
if (intersectionObserver && containerRef.value) {
intersectionObserver.unobserve(containerRef.value)
intersectionObserver.unobserve(containerRef.value);
}
})
});
</script>
<template>
<span ref="containerRef" :class="`inline-block whitespace-pre-wrap ${props.parentClassName}`"
@mouseenter="handleMouseEnter" @mouseleave="handleMouseLeave">
<span
ref="containerRef"
:class="`inline-block whitespace-pre-wrap ${props.parentClassName}`"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
>
<span class="sr-only">{{ displayText }}</span>
<span aria-hidden="true">
<span v-for="(char, index) in displayText.split('')" :key="index" :class="(revealedIndices.has(index) || !isScrambling || !isHovering)
? props.className
: props.encryptedClassName">
<span
v-for="(char, index) in displayText.split('')"
:key="index"
:class="revealedIndices.has(index) || !isScrambling || !isHovering ? props.className : props.encryptedClassName"
>
{{ char }}
</span>
</span>

View File

@@ -1,16 +1,16 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
import Matter from 'matter-js'
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue';
import Matter from 'matter-js';
interface FallingTextProps {
text?: string
highlightWords?: string[]
trigger?: 'auto' | 'scroll' | 'click' | 'hover'
backgroundColor?: string
wireframes?: boolean
gravity?: number
mouseConstraintStiffness?: number
fontSize?: string
text?: string;
highlightWords?: string[];
trigger?: 'auto' | 'scroll' | 'click' | 'hover';
backgroundColor?: string;
wireframes?: boolean;
gravity?: number;
mouseConstraintStiffness?: number;
fontSize?: string;
}
const props = withDefaults(defineProps<FallingTextProps>(), {
@@ -22,80 +22,79 @@ const props = withDefaults(defineProps<FallingTextProps>(), {
gravity: 1,
mouseConstraintStiffness: 0.2,
fontSize: '1rem'
})
});
const containerRef = ref<HTMLDivElement>()
const textRef = ref<HTMLDivElement>()
const canvasContainerRef = ref<HTMLDivElement>()
const containerRef = ref<HTMLDivElement>();
const textRef = ref<HTMLDivElement>();
const canvasContainerRef = ref<HTMLDivElement>();
const effectStarted = ref(false)
const effectStarted = ref(false);
let engine: Matter.Engine | null = null
let render: Matter.Render | null = null
let runner: Matter.Runner | null = null
let mouseConstraint: Matter.MouseConstraint | null = null
let wordBodies: Array<{ elem: HTMLElement; body: Matter.Body }> = []
let intersectionObserver: IntersectionObserver | null = null
let animationFrameId: number | null = null
let engine: Matter.Engine | null = null;
let render: Matter.Render | null = null;
let runner: Matter.Runner | null = null;
let mouseConstraint: Matter.MouseConstraint | null = null;
let wordBodies: Array<{ elem: HTMLElement; body: Matter.Body }> = [];
let intersectionObserver: IntersectionObserver | null = null;
let animationFrameId: number | null = null;
const createTextHTML = () => {
if (!textRef.value) return
if (!textRef.value) return;
const words = props.text.split(' ')
const words = props.text.split(' ');
const newHTML = words
.map((word) => {
const isHighlighted = props.highlightWords.some((hw) => word.startsWith(hw))
return `<span class="inline-block mx-[2px] select-none ${isHighlighted ? 'text-green-500 font-bold' : ''
}">${word}</span>`
.map(word => {
const isHighlighted = props.highlightWords.some(hw => word.startsWith(hw));
return `<span class="inline-block mx-[2px] select-none ${isHighlighted ? 'text-green-500 font-bold' : ''}">${word}</span>`;
})
.join(' ')
.join(' ');
textRef.value.innerHTML = newHTML
}
textRef.value.innerHTML = newHTML;
};
const setupTrigger = () => {
if (props.trigger === 'auto') {
setTimeout(() => {
effectStarted.value = true
}, 100)
return
effectStarted.value = true;
}, 100);
return;
}
if (props.trigger === 'scroll' && containerRef.value) {
intersectionObserver = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
effectStarted.value = true
intersectionObserver?.disconnect()
effectStarted.value = true;
intersectionObserver?.disconnect();
}
},
{ threshold: 0.1 }
)
intersectionObserver.observe(containerRef.value)
);
intersectionObserver.observe(containerRef.value);
}
}
};
const handleTrigger = () => {
if (!effectStarted.value && (props.trigger === 'click' || props.trigger === 'hover')) {
effectStarted.value = true
effectStarted.value = true;
}
}
};
const startPhysics = async () => {
if (!containerRef.value || !canvasContainerRef.value || !textRef.value) return
if (!containerRef.value || !canvasContainerRef.value || !textRef.value) return;
await nextTick()
await nextTick();
const { Engine, Render, World, Bodies, Runner, Mouse, MouseConstraint } = Matter
const { Engine, Render, World, Bodies, Runner, Mouse, MouseConstraint } = Matter;
const containerRect = containerRef.value.getBoundingClientRect()
const width = containerRect.width
const height = containerRect.height
const containerRect = containerRef.value.getBoundingClientRect();
const width = containerRect.width;
const height = containerRect.height;
if (width <= 0 || height <= 0) return
if (width <= 0 || height <= 0) return;
engine = Engine.create()
engine.world.gravity.y = props.gravity
engine = Engine.create();
engine.world.gravity.y = props.gravity;
render = Render.create({
element: canvasContainerRef.value,
@@ -106,180 +105,175 @@ const startPhysics = async () => {
background: props.backgroundColor,
wireframes: props.wireframes
}
})
});
const boundaryOptions = {
isStatic: true,
render: { fillStyle: 'transparent' }
}
};
const floor = Bodies.rectangle(width / 2, height + 25, width, 50, boundaryOptions)
const leftWall = Bodies.rectangle(-25, height / 2, 50, height, boundaryOptions)
const rightWall = Bodies.rectangle(width + 25, height / 2, 50, height, boundaryOptions)
const ceiling = Bodies.rectangle(width / 2, -25, width, 50, boundaryOptions)
const floor = Bodies.rectangle(width / 2, height + 25, width, 50, boundaryOptions);
const leftWall = Bodies.rectangle(-25, height / 2, 50, height, boundaryOptions);
const rightWall = Bodies.rectangle(width + 25, height / 2, 50, height, boundaryOptions);
const ceiling = Bodies.rectangle(width / 2, -25, width, 50, boundaryOptions);
const wordSpans = textRef.value.querySelectorAll('span') as NodeListOf<HTMLElement>
wordBodies = Array.from(wordSpans).map((elem) => {
const rect = elem.getBoundingClientRect()
const containerRect = containerRef.value!.getBoundingClientRect()
const wordSpans = textRef.value.querySelectorAll('span') as NodeListOf<HTMLElement>;
wordBodies = Array.from(wordSpans).map(elem => {
const rect = elem.getBoundingClientRect();
const containerRect = containerRef.value!.getBoundingClientRect();
const x = rect.left - containerRect.left + rect.width / 2
const y = rect.top - containerRect.top + rect.height / 2
const x = rect.left - containerRect.left + rect.width / 2;
const y = rect.top - containerRect.top + rect.height / 2;
const body = Bodies.rectangle(x, y, rect.width, rect.height, {
render: { fillStyle: 'transparent' },
restitution: 0.8,
frictionAir: 0.01,
friction: 0.2
})
});
Matter.Body.setVelocity(body, {
x: (Math.random() - 0.5) * 5,
y: 0
})
Matter.Body.setAngularVelocity(body, (Math.random() - 0.5) * 0.05)
});
Matter.Body.setAngularVelocity(body, (Math.random() - 0.5) * 0.05);
return { elem, body }
})
return { elem, body };
});
wordBodies.forEach(({ elem, body }) => {
elem.style.position = 'absolute'
elem.style.left = `${body.position.x - (body.bounds.max.x - body.bounds.min.x) / 2}px`
elem.style.top = `${body.position.y - (body.bounds.max.y - body.bounds.min.y) / 2}px`
elem.style.transform = 'none'
})
elem.style.position = 'absolute';
elem.style.left = `${body.position.x - (body.bounds.max.x - body.bounds.min.x) / 2}px`;
elem.style.top = `${body.position.y - (body.bounds.max.y - body.bounds.min.y) / 2}px`;
elem.style.transform = 'none';
});
const mouse = Mouse.create(containerRef.value)
const mouse = Mouse.create(containerRef.value);
mouseConstraint = MouseConstraint.create(engine, {
mouse,
constraint: {
stiffness: props.mouseConstraintStiffness,
render: { visible: false }
}
})
render.mouse = mouse
});
render.mouse = mouse;
World.add(engine.world, [
floor,
leftWall,
rightWall,
ceiling,
mouseConstraint,
...wordBodies.map((wb) => wb.body)
])
World.add(engine.world, [floor, leftWall, rightWall, ceiling, mouseConstraint, ...wordBodies.map(wb => wb.body)]);
runner = Runner.create()
Runner.run(runner, engine)
Render.run(render)
runner = Runner.create();
Runner.run(runner, engine);
Render.run(render);
const updateLoop = () => {
wordBodies.forEach(({ body, elem }) => {
const { x, y } = body.position
elem.style.left = `${x}px`
elem.style.top = `${y}px`
elem.style.transform = `translate(-50%, -50%) rotate(${body.angle}rad)`
})
Matter.Engine.update(engine!)
animationFrameId = requestAnimationFrame(updateLoop)
}
updateLoop()
}
const { x, y } = body.position;
elem.style.left = `${x}px`;
elem.style.top = `${y}px`;
elem.style.transform = `translate(-50%, -50%) rotate(${body.angle}rad)`;
});
Matter.Engine.update(engine!);
animationFrameId = requestAnimationFrame(updateLoop);
};
updateLoop();
};
const cleanup = () => {
if (animationFrameId) {
cancelAnimationFrame(animationFrameId)
animationFrameId = null
cancelAnimationFrame(animationFrameId);
animationFrameId = null;
}
if (render) {
Matter.Render.stop(render)
Matter.Render.stop(render);
if (render.canvas && canvasContainerRef.value) {
canvasContainerRef.value.removeChild(render.canvas)
canvasContainerRef.value.removeChild(render.canvas);
}
render = null
render = null;
}
if (runner && engine) {
Matter.Runner.stop(runner)
runner = null
Matter.Runner.stop(runner);
runner = null;
}
if (engine) {
Matter.World.clear(engine.world, false)
Matter.Engine.clear(engine)
engine = null
Matter.World.clear(engine.world, false);
Matter.Engine.clear(engine);
engine = null;
}
if (intersectionObserver) {
intersectionObserver.disconnect()
intersectionObserver = null
intersectionObserver.disconnect();
intersectionObserver = null;
}
mouseConstraint = null
wordBodies = []
}
mouseConstraint = null;
wordBodies = [];
};
watch(
() => [props.text, props.highlightWords],
() => {
createTextHTML()
createTextHTML();
},
{ immediate: true, deep: true }
)
);
watch(
() => props.trigger,
() => {
effectStarted.value = false
cleanup()
setupTrigger()
effectStarted.value = false;
cleanup();
setupTrigger();
},
{ immediate: true }
)
);
watch(
() => effectStarted.value,
(started) => {
started => {
if (started) {
startPhysics()
startPhysics();
}
}
)
);
watch(
() => [
props.gravity,
props.wireframes,
props.backgroundColor,
props.mouseConstraintStiffness
],
() => [props.gravity, props.wireframes, props.backgroundColor, props.mouseConstraintStiffness],
() => {
if (effectStarted.value) {
cleanup()
startPhysics()
cleanup();
startPhysics();
}
},
{ deep: true }
)
);
onMounted(() => {
createTextHTML()
setupTrigger()
})
createTextHTML();
setupTrigger();
});
onUnmounted(() => {
cleanup()
})
cleanup();
});
</script>
<template>
<div ref="containerRef" class="relative z-[1] w-full h-full cursor-pointer text-center pt-8 overflow-hidden"
<div
ref="containerRef"
class="relative z-[1] w-full h-full cursor-pointer text-center pt-8 overflow-hidden"
@click="props.trigger === 'click' ? handleTrigger() : undefined"
@mouseenter="props.trigger === 'hover' ? handleTrigger() : undefined">
<div ref="textRef" class="inline-block" :style="{
fontSize: props.fontSize,
lineHeight: 1.4
}" />
@mouseenter="props.trigger === 'hover' ? handleTrigger() : undefined"
>
<div
ref="textRef"
class="inline-block"
:style="{
fontSize: props.fontSize,
lineHeight: 1.4
}"
/>
<div class="absolute top-0 left-0 z-0" ref="canvasContainerRef" />
</div>

View File

@@ -1,15 +1,15 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue';
interface FuzzyTextProps {
text: string
fontSize?: number | string
fontWeight?: string | number
fontFamily?: string
color?: string
enableHover?: boolean
baseIntensity?: number
hoverIntensity?: number
text: string;
fontSize?: number | string;
fontWeight?: string | number;
fontFamily?: string;
color?: string;
enableHover?: boolean;
baseIntensity?: number;
hoverIntensity?: number;
}
const props = withDefaults(defineProps<FuzzyTextProps>(), {
@@ -21,246 +21,227 @@ const props = withDefaults(defineProps<FuzzyTextProps>(), {
enableHover: true,
baseIntensity: 0.18,
hoverIntensity: 0.5
})
});
const canvasRef = ref<HTMLCanvasElement | null>(null)
let animationFrameId: number
let isCancelled = false
let cleanup: (() => void) | null = null
const canvasRef = ref<HTMLCanvasElement | null>(null);
let animationFrameId: number;
let isCancelled = false;
let cleanup: (() => void) | null = null;
const waitForFont = async (fontFamily: string, fontWeight: string | number, fontSize: string): Promise<boolean> => {
if (document.fonts?.check) {
const fontString = `${fontWeight} ${fontSize} ${fontFamily}`
const fontString = `${fontWeight} ${fontSize} ${fontFamily}`;
if (document.fonts.check(fontString)) {
return true
return true;
}
try {
await document.fonts.load(fontString)
return document.fonts.check(fontString)
await document.fonts.load(fontString);
return document.fonts.check(fontString);
} catch (error) {
console.warn('Font loading failed:', error)
return false
console.warn('Font loading failed:', error);
return false;
}
}
return new Promise((resolve) => {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
return new Promise(resolve => {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) {
resolve(false)
return
resolve(false);
return;
}
ctx.font = `${fontWeight} ${fontSize} ${fontFamily}`
const testWidth = ctx.measureText('M').width
ctx.font = `${fontWeight} ${fontSize} ${fontFamily}`;
const testWidth = ctx.measureText('M').width;
let attempts = 0
let attempts = 0;
const checkFont = () => {
ctx.font = `${fontWeight} ${fontSize} ${fontFamily}`
const newWidth = ctx.measureText('M').width
ctx.font = `${fontWeight} ${fontSize} ${fontFamily}`;
const newWidth = ctx.measureText('M').width;
if (newWidth !== testWidth && newWidth > 0) {
resolve(true)
resolve(true);
} else if (attempts < 20) {
attempts++
setTimeout(checkFont, 50)
attempts++;
setTimeout(checkFont, 50);
} else {
resolve(false)
resolve(false);
}
}
};
setTimeout(checkFont, 10)
})
}
setTimeout(checkFont, 10);
});
};
const initCanvas = async () => {
if (document.fonts?.ready) {
await document.fonts.ready
await document.fonts.ready;
}
if (isCancelled) return
if (isCancelled) return;
const canvas = canvasRef.value
if (!canvas) return
const canvas = canvasRef.value;
if (!canvas) return;
const ctx = canvas.getContext('2d')
if (!ctx) return
const ctx = canvas.getContext('2d');
if (!ctx) return;
const computedFontFamily = props.fontFamily === 'inherit'
? window.getComputedStyle(canvas).fontFamily || 'sans-serif'
: props.fontFamily
const computedFontFamily =
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 fontSizeStr = typeof props.fontSize === 'number' ? `${props.fontSize}px` : props.fontSize;
let numericFontSize: number;
if (typeof props.fontSize === 'number') {
numericFontSize = props.fontSize
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)
document.body.removeChild(temp)
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);
document.body.removeChild(temp);
}
const fontLoaded = await waitForFont(computedFontFamily, props.fontWeight, fontSizeStr)
const fontLoaded = await waitForFont(computedFontFamily, props.fontWeight, fontSizeStr);
if (!fontLoaded) {
console.warn(`Font not loaded: ${computedFontFamily}`)
console.warn(`Font not loaded: ${computedFontFamily}`);
}
const text = props.text
const text = props.text;
const offscreen = document.createElement('canvas')
const offCtx = offscreen.getContext('2d')
if (!offCtx) return
const offscreen = document.createElement('canvas');
const offCtx = offscreen.getContext('2d');
if (!offCtx) return;
const fontString = `${props.fontWeight} ${fontSizeStr} ${computedFontFamily}`
offCtx.font = fontString
const fontString = `${props.fontWeight} ${fontSizeStr} ${computedFontFamily}`;
offCtx.font = fontString;
const testMetrics = offCtx.measureText('M')
const testMetrics = offCtx.measureText('M');
if (testMetrics.width === 0) {
setTimeout(() => {
if (!isCancelled) {
initCanvas()
initCanvas();
}
}, 100)
return
}, 100);
return;
}
offCtx.textBaseline = 'alphabetic'
const metrics = offCtx.measureText(text)
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
const actualLeft = metrics.actualBoundingBoxLeft ?? 0;
const actualRight = metrics.actualBoundingBoxRight ?? metrics.width;
const actualAscent = metrics.actualBoundingBoxAscent ?? numericFontSize;
const actualDescent = metrics.actualBoundingBoxDescent ?? numericFontSize * 0.2;
const textBoundingWidth = Math.ceil(actualLeft + actualRight)
const tightHeight = Math.ceil(actualAscent + actualDescent)
const textBoundingWidth = Math.ceil(actualLeft + actualRight);
const tightHeight = Math.ceil(actualAscent + actualDescent);
const extraWidthBuffer = 10
const offscreenWidth = textBoundingWidth + extraWidthBuffer
const extraWidthBuffer = 10;
const offscreenWidth = textBoundingWidth + extraWidthBuffer;
offscreen.width = offscreenWidth
offscreen.height = tightHeight
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 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 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
const interactiveLeft = horizontalMargin + xOffset;
const interactiveTop = verticalMargin;
const interactiveRight = interactiveLeft + textBoundingWidth;
const interactiveBottom = interactiveTop + tightHeight;
let isHovering = false
const fuzzRange = 30
let isHovering = false;
const fuzzRange = 30;
const run = () => {
if (isCancelled) return
ctx.clearRect(
-fuzzRange,
-fuzzRange,
offscreenWidth + 2 * fuzzRange,
tightHeight + 2 * fuzzRange
)
const intensity = isHovering ? props.hoverIntensity : props.baseIntensity
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 dx = Math.floor(intensity * (Math.random() - 0.5) * fuzzRange);
ctx.drawImage(offscreen, 0, j, offscreenWidth, 1, dx, j, offscreenWidth, 1);
}
animationFrameId = window.requestAnimationFrame(run)
}
animationFrameId = window.requestAnimationFrame(run);
};
run()
run();
const isInsideTextArea = (x: number, y: number) =>
x >= interactiveLeft &&
x <= interactiveRight &&
y >= interactiveTop &&
y <= interactiveBottom
x >= interactiveLeft && x <= interactiveRight && y >= interactiveTop && y <= interactiveBottom;
const handleMouseMove = (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)
}
if (!props.enableHover) return;
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
isHovering = isInsideTextArea(x, y);
};
const handleMouseLeave = () => {
isHovering = false
}
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)
}
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
}
isHovering = false;
};
if (props.enableHover) {
canvas.addEventListener('mousemove', handleMouseMove)
canvas.addEventListener('mouseleave', handleMouseLeave)
canvas.addEventListener('touchmove', handleTouchMove, { passive: false })
canvas.addEventListener('touchend', handleTouchEnd)
canvas.addEventListener('mousemove', handleMouseMove);
canvas.addEventListener('mouseleave', handleMouseLeave);
canvas.addEventListener('touchmove', handleTouchMove, { passive: false });
canvas.addEventListener('touchend', handleTouchEnd);
}
cleanup = () => {
window.cancelAnimationFrame(animationFrameId)
window.cancelAnimationFrame(animationFrameId);
if (props.enableHover) {
canvas.removeEventListener('mousemove', handleMouseMove)
canvas.removeEventListener('mouseleave', handleMouseLeave)
canvas.removeEventListener('touchmove', handleTouchMove)
canvas.removeEventListener('touchend', handleTouchEnd)
canvas.removeEventListener('mousemove', handleMouseMove);
canvas.removeEventListener('mouseleave', handleMouseLeave);
canvas.removeEventListener('touchmove', handleTouchMove);
canvas.removeEventListener('touchend', handleTouchEnd);
}
}
}
};
};
onMounted(() => {
nextTick(() => {
initCanvas()
})
})
initCanvas();
});
});
onUnmounted(() => {
isCancelled = true
isCancelled = true;
if (animationFrameId) {
window.cancelAnimationFrame(animationFrameId)
window.cancelAnimationFrame(animationFrameId);
}
if (cleanup) {
cleanup()
cleanup();
}
})
});
watch(
[
@@ -274,19 +255,19 @@ watch(
() => props.hoverIntensity
],
() => {
isCancelled = true
isCancelled = true;
if (animationFrameId) {
window.cancelAnimationFrame(animationFrameId)
window.cancelAnimationFrame(animationFrameId);
}
if (cleanup) {
cleanup()
cleanup();
}
isCancelled = false
isCancelled = false;
nextTick(() => {
initCanvas()
})
initCanvas();
});
}
)
);
</script>
<template>

View File

@@ -0,0 +1,183 @@
<template>
<div :class="computedClasses" :style="inlineStyles" :data-text="children">
{{ children }}
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import type { CSSProperties } from 'vue';
interface GlitchTextProps {
children: string;
speed?: number;
enableShadows?: boolean;
enableOnHover?: boolean;
className?: string;
}
interface CustomCSSProperties extends CSSProperties {
'--after-duration': string;
'--before-duration': string;
'--after-shadow': string;
'--before-shadow': string;
}
const props = withDefaults(defineProps<GlitchTextProps>(), {
speed: 0.5,
enableShadows: true,
enableOnHover: false,
className: ''
});
const inlineStyles = computed(
(): CustomCSSProperties => ({
'--after-duration': `${props.speed * 3}s`,
'--before-duration': `${props.speed * 2}s`,
'--after-shadow': props.enableShadows ? '-5px 0 red' : 'none',
'--before-shadow': props.enableShadows ? '5px 0 cyan' : 'none'
})
);
const baseClasses = [
'text-white',
'font-black',
'whitespace-nowrap',
'relative',
'mx-auto',
'select-none',
'cursor-pointer',
'text-[clamp(2rem,10vw,8rem)]',
'before:content-[attr(data-text)]',
'before:absolute',
'before:top-0',
'before:text-white',
'before:bg-[#060010]',
'before:overflow-hidden',
'before:[clip-path:inset(0_0_0_0)]',
'after:content-[attr(data-text)]',
'after:absolute',
'after:top-0',
'after:text-white',
'after:bg-[#060010]',
'after:overflow-hidden',
'after:[clip-path:inset(0_0_0_0)]'
];
const normalGlitchClasses = [
'after:left-[10px]',
'after:[text-shadow:var(--after-shadow,-10px_0_red)]',
'after:[animation:animate-glitch_var(--after-duration,3s)_infinite_linear_alternate-reverse]',
'before:left-[-10px]',
'before:[text-shadow:var(--before-shadow,10px_0_cyan)]',
'before:[animation:animate-glitch_var(--before-duration,2s)_infinite_linear_alternate-reverse]'
];
const hoverOnlyClasses = [
'before:content-[""]',
'before:opacity-0',
'before:[animation:none]',
'after:content-[""]',
'after:opacity-0',
'after:[animation:none]',
'hover:before:content-[attr(data-text)]',
'hover:before:opacity-100',
'hover:before:left-[-10px]',
'hover:before:[text-shadow:var(--before-shadow,10px_0_cyan)]',
'hover:before:[animation:animate-glitch_var(--before-duration,2s)_infinite_linear_alternate-reverse]',
'hover:after:content-[attr(data-text)]',
'hover:after:opacity-100',
'hover:after:left-[10px]',
'hover:after:[text-shadow:var(--after-shadow,-10px_0_red)]',
'hover:after:[animation:animate-glitch_var(--after-duration,3s)_infinite_linear_alternate-reverse]'
];
const computedClasses = computed(() => {
const classes = [...baseClasses];
if (props.enableOnHover) {
classes.push(...hoverOnlyClasses);
} else {
classes.push(...normalGlitchClasses);
}
if (props.className) {
classes.push(props.className);
}
return classes.join(' ');
});
</script>
<style>
@keyframes animate-glitch {
0% {
clip-path: inset(20% 0 50% 0);
}
5% {
clip-path: inset(10% 0 60% 0);
}
10% {
clip-path: inset(15% 0 55% 0);
}
15% {
clip-path: inset(25% 0 35% 0);
}
20% {
clip-path: inset(30% 0 40% 0);
}
25% {
clip-path: inset(40% 0 20% 0);
}
30% {
clip-path: inset(10% 0 60% 0);
}
35% {
clip-path: inset(15% 0 55% 0);
}
40% {
clip-path: inset(25% 0 35% 0);
}
45% {
clip-path: inset(30% 0 40% 0);
}
50% {
clip-path: inset(20% 0 50% 0);
}
55% {
clip-path: inset(10% 0 60% 0);
}
60% {
clip-path: inset(15% 0 55% 0);
}
65% {
clip-path: inset(25% 0 35% 0);
}
70% {
clip-path: inset(30% 0 40% 0);
}
75% {
clip-path: inset(40% 0 20% 0);
}
80% {
clip-path: inset(20% 0 50% 0);
}
85% {
clip-path: inset(10% 0 60% 0);
}
90% {
clip-path: inset(15% 0 55% 0);
}
95% {
clip-path: inset(25% 0 35% 0);
}
100% {
clip-path: inset(30% 0 40% 0);
}
}
</style>

View File

@@ -1,12 +1,12 @@
<script setup lang="ts">
import { computed } from 'vue'
import { computed } from 'vue';
interface GradientTextProps {
text: string
className?: string
colors?: string[]
animationSpeed?: number
showBorder?: boolean
text: string;
className?: string;
colors?: string[];
animationSpeed?: number;
showBorder?: boolean;
}
const props = withDefaults(defineProps<GradientTextProps>(), {
@@ -15,24 +15,24 @@ const props = withDefaults(defineProps<GradientTextProps>(), {
colors: () => ['#ffaa40', '#9c40ff', '#ffaa40'],
animationSpeed: 8,
showBorder: false
})
});
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'
}))
}));
</script>
<template>
@@ -46,20 +46,11 @@ const textStyle = computed(() => ({
>
<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%);
"
style="width: calc(100% - 2px); height: calc(100% - 2px); left: 50%; top: 50%; transform: translate(-50%, -50%)"
/>
</div>
<div
class="inline-block relative z-2 text-transparent bg-cover animate-gradient"
:style="textStyle"
>
<div class="inline-block relative z-2 text-transparent bg-cover animate-gradient" :style="textStyle">
{{ text }}
</div>
</div>

View File

@@ -0,0 +1,248 @@
<script setup lang="ts">
import { AnimatePresence, Motion, type Target, type Transition, type VariantLabels } from 'motion-v';
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
type StaggerFrom = 'first' | 'last' | 'center' | 'random' | number;
type SplitBy = 'characters' | 'words' | 'lines';
interface WordElement {
characters: string[];
needsSpace: boolean;
}
interface RotatingTextProps {
texts: string[];
transition?: Transition;
initial?: boolean | Target | VariantLabels;
animate?: Target | VariantLabels;
exit?: Target | VariantLabels;
animatePresenceMode?: 'sync' | 'wait';
animatePresenceInitial?: boolean;
rotationInterval?: number;
staggerDuration?: number;
staggerFrom?: StaggerFrom;
loop?: boolean;
auto?: boolean;
splitBy?: SplitBy;
onNext?: (index: number) => void;
mainClassName?: string;
splitLevelClassName?: string;
elementLevelClassName?: string;
}
const cn = (...classes: (string | undefined | null | boolean)[]): string => {
return classes.filter(Boolean).join(' ');
};
const props = withDefaults(defineProps<RotatingTextProps>(), {
transition: () =>
({
type: 'spring',
damping: 25,
stiffness: 300
}) as Transition,
initial: () => ({ y: '100%', opacity: 0 }) as Target,
animate: () => ({ y: 0, opacity: 1 }) as Target,
exit: () => ({ y: '-120%', opacity: 0 }) as Target,
animatePresenceMode: 'wait',
animatePresenceInitial: false,
rotationInterval: 2000,
staggerDuration: 0,
staggerFrom: 'first',
loop: true,
auto: true,
splitBy: 'characters'
});
const currentTextIndex = ref(0);
let intervalId: ReturnType<typeof setInterval> | null = null;
const splitIntoCharacters = (text: string): string[] => {
if (typeof Intl !== 'undefined' && 'Segmenter' in Intl) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const segmenter = new (Intl as any).Segmenter('en', { granularity: 'grapheme' });
return [...segmenter.segment(text)].map(({ segment }) => segment);
}
return [...text];
};
const elements = computed((): WordElement[] => {
const currentText = props.texts[currentTextIndex.value];
switch (props.splitBy) {
case 'characters': {
const words = currentText.split(' ');
return words.map((word, i) => ({
characters: splitIntoCharacters(word),
needsSpace: i !== words.length - 1
}));
}
case 'words': {
const words = currentText.split(' ');
return words.map((word, i) => ({
characters: [word],
needsSpace: i !== words.length - 1
}));
}
case 'lines': {
const lines = currentText.split('\n');
return lines.map((line, i) => ({
characters: [line],
needsSpace: i !== lines.length - 1
}));
}
default: {
const parts = currentText.split(props.splitBy!);
return parts.map((part, i) => ({
characters: [part],
needsSpace: i !== parts.length - 1
}));
}
}
});
const getStaggerDelay = (index: number, totalChars: number): number => {
const { staggerDuration, staggerFrom } = props;
switch (staggerFrom) {
case 'first':
return index * staggerDuration;
case 'last':
return (totalChars - 1 - index) * staggerDuration;
case 'center': {
const center = Math.floor(totalChars / 2);
return Math.abs(center - index) * staggerDuration;
}
case 'random': {
const randomIndex = Math.floor(Math.random() * totalChars);
return Math.abs(randomIndex - index) * staggerDuration;
}
default:
return Math.abs((staggerFrom as number) - index) * staggerDuration;
}
};
const handleIndexChange = (newIndex: number): void => {
currentTextIndex.value = newIndex;
props.onNext?.(newIndex);
};
const next = (): void => {
const isAtEnd = currentTextIndex.value === props.texts.length - 1;
const nextIndex = isAtEnd ? (props.loop ? 0 : currentTextIndex.value) : currentTextIndex.value + 1;
if (nextIndex !== currentTextIndex.value) {
handleIndexChange(nextIndex);
}
};
const previous = (): void => {
const isAtStart = currentTextIndex.value === 0;
const prevIndex = isAtStart
? props.loop
? props.texts.length - 1
: currentTextIndex.value
: currentTextIndex.value - 1;
if (prevIndex !== currentTextIndex.value) {
handleIndexChange(prevIndex);
}
};
const jumpTo = (index: number): void => {
const validIndex = Math.max(0, Math.min(index, props.texts.length - 1));
if (validIndex !== currentTextIndex.value) {
handleIndexChange(validIndex);
}
};
const reset = (): void => {
if (currentTextIndex.value !== 0) {
handleIndexChange(0);
}
};
const cleanupInterval = (): void => {
if (intervalId) {
clearInterval(intervalId);
intervalId = null;
}
};
const startInterval = (): void => {
if (props.auto) {
intervalId = setInterval(next, props.rotationInterval);
}
};
defineExpose({
next,
previous,
jumpTo,
reset
});
watch(
() => [props.auto, props.rotationInterval] as const,
() => {
cleanupInterval();
startInterval();
},
{ immediate: true }
);
onMounted(() => {
startInterval();
});
onUnmounted(() => {
cleanupInterval();
});
</script>
<template>
<Motion
tag="span"
:class="cn('flex flex-wrap whitespace-pre-wrap relative', mainClassName)"
v-bind="$attrs"
:transition="transition"
layout
>
<span class="sr-only">
{{ texts[currentTextIndex] }}
</span>
<AnimatePresence :mode="animatePresenceMode" :initial="animatePresenceInitial">
<Motion
:key="currentTextIndex"
tag="span"
:class="cn(splitBy === 'lines' ? 'flex flex-col w-full' : 'flex flex-wrap whitespace-pre-wrap relative')"
aria-hidden="true"
layout
>
<span v-for="(wordObj, wordIndex) in elements" :key="wordIndex" :class="cn('inline-flex', splitLevelClassName)">
<Motion
v-for="(char, charIndex) in wordObj.characters"
:key="charIndex"
tag="span"
:initial="initial"
:animate="animate"
:exit="exit"
:transition="{
...transition,
delay: getStaggerDelay(
elements.slice(0, wordIndex).reduce((sum, word) => sum + word.characters.length, 0) + charIndex,
elements.reduce((sum, word) => sum + word.characters.length, 0)
)
}"
:class="cn('inline-block', elementLevelClassName)"
>
{{ char }}
</Motion>
<span v-if="wordObj.needsSpace" class="whitespace-pre"></span>
</span>
</Motion>
</AnimatePresence>
</Motion>
</template>

View File

@@ -0,0 +1,120 @@
<template>
<h2 ref="containerRef" :class="`overflow-hidden ${containerClassName}`">
<span
:class="`inline-block text-center leading-relaxed font-black ${textClassName}`"
style="font-size: clamp(1.6rem, 8vw, 10rem)"
>
<span v-for="(char, index) in splitText" :key="index" class="inline-block char">
{{ char === ' ' ? '\u00A0' : char }}
</span>
</span>
</h2>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
gsap.registerPlugin(ScrollTrigger);
interface Props {
children: string;
scrollContainerRef?: { current: HTMLElement | null };
containerClassName?: string;
textClassName?: string;
animationDuration?: number;
ease?: string;
scrollStart?: string;
scrollEnd?: string;
stagger?: number;
}
const props = withDefaults(defineProps<Props>(), {
containerClassName: '',
textClassName: '',
animationDuration: 1,
ease: 'back.inOut(2)',
scrollStart: 'center bottom+=50%',
scrollEnd: 'bottom bottom-=40%',
stagger: 0.03
});
const containerRef = ref<HTMLElement | null>(null);
let scrollTriggerInstance: ScrollTrigger | null = null;
const splitText = computed(() => {
const text = typeof props.children === 'string' ? props.children : '';
return text.split('');
});
const initializeAnimation = () => {
const el = containerRef.value;
if (!el) return;
const scroller =
props.scrollContainerRef && props.scrollContainerRef.current ? props.scrollContainerRef.current : window;
const charElements = el.querySelectorAll('.char');
if (scrollTriggerInstance) {
scrollTriggerInstance.kill();
}
const tl = gsap.fromTo(
charElements,
{
willChange: 'opacity, transform',
opacity: 0,
yPercent: 120,
scaleY: 2.3,
scaleX: 0.7,
transformOrigin: '50% 0%'
},
{
duration: props.animationDuration,
ease: props.ease,
opacity: 1,
yPercent: 0,
scaleY: 1,
scaleX: 1,
stagger: props.stagger,
scrollTrigger: {
trigger: el,
scroller,
start: props.scrollStart,
end: props.scrollEnd,
scrub: true
}
}
);
scrollTriggerInstance = tl.scrollTrigger || null;
};
onMounted(() => {
initializeAnimation();
});
onUnmounted(() => {
if (scrollTriggerInstance) {
scrollTriggerInstance.kill();
}
});
watch(
[
() => props.children,
() => props.scrollContainerRef,
() => props.animationDuration,
() => props.ease,
() => props.scrollStart,
() => props.scrollEnd,
() => props.stagger
],
() => {
initializeAnimation();
},
{ deep: true }
);
</script>

View File

@@ -0,0 +1,155 @@
<template>
<h2 ref="containerRef" :class="`my-5 ${containerClassName}`">
<p :class="`leading-relaxed font-semibold ${textClassName}`" style="font-size: clamp(1.6rem, 4vw, 3rem)">
<span v-for="(word, index) in splitText" :key="index" :class="word.isWhitespace ? '' : 'inline-block word'">
{{ word.text }}
</span>
</p>
</h2>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
gsap.registerPlugin(ScrollTrigger);
interface Props {
children: string;
scrollContainerRef?: { current: HTMLElement | null };
enableBlur?: boolean;
baseOpacity?: number;
baseRotation?: number;
blurStrength?: number;
containerClassName?: string;
textClassName?: string;
rotationEnd?: string;
wordAnimationEnd?: string;
}
const props = withDefaults(defineProps<Props>(), {
enableBlur: true,
baseOpacity: 0.1,
baseRotation: 3,
blurStrength: 4,
containerClassName: '',
textClassName: '',
rotationEnd: 'bottom bottom',
wordAnimationEnd: 'bottom bottom'
});
const containerRef = ref<HTMLElement | null>(null);
let scrollTriggerInstances: ScrollTrigger[] = [];
const splitText = computed(() => {
const text = typeof props.children === 'string' ? props.children : '';
return text.split(/(\s+)/).map((word, index) => ({
text: word,
isWhitespace: word.match(/^\s+$/) !== null,
key: index
}));
});
const initializeAnimation = () => {
const el = containerRef.value;
if (!el) return;
scrollTriggerInstances.forEach(trigger => trigger.kill());
scrollTriggerInstances = [];
const scroller =
props.scrollContainerRef && props.scrollContainerRef.current ? props.scrollContainerRef.current : window;
const rotationTl = gsap.fromTo(
el,
{ transformOrigin: '0% 50%', rotate: props.baseRotation },
{
ease: 'none',
rotate: 0,
scrollTrigger: {
trigger: el,
scroller,
start: 'top bottom',
end: props.rotationEnd,
scrub: true
}
}
);
if (rotationTl.scrollTrigger) {
scrollTriggerInstances.push(rotationTl.scrollTrigger);
}
const wordElements = el.querySelectorAll('.word');
const opacityTl = gsap.fromTo(
wordElements,
{ opacity: props.baseOpacity, willChange: 'opacity' },
{
ease: 'none',
opacity: 1,
stagger: 0.05,
scrollTrigger: {
trigger: el,
scroller,
start: 'top bottom-=20%',
end: props.wordAnimationEnd,
scrub: true
}
}
);
if (opacityTl.scrollTrigger) {
scrollTriggerInstances.push(opacityTl.scrollTrigger);
}
if (props.enableBlur) {
const blurTl = gsap.fromTo(
wordElements,
{ filter: `blur(${props.blurStrength}px)` },
{
ease: 'none',
filter: 'blur(0px)',
stagger: 0.05,
scrollTrigger: {
trigger: el,
scroller,
start: 'top bottom-=20%',
end: props.wordAnimationEnd,
scrub: true
}
}
);
if (blurTl.scrollTrigger) {
scrollTriggerInstances.push(blurTl.scrollTrigger);
}
}
};
onMounted(() => {
initializeAnimation();
});
onUnmounted(() => {
scrollTriggerInstances.forEach(trigger => trigger.kill());
});
watch(
[
() => props.children,
() => props.scrollContainerRef,
() => props.enableBlur,
() => props.baseRotation,
() => props.baseOpacity,
() => props.rotationEnd,
() => props.wordAnimationEnd,
() => props.blurStrength
],
() => {
initializeAnimation();
},
{ deep: true }
);
</script>

View File

@@ -1,11 +1,11 @@
<script setup lang="ts">
import { computed } from 'vue'
import { computed } from 'vue';
interface ShinyTextProps {
text: string
disabled?: boolean
speed?: number
className?: string
text: string;
disabled?: boolean;
speed?: number;
className?: string;
}
const props = withDefaults(defineProps<ShinyTextProps>(), {
@@ -13,16 +13,17 @@ const props = withDefaults(defineProps<ShinyTextProps>(), {
disabled: false,
speed: 5,
className: ''
})
});
const animationDuration = computed(() => `${props.speed}s`)
const animationDuration = computed(() => `${props.speed}s`);
</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%)',
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

View File

@@ -4,7 +4,7 @@
:class="`split-parent overflow-hidden inline-block whitespace-normal ${className}`"
:style="{
textAlign,
wordWrap: 'break-word',
wordWrap: 'break-word'
}"
>
{{ text }}
@@ -12,26 +12,26 @@
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { gsap } from 'gsap'
import { ScrollTrigger } from 'gsap/ScrollTrigger'
import { SplitText as GSAPSplitText } from 'gsap/SplitText'
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue';
import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
import { SplitText as GSAPSplitText } from 'gsap/SplitText';
gsap.registerPlugin(ScrollTrigger, GSAPSplitText)
gsap.registerPlugin(ScrollTrigger, GSAPSplitText);
export interface SplitTextProps {
text: string
className?: string
delay?: number
duration?: number
ease?: string | ((t: number) => number)
splitType?: 'chars' | 'words' | 'lines' | 'words, chars'
from?: gsap.TweenVars
to?: gsap.TweenVars
threshold?: number
rootMargin?: string
textAlign?: 'left' | 'center' | 'right' | 'justify'
onLetterAnimationComplete?: () => void
text: string;
className?: string;
delay?: number;
duration?: number;
ease?: string | ((t: number) => number);
splitType?: 'chars' | 'words' | 'lines' | 'words, chars';
from?: gsap.TweenVars;
to?: gsap.TweenVars;
threshold?: number;
rootMargin?: string;
textAlign?: 'left' | 'center' | 'right' | 'justify';
onLetterAnimationComplete?: () => void;
}
const props = withDefaults(defineProps<SplitTextProps>(), {
@@ -45,74 +45,74 @@ const props = withDefaults(defineProps<SplitTextProps>(), {
threshold: 0.1,
rootMargin: '-100px',
textAlign: 'center'
})
});
const emit = defineEmits<{
'animation-complete': []
}>()
'animation-complete': [];
}>();
const textRef = ref<HTMLParagraphElement | null>(null)
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 textRef = ref<HTMLParagraphElement | null>(null);
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 initializeAnimation = async () => {
if (typeof window === 'undefined' || !textRef.value || !props.text) return
if (typeof window === 'undefined' || !textRef.value || !props.text) return;
await nextTick()
const el = textRef.value
animationCompletedRef.value = false
await nextTick();
const absoluteLines = props.splitType === 'lines'
if (absoluteLines) el.style.position = 'relative'
const el = textRef.value;
let splitter: GSAPSplitText
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',
})
splitterRef.value = splitter
linesClass: 'split-line'
});
splitterRef.value = splitter;
} catch (error) {
console.error('Failed to create SplitText:', error)
return
console.error('Failed to create SplitText:', error);
return;
}
let targets: Element[]
let targets: Element[];
switch (props.splitType) {
case 'lines':
targets = splitter.lines
break
targets = splitter.lines;
break;
case 'words':
targets = splitter.words
break
targets = splitter.words;
break;
case 'chars':
targets = splitter.chars
break
targets = splitter.chars;
break;
default:
targets = splitter.chars
targets = splitter.chars;
}
if (!targets || targets.length === 0) {
console.warn('No targets found for SplitText animation')
splitter.revert()
return
console.warn('No targets found for SplitText animation');
splitter.revert();
return;
}
targets.forEach((t) => {
;(t as HTMLElement).style.willChange = 'transform, opacity'
})
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 start = `top ${startPct}%${sign}`
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 start = `top ${startPct}%${sign}`;
const tl = gsap.timeline({
scrollTrigger: {
@@ -120,58 +120,58 @@ const initializeAnimation = async () => {
start,
toggleActions: 'play none none none',
once: true,
onToggle: (self) => {
scrollTriggerRef.value = self
},
onToggle: self => {
scrollTriggerRef.value = self;
}
},
smoothChildTiming: true,
onComplete: () => {
animationCompletedRef.value = true
animationCompletedRef.value = true;
gsap.set(targets, {
...props.to,
clearProps: 'willChange',
immediateRender: true,
})
props.onLetterAnimationComplete?.()
emit('animation-complete')
},
})
immediateRender: true
});
props.onLetterAnimationComplete?.();
emit('animation-complete');
}
});
timelineRef.value = tl
timelineRef.value = tl;
tl.set(targets, { ...props.from, immediateRender: false, force3D: true })
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,
})
}
force3D: true
});
};
const cleanup = () => {
if (timelineRef.value) {
timelineRef.value.kill()
timelineRef.value = null
timelineRef.value.kill();
timelineRef.value = null;
}
if (scrollTriggerRef.value) {
scrollTriggerRef.value.kill()
scrollTriggerRef.value = null
scrollTriggerRef.value.kill();
scrollTriggerRef.value = null;
}
if (splitterRef.value) {
gsap.killTweensOf(textRef.value)
splitterRef.value.revert()
splitterRef.value = null
gsap.killTweensOf(textRef.value);
splitterRef.value.revert();
splitterRef.value = null;
}
}
};
onMounted(() => {
initializeAnimation()
})
initializeAnimation();
});
onUnmounted(() => {
cleanup()
})
cleanup();
});
watch(
[
@@ -184,12 +184,11 @@ watch(
() => props.to,
() => props.threshold,
() => props.rootMargin,
() => props.onLetterAnimationComplete,
() => props.onLetterAnimationComplete
],
() => {
cleanup()
initializeAnimation()
cleanup();
initializeAnimation();
}
)
);
</script>

View File

@@ -1,26 +1,26 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { Motion } from 'motion-v'
import { ref, onMounted, onUnmounted } from 'vue';
import { Motion } from 'motion-v';
interface TextCursorProps {
text?: string
delay?: number
spacing?: number
followMouseDirection?: boolean
randomFloat?: boolean
exitDuration?: number
removalInterval?: number
maxPoints?: number
text?: string;
delay?: number;
spacing?: number;
followMouseDirection?: boolean;
randomFloat?: boolean;
exitDuration?: number;
removalInterval?: number;
maxPoints?: number;
}
interface TrailItem {
id: number
x: number
y: number
angle: number
randomX?: number
randomY?: number
randomRotate?: number
id: number;
x: number;
y: number;
angle: number;
randomX?: number;
randomY?: number;
randomRotate?: number;
}
const props = withDefaults(defineProps<TextCursorProps>(), {
@@ -32,24 +32,24 @@ const props = withDefaults(defineProps<TextCursorProps>(), {
exitDuration: 0.5,
removalInterval: 30,
maxPoints: 5
})
});
const containerRef = ref<HTMLDivElement>()
const trail = ref<TrailItem[]>([])
const lastMoveTime = ref(Date.now())
const idCounter = ref(0)
const containerRef = ref<HTMLDivElement>();
const trail = ref<TrailItem[]>([]);
const lastMoveTime = ref(Date.now());
const idCounter = ref(0);
let removalIntervalId: number | null = null
let removalIntervalId: number | null = null;
const handleMouseMove = (e: MouseEvent) => {
if (!containerRef.value) return
const rect = containerRef.value.getBoundingClientRect()
const mouseX = e.clientX - rect.left
const mouseY = e.clientY - rect.top
if (!containerRef.value) return;
const rect = containerRef.value.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
let newTrail = [...trail.value];
let newTrail = [...trail.value]
if (newTrail.length === 0) {
newTrail.push({
id: idCounter.value++,
@@ -61,24 +61,24 @@ const handleMouseMove = (e: MouseEvent) => {
randomY: Math.random() * 10 - 5,
randomRotate: Math.random() * 10 - 5
})
})
});
} else {
const last = newTrail[newTrail.length - 1]
const dx = mouseX - last.x
const dy = mouseY - last.y
const distance = Math.sqrt(dx * dx + dy * dy)
const last = newTrail[newTrail.length - 1];
const dx = mouseX - last.x;
const dy = mouseY - last.y;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance >= props.spacing) {
let rawAngle = (Math.atan2(dy, dx) * 180) / Math.PI
if (rawAngle > 90) rawAngle -= 180
else if (rawAngle < -90) rawAngle += 180
const computedAngle = props.followMouseDirection ? rawAngle : 0
const steps = Math.floor(distance / props.spacing)
let rawAngle = (Math.atan2(dy, dx) * 180) / Math.PI;
if (rawAngle > 90) rawAngle -= 180;
else if (rawAngle < -90) rawAngle += 180;
const computedAngle = props.followMouseDirection ? rawAngle : 0;
const steps = Math.floor(distance / props.spacing);
for (let i = 1; i <= steps; i++) {
const t = (props.spacing * i) / distance
const newX = last.x + dx * t
const newY = last.y + dy * t
const t = (props.spacing * i) / distance;
const newX = last.x + dx * t;
const newY = last.y + dy * t;
newTrail.push({
id: idCounter.value++,
x: newX,
@@ -89,48 +89,48 @@ const handleMouseMove = (e: MouseEvent) => {
randomY: Math.random() * 10 - 5,
randomRotate: Math.random() * 10 - 5
})
})
});
}
}
}
if (newTrail.length > props.maxPoints) {
newTrail = newTrail.slice(newTrail.length - props.maxPoints)
newTrail = newTrail.slice(newTrail.length - props.maxPoints);
}
trail.value = newTrail
lastMoveTime.value = Date.now()
}
trail.value = newTrail;
lastMoveTime.value = Date.now();
};
const startRemovalInterval = () => {
if (removalIntervalId) {
clearInterval(removalIntervalId)
clearInterval(removalIntervalId);
}
removalIntervalId = setInterval(() => {
if (Date.now() - lastMoveTime.value > 100) {
if (trail.value.length > 0) {
trail.value = trail.value.slice(1)
trail.value = trail.value.slice(1);
}
}
}, props.removalInterval)
}
}, props.removalInterval);
};
onMounted(() => {
if (containerRef.value) {
containerRef.value.addEventListener('mousemove', handleMouseMove)
startRemovalInterval()
containerRef.value.addEventListener('mousemove', handleMouseMove);
startRemovalInterval();
}
})
});
onUnmounted(() => {
if (containerRef.value) {
containerRef.value.removeEventListener('mousemove', handleMouseMove)
containerRef.value.removeEventListener('mousemove', handleMouseMove);
}
if (removalIntervalId) {
clearInterval(removalIntervalId)
clearInterval(removalIntervalId);
}
})
});
</script>
<template>
@@ -140,16 +140,14 @@ onUnmounted(() => {
v-for="item in trail"
:key="item.id"
:initial="{ opacity: 0, scale: 1, rotate: item.angle }"
:animate="{
opacity: 1,
:animate="{
opacity: 1,
scale: 1,
x: props.randomFloat ? [0, item.randomX || 0, 0] : 0,
y: props.randomFloat ? [0, item.randomY || 0, 0] : 0,
rotate: props.randomFloat
? [item.angle, item.angle + (item.randomRotate || 0), item.angle]
: item.angle
rotate: props.randomFloat ? [item.angle, item.angle + (item.randomRotate || 0), item.angle] : item.angle
}"
:transition="{
:transition="{
duration: props.randomFloat ? 2 : props.exitDuration,
repeat: props.randomFloat ? Infinity : 0,
repeatType: props.randomFloat ? 'mirror' : 'loop'

View File

@@ -1,22 +1,22 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, nextTick, computed, watch } from 'vue'
import { ref, onMounted, onUnmounted, nextTick, computed, watch } from 'vue';
interface TextPressureProps {
text?: string
fontFamily?: string
fontUrl?: string
width?: boolean
weight?: boolean
italic?: boolean
alpha?: boolean
flex?: boolean
stroke?: boolean
scale?: boolean
textColor?: string
strokeColor?: string
strokeWidth?: number
className?: string
minFontSize?: number
text?: string;
fontFamily?: string;
fontUrl?: string;
width?: boolean;
weight?: boolean;
italic?: boolean;
alpha?: boolean;
flex?: boolean;
stroke?: boolean;
scale?: boolean;
textColor?: string;
strokeColor?: string;
strokeWidth?: number;
className?: string;
minFontSize?: number;
}
const props = withDefaults(defineProps<TextPressureProps>(), {
@@ -35,141 +35,141 @@ const props = withDefaults(defineProps<TextPressureProps>(), {
strokeWidth: 2,
className: '',
minFontSize: 24
})
});
const containerRef = ref<HTMLDivElement | null>(null)
const titleRef = ref<HTMLHeadingElement | null>(null)
const spansRef = ref<(HTMLSpanElement | null)[]>([])
const containerRef = ref<HTMLDivElement | null>(null);
const titleRef = ref<HTMLHeadingElement | null>(null);
const spansRef = ref<(HTMLSpanElement | null)[]>([]);
const mouseRef = ref({ x: 0, y: 0 })
const cursorRef = ref({ x: 0, y: 0 })
const mouseRef = ref({ x: 0, y: 0 });
const cursorRef = ref({ x: 0, y: 0 });
const fontSize = ref(props.minFontSize)
const scaleY = ref(1)
const lineHeight = ref(1)
const fontSize = ref(props.minFontSize);
const scaleY = ref(1);
const lineHeight = ref(1);
const chars = computed(() => props.text.split(''))
const chars = computed(() => props.text.split(''));
const dist = (a: { x: number; y: number }, b: { x: number; y: number }) => {
const dx = b.x - a.x
const dy = b.y - a.y
return Math.sqrt(dx * dx + dy * dy)
}
const dx = b.x - a.x;
const dy = b.y - a.y;
return Math.sqrt(dx * dx + dy * dy);
};
const handleMouseMove = (e: MouseEvent) => {
cursorRef.value.x = e.clientX
cursorRef.value.y = e.clientY
}
cursorRef.value.x = e.clientX;
cursorRef.value.y = e.clientY;
};
const handleTouchMove = (e: TouchEvent) => {
const t = e.touches[0]
cursorRef.value.x = t.clientX
cursorRef.value.y = t.clientY
}
const t = e.touches[0];
cursorRef.value.x = t.clientX;
cursorRef.value.y = t.clientY;
};
const setSize = () => {
if (!containerRef.value || !titleRef.value) return
if (!containerRef.value || !titleRef.value) return;
const { width: containerW, height: containerH } = containerRef.value.getBoundingClientRect()
const { width: containerW, height: containerH } = containerRef.value.getBoundingClientRect();
let newFontSize = containerW / (chars.value.length / 2)
newFontSize = Math.max(newFontSize, props.minFontSize)
let newFontSize = containerW / (chars.value.length / 2);
newFontSize = Math.max(newFontSize, props.minFontSize);
fontSize.value = newFontSize
scaleY.value = 1
lineHeight.value = 1
fontSize.value = newFontSize;
scaleY.value = 1;
lineHeight.value = 1;
nextTick(() => {
if (!titleRef.value) return
const textRect = titleRef.value.getBoundingClientRect()
if (!titleRef.value) return;
const textRect = titleRef.value.getBoundingClientRect();
if (props.scale && textRect.height > 0) {
const yRatio = containerH / textRect.height
scaleY.value = yRatio
lineHeight.value = yRatio
const yRatio = containerH / textRect.height;
scaleY.value = yRatio;
lineHeight.value = yRatio;
}
})
}
});
};
let rafId: number
let rafId: number;
const animate = () => {
mouseRef.value.x += (cursorRef.value.x - mouseRef.value.x) / 15
mouseRef.value.y += (cursorRef.value.y - mouseRef.value.y) / 15
mouseRef.value.x += (cursorRef.value.x - mouseRef.value.x) / 15;
mouseRef.value.y += (cursorRef.value.y - mouseRef.value.y) / 15;
if (titleRef.value) {
const titleRect = titleRef.value.getBoundingClientRect()
const maxDist = titleRect.width / 2
const titleRect = titleRef.value.getBoundingClientRect();
const maxDist = titleRect.width / 2;
spansRef.value.forEach((span) => {
if (!span) return
spansRef.value.forEach(span => {
if (!span) return;
const rect = span.getBoundingClientRect()
const rect = span.getBoundingClientRect();
const charCenter = {
x: rect.x + rect.width / 2,
y: rect.y + rect.height / 2,
}
y: rect.y + rect.height / 2
};
const d = dist(mouseRef.value, charCenter)
const d = dist(mouseRef.value, charCenter);
const getAttr = (distance: number, minVal: number, maxVal: number) => {
const val = maxVal - Math.abs((maxVal * distance) / maxDist)
return Math.max(minVal, val + minVal)
}
const val = maxVal - Math.abs((maxVal * distance) / maxDist);
return Math.max(minVal, val + minVal);
};
const wdth = props.width ? Math.floor(getAttr(d, 5, 200)) : 100
const wght = props.weight ? Math.floor(getAttr(d, 100, 900)) : 400
const italVal = props.italic ? getAttr(d, 0, 1).toFixed(2) : '0'
const alphaVal = props.alpha ? getAttr(d, 0, 1).toFixed(2) : '1'
const wdth = props.width ? Math.floor(getAttr(d, 5, 200)) : 100;
const wght = props.weight ? Math.floor(getAttr(d, 100, 900)) : 400;
const italVal = props.italic ? getAttr(d, 0, 1).toFixed(2) : '0';
const alphaVal = props.alpha ? getAttr(d, 0, 1).toFixed(2) : '1';
span.style.opacity = alphaVal
span.style.fontVariationSettings = `'wght' ${wght}, 'wdth' ${wdth}, 'ital' ${italVal}`
})
span.style.opacity = alphaVal;
span.style.fontVariationSettings = `'wght' ${wght}, 'wdth' ${wdth}, 'ital' ${italVal}`;
});
}
rafId = requestAnimationFrame(animate)
}
rafId = requestAnimationFrame(animate);
};
onMounted(() => {
const styleElement = document.createElement('style')
styleElement.textContent = dynamicStyles.value
document.head.appendChild(styleElement)
styleElement.setAttribute('data-text-pressure', 'true')
const styleElement = document.createElement('style');
styleElement.textContent = dynamicStyles.value;
document.head.appendChild(styleElement);
styleElement.setAttribute('data-text-pressure', 'true');
setSize()
setSize();
window.addEventListener('mousemove', handleMouseMove)
window.addEventListener('touchmove', handleTouchMove, { passive: false })
window.addEventListener('resize', setSize)
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('touchmove', handleTouchMove, { passive: false });
window.addEventListener('resize', setSize);
if (containerRef.value) {
const { left, top, width, height } = containerRef.value.getBoundingClientRect()
mouseRef.value.x = left + width / 2
mouseRef.value.y = top + height / 2
cursorRef.value.x = mouseRef.value.x
cursorRef.value.y = mouseRef.value.y
const { left, top, width, height } = containerRef.value.getBoundingClientRect();
mouseRef.value.x = left + width / 2;
mouseRef.value.y = top + height / 2;
cursorRef.value.x = mouseRef.value.x;
cursorRef.value.y = mouseRef.value.y;
}
animate()
})
animate();
});
onUnmounted(() => {
const styleElements = document.querySelectorAll('style[data-text-pressure="true"]')
styleElements.forEach(el => el.remove())
const styleElements = document.querySelectorAll('style[data-text-pressure="true"]');
styleElements.forEach(el => el.remove());
window.removeEventListener('mousemove', handleMouseMove)
window.removeEventListener('touchmove', handleTouchMove)
window.removeEventListener('resize', setSize)
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('touchmove', handleTouchMove);
window.removeEventListener('resize', setSize);
if (rafId) {
cancelAnimationFrame(rafId)
cancelAnimationFrame(rafId);
}
})
});
watch([() => props.scale, () => props.text], () => {
setSize()
})
setSize();
});
watch([() => props.width, () => props.weight, () => props.italic, () => props.alpha], () => {})
watch([() => props.width, () => props.weight, () => props.italic, () => props.alpha], () => {});
const titleStyle = computed(() => ({
fontFamily: props.fontFamily,
@@ -179,10 +179,11 @@ const titleStyle = computed(() => ({
transformOrigin: 'center top',
margin: 0,
fontWeight: 100,
color: props.stroke ? undefined : props.textColor,
}))
color: props.stroke ? undefined : props.textColor
}));
const dynamicStyles = computed(() => `
const dynamicStyles = computed(
() => `
@font-face {
font-family: '${props.fontFamily}';
src: url('${props.fontUrl}');
@@ -202,29 +203,37 @@ const dynamicStyles = computed(() => `
-webkit-text-stroke-width: ${props.strokeWidth}px;
-webkit-text-stroke-color: ${props.strokeColor};
}
`)
`
);
onMounted(() => {
const styleElement = document.createElement('style')
styleElement.textContent = dynamicStyles.value
document.head.appendChild(styleElement)
const styleElement = document.createElement('style');
styleElement.textContent = dynamicStyles.value;
document.head.appendChild(styleElement);
styleElement.setAttribute('data-text-pressure', 'true')
})
styleElement.setAttribute('data-text-pressure', 'true');
});
onUnmounted(() => {
const styleElements = document.querySelectorAll('style[data-text-pressure="true"]')
styleElements.forEach(el => el.remove())
})
const styleElements = document.querySelectorAll('style[data-text-pressure="true"]');
styleElements.forEach(el => el.remove());
});
</script>
<template>
<div ref="containerRef" class="relative w-full h-full overflow-hidden bg-transparent">
<h1 ref="titleRef"
<h1
ref="titleRef"
:class="`text-pressure-title ${className} ${flex ? 'flex justify-between' : ''} ${stroke ? 'stroke' : ''} uppercase text-center`"
:style="titleStyle">
<span v-for="(char, i) in chars" :key="i" :ref="(el) => spansRef[i] = el as HTMLSpanElement" :data-char="char"
class="inline-block">
:style="titleStyle"
>
<span
v-for="(char, i) in chars"
:key="i"
:ref="el => (spansRef[i] = el as HTMLSpanElement)"
:data-char="char"
class="inline-block"
>
{{ char }}
</span>
</h1>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from 'vue'
import { ref, onMounted, onUnmounted, watch } from 'vue';
import {
CanvasTexture,
Clock,
@@ -15,22 +15,22 @@ import {
Vector3,
WebGLRenderer,
WebGLRenderTarget
} from 'three'
} from 'three';
interface TextTrailProps {
text?: string
fontFamily?: string
fontWeight?: string | number
noiseFactor?: number
noiseScale?: number
rgbPersistFactor?: number
alphaPersistFactor?: number
animateColor?: boolean
startColor?: string
textColor?: string
backgroundColor?: number | string
colorCycleInterval?: number
supersample?: number
text?: string;
fontFamily?: string;
fontWeight?: string | number;
noiseFactor?: number;
noiseScale?: number;
rgbPersistFactor?: number;
alphaPersistFactor?: number;
animateColor?: boolean;
startColor?: string;
textColor?: string;
backgroundColor?: number | string;
colorCycleInterval?: number;
supersample?: number;
}
const props = withDefaults(defineProps<TextTrailProps>(), {
@@ -47,31 +47,31 @@ const props = withDefaults(defineProps<TextTrailProps>(), {
backgroundColor: 0x151515,
colorCycleInterval: 3000,
supersample: 2
})
});
const containerRef = ref<HTMLDivElement>()
const containerRef = ref<HTMLDivElement>();
const hexToRgb = (hex: string): [number, number, number] => {
let h = hex.replace('#', '')
let h = hex.replace('#', '');
if (h.length === 3)
h = h
.split('')
.map((c) => c + c)
.join('')
const n = parseInt(h, 16)
return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
}
.map(c => c + c)
.join('');
const n = parseInt(h, 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
};
const loadFont = async (fam: string) => {
if ('fonts' in document) {
const fonts = (document as Document & { fonts: { load: (font: string) => Promise<void> } }).fonts
await fonts.load(`64px "${fam}"`)
const fonts = (document as Document & { fonts: { load: (font: string) => Promise<void> } }).fonts;
await fonts.load(`64px "${fam}"`);
}
}
};
const BASE_VERT = `
varying vec2 v_uv;
void main(){gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0);v_uv=uv;}`
void main(){gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0);v_uv=uv;}`;
const SIMPLEX = `
vec3 mod289(vec3 x){return x-floor(x*(1./289.))*289.;}
@@ -114,7 +114,7 @@ float snoise3(vec3 v){
vec4 m=max(.6-vec4(dot(x0,x0),dot(x1,x1),dot(x2,x2),dot(x3,x3)),0.);
m*=m;
return 42.*dot(m*m,vec4(dot(p0,x0),dot(p1,x1),dot(p2,x2),dot(p3,x3)));
}`
}`;
const PERSIST_FRAG = `
uniform sampler2D sampler;
@@ -128,7 +128,7 @@ void main(){
float b=snoise3(vec3(v_uv*noiseFactor,time*.1+100.))*noiseScale;
vec4 t=texture2D(sampler,v_uv+vec2(a,b)+mousePos*.005);
gl_FragColor=vec4(t.xyz*rgbPersistFactor,alphaPersistFactor);
}`
}`;
const TEXT_FRAG = `
uniform sampler2D sampler;uniform vec3 color;varying vec2 v_uv;
@@ -137,77 +137,77 @@ void main(){
float alpha=smoothstep(0.1,0.9,t.a);
if(alpha<0.01)discard;
gl_FragColor=vec4(color,alpha);
}`
}`;
let renderer: WebGLRenderer | null = null
let scene: Scene | null = null
let fluidScene: Scene | null = null
let clock: Clock | null = null
let cam: OrthographicCamera | null = null
let rt0: WebGLRenderTarget | null = null
let rt1: WebGLRenderTarget | null = null
let quadMat: ShaderMaterial | null = null
let quad: Mesh | null = null
let labelMat: ShaderMaterial | null = null
let label: Mesh | null = null
let resizeObserver: ResizeObserver | null = null
let colorTimer: number | null = null
let renderer: WebGLRenderer | null = null;
let scene: Scene | null = null;
let fluidScene: Scene | null = null;
let clock: Clock | null = null;
let cam: OrthographicCamera | null = null;
let rt0: WebGLRenderTarget | null = null;
let rt1: WebGLRenderTarget | null = null;
let quadMat: ShaderMaterial | null = null;
let quad: Mesh | null = null;
let labelMat: ShaderMaterial | null = null;
let label: Mesh | null = null;
let resizeObserver: ResizeObserver | null = null;
let colorTimer: number | null = null;
const persistColor = ref<[number, number, number]>(
hexToRgb(props.textColor || props.startColor).map((c) => c / 255) as [number, number, number]
)
const targetColor = ref<[number, number, number]>([...persistColor.value])
hexToRgb(props.textColor || props.startColor).map(c => c / 255) as [number, number, number]
);
const targetColor = ref<[number, number, number]>([...persistColor.value]);
const mouse = [0, 0]
const target = [0, 0]
const mouse = [0, 0];
const target = [0, 0];
const getSize = () => ({
w: containerRef.value!.clientWidth,
h: containerRef.value!.clientHeight
})
});
const onMove = (e: PointerEvent) => {
if (!containerRef.value) return
const r = containerRef.value.getBoundingClientRect()
target[0] = ((e.clientX - r.left) / r.width) * 2 - 1
target[1] = ((r.top + r.height - e.clientY) / r.height) * 2 - 1
}
if (!containerRef.value) return;
const r = containerRef.value.getBoundingClientRect();
target[0] = ((e.clientX - r.left) / r.width) * 2 - 1;
target[1] = ((r.top + r.height - e.clientY) / r.height) * 2 - 1;
};
const drawText = () => {
if (!renderer || !labelMat) return
const texCanvas = document.createElement('canvas')
if (!renderer || !labelMat) return;
const texCanvas = document.createElement('canvas');
const ctx = texCanvas.getContext('2d', {
alpha: true,
colorSpace: 'srgb'
})!
const max = Math.min(renderer.capabilities.maxTextureSize, 4096)
const pixelRatio = (window.devicePixelRatio || 1) * props.supersample
const canvasSize = max * pixelRatio
texCanvas.width = canvasSize
texCanvas.height = canvasSize
texCanvas.style.width = `${max}px`
texCanvas.style.height = `${max}px`
})!;
ctx.setTransform(1, 0, 0, 1, 0, 0)
ctx.scale(pixelRatio, pixelRatio)
ctx.clearRect(0, 0, max, max)
ctx.imageSmoothingEnabled = true
ctx.imageSmoothingQuality = 'high'
ctx.shadowColor = 'rgba(255,255,255,0.3)'
ctx.shadowBlur = 2
ctx.fillStyle = '#fff'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
const max = Math.min(renderer.capabilities.maxTextureSize, 4096);
const pixelRatio = (window.devicePixelRatio || 1) * props.supersample;
const canvasSize = max * pixelRatio;
texCanvas.width = canvasSize;
texCanvas.height = canvasSize;
texCanvas.style.width = `${max}px`;
texCanvas.style.height = `${max}px`;
const refSize = 250
ctx.font = `${props.fontWeight} ${refSize}px ${props.fontFamily}`
const width = ctx.measureText(props.text).width
ctx.font = `${props.fontWeight} ${(refSize * max) / width}px ${props.fontFamily}`
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.scale(pixelRatio, pixelRatio);
ctx.clearRect(0, 0, max, max);
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.shadowColor = 'rgba(255,255,255,0.3)';
ctx.shadowBlur = 2;
ctx.fillStyle = '#fff';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
const cx = max / 2
const cy = max / 2
const refSize = 250;
ctx.font = `${props.fontWeight} ${refSize}px ${props.fontFamily}`;
const width = ctx.measureText(props.text).width;
ctx.font = `${props.fontWeight} ${(refSize * max) / width}px ${props.fontFamily}`;
const cx = max / 2;
const cy = max / 2;
const offs = [
[0, 0],
[0.1, 0],
@@ -218,42 +218,40 @@ const drawText = () => {
[-0.1, -0.1],
[0.1, -0.1],
[-0.1, 0.1]
]
ctx.globalAlpha = 1 / offs.length
offs.forEach(([dx, dy]) => ctx.fillText(props.text, cx + dx, cy + dy))
ctx.globalAlpha = 1
];
ctx.globalAlpha = 1 / offs.length;
offs.forEach(([dx, dy]) => ctx.fillText(props.text, cx + dx, cy + dy));
ctx.globalAlpha = 1;
const tex = new CanvasTexture(texCanvas)
tex.generateMipmaps = true
tex.minFilter = LinearMipmapLinearFilter
tex.magFilter = LinearFilter
labelMat.uniforms.sampler.value = tex
}
const tex = new CanvasTexture(texCanvas);
tex.generateMipmaps = true;
tex.minFilter = LinearMipmapLinearFilter;
tex.magFilter = LinearFilter;
labelMat.uniforms.sampler.value = tex;
};
const initThreeJS = async () => {
if (!containerRef.value) return
if (!containerRef.value) return;
let { w, h } = getSize()
let { w, h } = getSize();
renderer = new WebGLRenderer({ antialias: true })
renderer = new WebGLRenderer({ antialias: true });
renderer.setClearColor(
typeof props.backgroundColor === 'string'
? new Color(props.backgroundColor)
: new Color(props.backgroundColor),
typeof props.backgroundColor === 'string' ? new Color(props.backgroundColor) : new Color(props.backgroundColor),
1
)
renderer.setPixelRatio(window.devicePixelRatio || 1)
renderer.setSize(w, h)
containerRef.value.appendChild(renderer.domElement)
);
renderer.setPixelRatio(window.devicePixelRatio || 1);
renderer.setSize(w, h);
containerRef.value.appendChild(renderer.domElement);
scene = new Scene()
fluidScene = new Scene()
clock = new Clock()
cam = new OrthographicCamera(-w / 2, w / 2, h / 2, -h / 2, 0.1, 10)
cam.position.z = 1
scene = new Scene();
fluidScene = new Scene();
clock = new Clock();
cam = new OrthographicCamera(-w / 2, w / 2, h / 2, -h / 2, 0.1, 10);
cam.position.z = 1;
rt0 = new WebGLRenderTarget(w, h)
rt1 = rt0.clone()
rt0 = new WebGLRenderTarget(w, h);
rt1 = rt0.clone();
quadMat = new ShaderMaterial({
uniforms: {
@@ -268,9 +266,9 @@ const initThreeJS = async () => {
vertexShader: BASE_VERT,
fragmentShader: PERSIST_FRAG,
transparent: true
})
quad = new Mesh(new PlaneGeometry(w, h), quadMat)
fluidScene.add(quad)
});
quad = new Mesh(new PlaneGeometry(w, h), quadMat);
fluidScene.add(quad);
labelMat = new ShaderMaterial({
uniforms: {
@@ -280,133 +278,132 @@ const initThreeJS = async () => {
vertexShader: BASE_VERT,
fragmentShader: TEXT_FRAG,
transparent: true
})
label = new Mesh(new PlaneGeometry(Math.min(w, h), Math.min(w, h)), labelMat)
scene.add(label)
});
label = new Mesh(new PlaneGeometry(Math.min(w, h), Math.min(w, h)), labelMat);
scene.add(label);
await loadFont(props.fontFamily)
drawText()
await loadFont(props.fontFamily);
drawText();
containerRef.value.addEventListener('pointermove', onMove)
containerRef.value.addEventListener('pointermove', onMove);
resizeObserver = new ResizeObserver(() => {
if (!containerRef.value || !renderer || !cam || !quad || !rt0 || !rt1 || !label) return
const size = getSize()
w = size.w
h = size.h
renderer.setSize(w, h)
cam.left = -w / 2
cam.right = w / 2
cam.top = h / 2
cam.bottom = -h / 2
cam.updateProjectionMatrix()
quad.geometry.dispose()
quad.geometry = new PlaneGeometry(w, h)
rt0.setSize(w, h)
rt1.setSize(w, h)
label.geometry.dispose()
label.geometry = new PlaneGeometry(Math.min(w, h), Math.min(w, h))
})
resizeObserver.observe(containerRef.value)
if (!containerRef.value || !renderer || !cam || !quad || !rt0 || !rt1 || !label) return;
const size = getSize();
w = size.w;
h = size.h;
renderer.setSize(w, h);
cam.left = -w / 2;
cam.right = w / 2;
cam.top = h / 2;
cam.bottom = -h / 2;
cam.updateProjectionMatrix();
quad.geometry.dispose();
quad.geometry = new PlaneGeometry(w, h);
rt0.setSize(w, h);
rt1.setSize(w, h);
label.geometry.dispose();
label.geometry = new PlaneGeometry(Math.min(w, h), Math.min(w, h));
});
resizeObserver.observe(containerRef.value);
colorTimer = setInterval(() => {
if (!props.textColor) {
targetColor.value = [Math.random(), Math.random(), Math.random()]
targetColor.value = [Math.random(), Math.random(), Math.random()];
}
}, props.colorCycleInterval)
}, props.colorCycleInterval);
const animate = () => {
if (!renderer || !quadMat || !labelMat || !clock || !scene || !fluidScene || !cam || !rt0 || !rt1) return
if (!renderer || !quadMat || !labelMat || !clock || !scene || !fluidScene || !cam || !rt0 || !rt1) return;
const dt = clock.getDelta()
const dt = clock.getDelta();
if (props.animateColor && !props.textColor) {
for (let i = 0; i < 3; i++)
persistColor.value[i] += (targetColor.value[i] - persistColor.value[i]) * dt
for (let i = 0; i < 3; i++) persistColor.value[i] += (targetColor.value[i] - persistColor.value[i]) * dt;
}
const speed = dt * 5
mouse[0] += (target[0] - mouse[0]) * speed
mouse[1] += (target[1] - mouse[1]) * speed
const speed = dt * 5;
mouse[0] += (target[0] - mouse[0]) * speed;
mouse[1] += (target[1] - mouse[1]) * speed;
quadMat.uniforms.mousePos.value.set(mouse[0], mouse[1])
quadMat.uniforms.sampler.value = rt1.texture
quadMat.uniforms.time.value = clock.getElapsedTime()
labelMat.uniforms.color.value.set(...persistColor.value)
quadMat.uniforms.mousePos.value.set(mouse[0], mouse[1]);
quadMat.uniforms.sampler.value = rt1.texture;
quadMat.uniforms.time.value = clock.getElapsedTime();
labelMat.uniforms.color.value.set(...persistColor.value);
renderer.autoClearColor = false
renderer.setRenderTarget(rt0)
renderer.clearColor()
renderer.render(fluidScene, cam)
renderer.render(scene, cam)
renderer.setRenderTarget(null)
renderer.render(fluidScene, cam)
renderer.render(scene, cam)
;[rt0, rt1] = [rt1, rt0]
}
renderer.autoClearColor = false;
renderer.setRenderTarget(rt0);
renderer.clearColor();
renderer.render(fluidScene, cam);
renderer.render(scene, cam);
renderer.setRenderTarget(null);
renderer.render(fluidScene, cam);
renderer.render(scene, cam);
[rt0, rt1] = [rt1, rt0];
};
renderer.setAnimationLoop(animate)
}
renderer.setAnimationLoop(animate);
};
const cleanup = () => {
if (renderer) {
renderer.setAnimationLoop(null)
renderer.setAnimationLoop(null);
if (containerRef.value && renderer.domElement.parentNode === containerRef.value) {
containerRef.value.removeChild(renderer.domElement)
containerRef.value.removeChild(renderer.domElement);
}
renderer.dispose()
renderer = null
renderer.dispose();
renderer = null;
}
if (colorTimer) {
clearInterval(colorTimer)
colorTimer = null
clearInterval(colorTimer);
colorTimer = null;
}
if (containerRef.value) {
containerRef.value.removeEventListener('pointermove', onMove)
containerRef.value.removeEventListener('pointermove', onMove);
}
if (resizeObserver) {
resizeObserver.disconnect()
resizeObserver = null
resizeObserver.disconnect();
resizeObserver = null;
}
if (rt0) {
rt0.dispose()
rt0 = null
rt0.dispose();
rt0 = null;
}
if (rt1) {
rt1.dispose()
rt1 = null
rt1.dispose();
rt1 = null;
}
if (quadMat) {
quadMat.dispose()
quadMat = null
quadMat.dispose();
quadMat = null;
}
if (quad) {
quad.geometry.dispose()
quad = null
quad.geometry.dispose();
quad = null;
}
if (labelMat) {
labelMat.dispose()
labelMat = null
labelMat.dispose();
labelMat = null;
}
if (label) {
label.geometry.dispose()
label = null
label.geometry.dispose();
label = null;
}
scene = null
fluidScene = null
clock = null
cam = null
}
scene = null;
fluidScene = null;
clock = null;
cam = null;
};
watch(
() => [
@@ -425,25 +422,25 @@ watch(
props.supersample
],
() => {
cleanup()
cleanup();
if (containerRef.value) {
persistColor.value = hexToRgb(props.textColor || props.startColor).map((c) => c / 255) as [number, number, number]
targetColor.value = [...persistColor.value]
initThreeJS()
persistColor.value = hexToRgb(props.textColor || props.startColor).map(c => c / 255) as [number, number, number];
targetColor.value = [...persistColor.value];
initThreeJS();
}
},
{ deep: true }
)
);
onMounted(() => {
if (containerRef.value) {
initThreeJS()
initThreeJS();
}
})
});
onUnmounted(() => {
cleanup()
})
cleanup();
});
</script>
<template>

View File

@@ -0,0 +1,170 @@
<script setup lang="ts">
import { motion } from 'motion-v';
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
interface TrueFocusProps {
sentence?: string;
manualMode?: boolean;
blurAmount?: number;
borderColor?: string;
glowColor?: string;
animationDuration?: number;
pauseBetweenAnimations?: number;
}
const props = withDefaults(defineProps<TrueFocusProps>(), {
sentence: 'True Focus',
manualMode: false,
blurAmount: 5,
borderColor: 'green',
glowColor: 'rgba(0, 255, 0, 0.6)',
animationDuration: 0.5,
pauseBetweenAnimations: 1
});
const words = computed(() => props.sentence.split(' '));
const currentIndex = ref(0);
const lastActiveIndex = ref<number | null>(null);
const containerRef = ref<HTMLDivElement>();
const wordRefs = ref<HTMLSpanElement[]>([]);
const focusRect = ref({ x: 0, y: 0, width: 0, height: 0 });
let interval: number | null = null;
watch(
[() => props.manualMode, () => props.animationDuration, () => props.pauseBetweenAnimations, words],
() => {
if (interval) {
clearInterval(interval);
interval = null;
}
if (!props.manualMode) {
interval = setInterval(
() => {
currentIndex.value = (currentIndex.value + 1) % words.value.length;
},
(props.animationDuration + props.pauseBetweenAnimations) * 1000
);
}
},
{ immediate: true }
);
watch(
[currentIndex, words.value.length],
async () => {
if (currentIndex.value === null || currentIndex.value === -1) return;
if (!wordRefs.value[currentIndex.value] || !containerRef.value) return;
await nextTick();
const parentRect = containerRef.value.getBoundingClientRect();
const activeRect = wordRefs.value[currentIndex.value].getBoundingClientRect();
focusRect.value = {
x: activeRect.left - parentRect.left,
y: activeRect.top - parentRect.top,
width: activeRect.width,
height: activeRect.height
};
},
{ immediate: true }
);
const handleMouseEnter = (index: number) => {
if (props.manualMode) {
lastActiveIndex.value = index;
currentIndex.value = index;
}
};
const handleMouseLeave = () => {
if (props.manualMode) {
currentIndex.value = lastActiveIndex.value || 0;
}
};
const setWordRef = (el: HTMLSpanElement | null, index: number) => {
if (el) {
wordRefs.value[index] = el;
}
};
onMounted(async () => {
await nextTick();
if (wordRefs.value[0] && containerRef.value) {
const parentRect = containerRef.value.getBoundingClientRect();
const activeRect = wordRefs.value[0].getBoundingClientRect();
focusRect.value = {
x: activeRect.left - parentRect.left,
y: activeRect.top - parentRect.top,
width: activeRect.width,
height: activeRect.height
};
}
});
onUnmounted(() => {
if (interval) {
clearInterval(interval);
}
});
</script>
<template>
<div class="relative flex flex-wrap justify-center items-center gap-[1em]" ref="containerRef">
<span
v-for="(word, index) in words"
:key="index"
:ref="el => setWordRef(el as HTMLSpanElement, index)"
class="relative font-black text-5xl transition-[filter,color] duration-300 ease-in-out cursor-pointer"
:style="{
filter: index === currentIndex ? 'blur(0px)' : `blur(${blurAmount}px)`,
'--border-color': borderColor,
'--glow-color': glowColor,
transition: `filter ${animationDuration}s ease`
}"
@mouseenter="handleMouseEnter(index)"
@mouseleave="handleMouseLeave"
>
{{ word }}
</span>
<motion.div
class="top-0 left-0 box-content absolute border-none pointer-events-none"
:animate="{
x: focusRect.x,
y: focusRect.y,
width: focusRect.width,
height: focusRect.height,
opacity: currentIndex >= 0 ? 1 : 0
}"
:transition="{
duration: animationDuration
}"
:style="{
'--border-color': borderColor,
'--glow-color': glowColor
}"
>
<span
class="top-[-10px] left-[-10px] absolute [filter:drop-shadow(0_0_4px_var(--border-color,#fff))] border-[3px] border-[var(--border-color,#fff)] border-r-0 border-b-0 rounded-[3px] w-4 h-4 transition-none"
></span>
<span
class="top-[-10px] right-[-10px] absolute [filter:drop-shadow(0_0_4px_var(--border-color,#fff))] border-[3px] border-[var(--border-color,#fff)] border-b-0 border-l-0 rounded-[3px] w-4 h-4 transition-none"
></span>
<span
class="bottom-[-10px] left-[-10px] absolute [filter:drop-shadow(0_0_4px_var(--border-color,#fff))] border-[3px] border-[var(--border-color,#fff)] border-t-0 border-r-0 rounded-[3px] w-4 h-4 transition-none"
></span>
<span
class="right-[-10px] bottom-[-10px] absolute [filter:drop-shadow(0_0_4px_var(--border-color,#fff))] border-[3px] border-[var(--border-color,#fff)] border-t-0 border-l-0 rounded-[3px] w-4 h-4 transition-none"
></span>
</motion.div>
</div>
</template>