mirror of
https://github.com/DavidHDev/vue-bits.git
synced 2026-03-07 14:39:30 -07:00
Component Boom
This commit is contained in:
141
src/content/TextAnimations/BlurText/BlurText.vue
Normal file
141
src/content/TextAnimations/BlurText/BlurText.vue
Normal file
@@ -0,0 +1,141 @@
|
||||
<script setup lang="ts">
|
||||
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
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<BlurTextProps>(), {
|
||||
text: '',
|
||||
delay: 200,
|
||||
className: '',
|
||||
animateBy: 'words',
|
||||
direction: 'top',
|
||||
threshold: 0.1,
|
||||
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 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 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 }
|
||||
)
|
||||
|
||||
const defaultTo = computed(() => [
|
||||
{
|
||||
filter: 'blur(5px)',
|
||||
opacity: 0.5,
|
||||
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 stepCount = computed(() => toSnapshots.value.length + 1)
|
||||
const totalDuration = computed(() => props.stepDuration * (stepCount.value - 1))
|
||||
const times = computed(() =>
|
||||
Array.from({ length: stepCount.value }, (_, i) =>
|
||||
stepCount.value === 1 ? 0 : i / (stepCount.value - 1)
|
||||
)
|
||||
)
|
||||
|
||||
const setupObserver = () => {
|
||||
if (!rootRef.value) return
|
||||
|
||||
observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
inView.value = true
|
||||
observer?.unobserve(rootRef.value as Element)
|
||||
}
|
||||
},
|
||||
{ threshold: props.threshold, rootMargin: props.rootMargin }
|
||||
)
|
||||
|
||||
observer.observe(rootRef.value)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
setupObserver()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
observer?.disconnect()
|
||||
})
|
||||
|
||||
watch([() => props.threshold, () => props.rootMargin], () => {
|
||||
observer?.disconnect()
|
||||
setupObserver()
|
||||
})
|
||||
|
||||
const getAnimateKeyframes = () => {
|
||||
return buildKeyframes(fromSnapshot.value, toSnapshots.value)
|
||||
}
|
||||
|
||||
const getTransition = (index: number) => {
|
||||
return {
|
||||
duration: totalDuration.value,
|
||||
times: times.value,
|
||||
delay: (index * props.delay) / 1000,
|
||||
ease: props.easing
|
||||
}
|
||||
}
|
||||
|
||||
const handleAnimationComplete = (index: number) => {
|
||||
if (index === elements.value.length - 1 && 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="{
|
||||
display: 'inline-block',
|
||||
willChange: 'transform, filter, opacity'
|
||||
}" @animation-complete="handleAnimationComplete(index)">
|
||||
{{ segment === ' ' ? '\u00A0' : segment }}{{ animateBy === 'words' && index < elements.length - 1 ? '\u00A0' : ''
|
||||
}} </Motion>
|
||||
</p>
|
||||
</template>
|
||||
129
src/content/TextAnimations/CircularText/CircularText.vue
Normal file
129
src/content/TextAnimations/CircularText/CircularText.vue
Normal file
@@ -0,0 +1,129 @@
|
||||
<script setup lang="ts">
|
||||
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
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<CircularTextProps>(), {
|
||||
text: '',
|
||||
spinDuration: 20,
|
||||
onHover: 'speedUp',
|
||||
className: ''
|
||||
})
|
||||
|
||||
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 getCurrentSpeed = () => {
|
||||
if (isHovered.value && props.onHover === 'pause') return 0
|
||||
|
||||
const baseDuration = props.spinDuration
|
||||
const baseSpeed = 360 / baseDuration
|
||||
|
||||
if (!isHovered.value) return baseSpeed
|
||||
|
||||
switch (props.onHover) {
|
||||
case 'slowDown':
|
||||
return baseSpeed / 2
|
||||
case 'speedUp':
|
||||
return baseSpeed * 4
|
||||
case 'goBonkers':
|
||||
return baseSpeed * 20
|
||||
default:
|
||||
return baseSpeed
|
||||
}
|
||||
}
|
||||
|
||||
const getCurrentScale = () => {
|
||||
return (isHovered.value && props.onHover === 'goBonkers') ? 0.8 : 1
|
||||
}
|
||||
|
||||
const animate = () => {
|
||||
const now = Date.now()
|
||||
const deltaTime = (now - lastTime.value) / 1000
|
||||
lastTime.value = now
|
||||
|
||||
const targetSpeed = getCurrentSpeed()
|
||||
|
||||
const speedDiff = targetSpeed - rotationSpeed.value
|
||||
const smoothingFactor = Math.min(1, deltaTime * 5)
|
||||
rotationSpeed.value += speedDiff * smoothingFactor
|
||||
|
||||
currentRotation.value = (currentRotation.value + rotationSpeed.value * deltaTime) % 360
|
||||
|
||||
animationId.value = requestAnimationFrame(animate)
|
||||
}
|
||||
|
||||
const startAnimation = () => {
|
||||
if (animationId.value) {
|
||||
cancelAnimationFrame(animationId.value)
|
||||
}
|
||||
lastTime.value = Date.now()
|
||||
rotationSpeed.value = getCurrentSpeed()
|
||||
animate()
|
||||
}
|
||||
|
||||
watchEffect(() => {
|
||||
startAnimation()
|
||||
})
|
||||
|
||||
startAnimation()
|
||||
|
||||
onUnmounted(() => {
|
||||
if (animationId.value) {
|
||||
cancelAnimationFrame(animationId.value)
|
||||
}
|
||||
})
|
||||
|
||||
const handleHoverStart = () => {
|
||||
isHovered.value = true
|
||||
}
|
||||
|
||||
const handleHoverEnd = () => {
|
||||
isHovered.value = false
|
||||
}
|
||||
|
||||
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)`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Motion :animate="{
|
||||
rotate: currentRotation,
|
||||
scale: getCurrentScale()
|
||||
}" :transition="{
|
||||
rotate: {
|
||||
duration: 0
|
||||
},
|
||||
scale: {
|
||||
type: 'spring',
|
||||
damping: 20,
|
||||
stiffness: 300
|
||||
}
|
||||
}"
|
||||
: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="{
|
||||
transform: getLetterTransform(i),
|
||||
WebkitTransform: getLetterTransform(i)
|
||||
}">
|
||||
{{ letter }}
|
||||
</span>
|
||||
</Motion>
|
||||
</template>
|
||||
193
src/content/TextAnimations/CurvedLoop/CurvedLoop.vue
Normal file
193
src/content/TextAnimations/CurvedLoop/CurvedLoop.vue
Normal file
@@ -0,0 +1,193 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch, computed, nextTick } from 'vue'
|
||||
|
||||
interface CurvedLoopProps {
|
||||
marqueeText?: string
|
||||
speed?: number
|
||||
className?: string
|
||||
curveAmount?: number
|
||||
direction?: 'left' | 'right'
|
||||
interactive?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<CurvedLoopProps>(), {
|
||||
marqueeText: '',
|
||||
speed: 2,
|
||||
className: '',
|
||||
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 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 dragRef = ref(false)
|
||||
const lastXRef = ref(0)
|
||||
const dirRef = ref<'left' | 'right'>(props.direction)
|
||||
const velRef = ref(0)
|
||||
|
||||
let animationFrame: number | null = null
|
||||
|
||||
const updateSpacing = () => {
|
||||
if (measureRef.value) {
|
||||
spacing.value = measureRef.value.getComputedTextLength()
|
||||
}
|
||||
}
|
||||
|
||||
const updatePathLength = () => {
|
||||
if (pathRef.value) {
|
||||
pathLength.value = pathRef.value.getTotalLength()
|
||||
}
|
||||
}
|
||||
|
||||
const animate = () => {
|
||||
if (!spacing.value) return
|
||||
|
||||
const step = () => {
|
||||
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 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
|
||||
}
|
||||
}
|
||||
|
||||
const repeats = computed(() => {
|
||||
return pathLength.value && spacing.value ? Math.ceil(pathLength.value / spacing.value) + 2 : 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)
|
||||
}
|
||||
|
||||
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())
|
||||
})
|
||||
}
|
||||
|
||||
const endDrag = () => {
|
||||
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'
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
updateSpacing()
|
||||
updatePathLength()
|
||||
animate()
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopAnimation()
|
||||
})
|
||||
|
||||
watch([text, () => props.className], () => {
|
||||
nextTick(() => {
|
||||
updateSpacing()
|
||||
})
|
||||
})
|
||||
|
||||
watch(() => props.curveAmount, () => {
|
||||
nextTick(() => {
|
||||
updatePathLength()
|
||||
})
|
||||
})
|
||||
|
||||
watch([spacing, () => props.speed], () => {
|
||||
stopAnimation()
|
||||
if (spacing.value) {
|
||||
animate()
|
||||
}
|
||||
})
|
||||
|
||||
watch(repeats, () => {
|
||||
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">
|
||||
<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;">
|
||||
{{ text }}
|
||||
</text>
|
||||
|
||||
<defs>
|
||||
<path ref="pathRef" :id="pathId" :d="pathD" fill="none" stroke="transparent" />
|
||||
</defs>
|
||||
|
||||
<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
|
||||
}">
|
||||
{{ text }}
|
||||
</tspan>
|
||||
</textPath>
|
||||
</text>
|
||||
</svg>
|
||||
</div>
|
||||
</template>
|
||||
229
src/content/TextAnimations/DecryptedText/DecryptedText.vue
Normal file
229
src/content/TextAnimations/DecryptedText/DecryptedText.vue
Normal file
@@ -0,0 +1,229 @@
|
||||
<script setup lang="ts">
|
||||
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'
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<DecryptedTextProps>(), {
|
||||
text: '',
|
||||
speed: 50,
|
||||
maxIterations: 10,
|
||||
sequential: false,
|
||||
revealDirection: 'start',
|
||||
useOriginalCharsOnly: false,
|
||||
characters: 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz!@#$%^&*()_+',
|
||||
className: '',
|
||||
parentClassName: '',
|
||||
encryptedClassName: '',
|
||||
animateOn: 'hover'
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
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)
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
}
|
||||
for (let i = 0; i < textLength; i++) {
|
||||
if (!revealedSet.has(i)) return i
|
||||
}
|
||||
return 0
|
||||
}
|
||||
default:
|
||||
return revealedSet.size
|
||||
}
|
||||
}
|
||||
|
||||
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 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')
|
||||
}
|
||||
} 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
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
if (props.animateOn === 'hover') {
|
||||
isHovering.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (props.animateOn === 'view') {
|
||||
await nextTick()
|
||||
|
||||
const observerCallback = (entries: IntersectionObserverEntry[]) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting && !hasAnimated.value) {
|
||||
isHovering.value = true
|
||||
hasAnimated.value = true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const observerOptions = {
|
||||
root: null,
|
||||
rootMargin: '0px',
|
||||
threshold: 0.1
|
||||
}
|
||||
|
||||
intersectionObserver = new IntersectionObserver(observerCallback, observerOptions)
|
||||
if (containerRef.value) {
|
||||
intersectionObserver.observe(containerRef.value)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (interval) {
|
||||
clearInterval(interval)
|
||||
}
|
||||
if (intersectionObserver && containerRef.value) {
|
||||
intersectionObserver.unobserve(containerRef.value)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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">
|
||||
{{ char }}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
286
src/content/TextAnimations/FallingText/FallingText.vue
Normal file
286
src/content/TextAnimations/FallingText/FallingText.vue
Normal file
@@ -0,0 +1,286 @@
|
||||
<script setup lang="ts">
|
||||
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
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<FallingTextProps>(), {
|
||||
text: '',
|
||||
highlightWords: () => [],
|
||||
trigger: 'auto',
|
||||
backgroundColor: 'transparent',
|
||||
wireframes: false,
|
||||
gravity: 1,
|
||||
mouseConstraintStiffness: 0.2,
|
||||
fontSize: '1rem'
|
||||
})
|
||||
|
||||
const containerRef = ref<HTMLDivElement>()
|
||||
const textRef = ref<HTMLDivElement>()
|
||||
const canvasContainerRef = ref<HTMLDivElement>()
|
||||
|
||||
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
|
||||
|
||||
const createTextHTML = () => {
|
||||
if (!textRef.value) return
|
||||
|
||||
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>`
|
||||
})
|
||||
.join(' ')
|
||||
|
||||
textRef.value.innerHTML = newHTML
|
||||
}
|
||||
|
||||
const setupTrigger = () => {
|
||||
if (props.trigger === 'auto') {
|
||||
setTimeout(() => {
|
||||
effectStarted.value = true
|
||||
}, 100)
|
||||
return
|
||||
}
|
||||
|
||||
if (props.trigger === 'scroll' && containerRef.value) {
|
||||
intersectionObserver = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
effectStarted.value = true
|
||||
intersectionObserver?.disconnect()
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1 }
|
||||
)
|
||||
intersectionObserver.observe(containerRef.value)
|
||||
}
|
||||
}
|
||||
|
||||
const handleTrigger = () => {
|
||||
if (!effectStarted.value && (props.trigger === 'click' || props.trigger === 'hover')) {
|
||||
effectStarted.value = true
|
||||
}
|
||||
}
|
||||
|
||||
const startPhysics = async () => {
|
||||
if (!containerRef.value || !canvasContainerRef.value || !textRef.value) return
|
||||
|
||||
await nextTick()
|
||||
|
||||
const { Engine, Render, World, Bodies, Runner, Mouse, MouseConstraint } = Matter
|
||||
|
||||
const containerRect = containerRef.value.getBoundingClientRect()
|
||||
const width = containerRect.width
|
||||
const height = containerRect.height
|
||||
|
||||
if (width <= 0 || height <= 0) return
|
||||
|
||||
engine = Engine.create()
|
||||
engine.world.gravity.y = props.gravity
|
||||
|
||||
render = Render.create({
|
||||
element: canvasContainerRef.value,
|
||||
engine,
|
||||
options: {
|
||||
width,
|
||||
height,
|
||||
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 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 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)
|
||||
|
||||
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'
|
||||
})
|
||||
|
||||
const mouse = Mouse.create(containerRef.value)
|
||||
mouseConstraint = MouseConstraint.create(engine, {
|
||||
mouse,
|
||||
constraint: {
|
||||
stiffness: props.mouseConstraintStiffness,
|
||||
render: { visible: false }
|
||||
}
|
||||
})
|
||||
render.mouse = mouse
|
||||
|
||||
World.add(engine.world, [
|
||||
floor,
|
||||
leftWall,
|
||||
rightWall,
|
||||
ceiling,
|
||||
mouseConstraint,
|
||||
...wordBodies.map((wb) => wb.body)
|
||||
])
|
||||
|
||||
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 cleanup = () => {
|
||||
if (animationFrameId) {
|
||||
cancelAnimationFrame(animationFrameId)
|
||||
animationFrameId = null
|
||||
}
|
||||
|
||||
if (render) {
|
||||
Matter.Render.stop(render)
|
||||
if (render.canvas && canvasContainerRef.value) {
|
||||
canvasContainerRef.value.removeChild(render.canvas)
|
||||
}
|
||||
render = null
|
||||
}
|
||||
|
||||
if (runner && engine) {
|
||||
Matter.Runner.stop(runner)
|
||||
runner = null
|
||||
}
|
||||
|
||||
if (engine) {
|
||||
Matter.World.clear(engine.world, false)
|
||||
Matter.Engine.clear(engine)
|
||||
engine = null
|
||||
}
|
||||
|
||||
if (intersectionObserver) {
|
||||
intersectionObserver.disconnect()
|
||||
intersectionObserver = null
|
||||
}
|
||||
|
||||
mouseConstraint = null
|
||||
wordBodies = []
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.text, props.highlightWords],
|
||||
() => {
|
||||
createTextHTML()
|
||||
},
|
||||
{ immediate: true, deep: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.trigger,
|
||||
() => {
|
||||
effectStarted.value = false
|
||||
cleanup()
|
||||
setupTrigger()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => effectStarted.value,
|
||||
(started) => {
|
||||
if (started) {
|
||||
startPhysics()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => [
|
||||
props.gravity,
|
||||
props.wireframes,
|
||||
props.backgroundColor,
|
||||
props.mouseConstraintStiffness
|
||||
],
|
||||
() => {
|
||||
if (effectStarted.value) {
|
||||
cleanup()
|
||||
startPhysics()
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
createTextHTML()
|
||||
setupTrigger()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
cleanup()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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
|
||||
}" />
|
||||
|
||||
<div class="absolute top-0 left-0 z-0" ref="canvasContainerRef" />
|
||||
</div>
|
||||
</template>
|
||||
294
src/content/TextAnimations/FuzzyText/FuzzyText.vue
Normal file
294
src/content/TextAnimations/FuzzyText/FuzzyText.vue
Normal file
@@ -0,0 +1,294 @@
|
||||
<script setup lang="ts">
|
||||
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
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<FuzzyTextProps>(), {
|
||||
text: '',
|
||||
fontSize: 'clamp(2rem, 8vw, 8rem)',
|
||||
fontWeight: 900,
|
||||
fontFamily: 'inherit',
|
||||
color: '#fff',
|
||||
enableHover: true,
|
||||
baseIntensity: 0.18,
|
||||
hoverIntensity: 0.5
|
||||
})
|
||||
|
||||
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}`
|
||||
|
||||
if (document.fonts.check(fontString)) {
|
||||
return true
|
||||
}
|
||||
|
||||
try {
|
||||
await document.fonts.load(fontString)
|
||||
return document.fonts.check(fontString)
|
||||
} catch (error) {
|
||||
console.warn('Font loading failed:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const canvas = document.createElement('canvas')
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) {
|
||||
resolve(false)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.font = `${fontWeight} ${fontSize} ${fontFamily}`
|
||||
const testWidth = ctx.measureText('M').width
|
||||
|
||||
let attempts = 0
|
||||
const checkFont = () => {
|
||||
ctx.font = `${fontWeight} ${fontSize} ${fontFamily}`
|
||||
const newWidth = ctx.measureText('M').width
|
||||
|
||||
if (newWidth !== testWidth && newWidth > 0) {
|
||||
resolve(true)
|
||||
} else if (attempts < 20) {
|
||||
attempts++
|
||||
setTimeout(checkFont, 50)
|
||||
} else {
|
||||
resolve(false)
|
||||
}
|
||||
}
|
||||
|
||||
setTimeout(checkFont, 10)
|
||||
})
|
||||
}
|
||||
|
||||
const initCanvas = async () => {
|
||||
if (document.fonts?.ready) {
|
||||
await document.fonts.ready
|
||||
}
|
||||
|
||||
if (isCancelled) return
|
||||
|
||||
const canvas = canvasRef.value
|
||||
if (!canvas) return
|
||||
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
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
|
||||
|
||||
if (typeof props.fontSize === 'number') {
|
||||
numericFontSize = props.fontSize
|
||||
} else {
|
||||
const temp = document.createElement('span')
|
||||
temp.style.fontSize = props.fontSize
|
||||
temp.style.fontFamily = computedFontFamily
|
||||
document.body.appendChild(temp)
|
||||
const computedSize = window.getComputedStyle(temp).fontSize
|
||||
numericFontSize = parseFloat(computedSize)
|
||||
document.body.removeChild(temp)
|
||||
}
|
||||
|
||||
const fontLoaded = await waitForFont(computedFontFamily, props.fontWeight, fontSizeStr)
|
||||
if (!fontLoaded) {
|
||||
console.warn(`Font not loaded: ${computedFontFamily}`)
|
||||
}
|
||||
|
||||
const text = props.text
|
||||
|
||||
const offscreen = document.createElement('canvas')
|
||||
const offCtx = offscreen.getContext('2d')
|
||||
if (!offCtx) return
|
||||
|
||||
const fontString = `${props.fontWeight} ${fontSizeStr} ${computedFontFamily}`
|
||||
offCtx.font = fontString
|
||||
|
||||
const testMetrics = offCtx.measureText('M')
|
||||
if (testMetrics.width === 0) {
|
||||
setTimeout(() => {
|
||||
if (!isCancelled) {
|
||||
initCanvas()
|
||||
}
|
||||
}, 100)
|
||||
return
|
||||
}
|
||||
|
||||
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 textBoundingWidth = Math.ceil(actualLeft + actualRight)
|
||||
const tightHeight = Math.ceil(actualAscent + actualDescent)
|
||||
|
||||
const extraWidthBuffer = 10
|
||||
const offscreenWidth = textBoundingWidth + extraWidthBuffer
|
||||
|
||||
offscreen.width = offscreenWidth
|
||||
offscreen.height = tightHeight
|
||||
|
||||
const xOffset = extraWidthBuffer / 2
|
||||
offCtx.font = `${props.fontWeight} ${fontSizeStr} ${computedFontFamily}`
|
||||
offCtx.textBaseline = 'alphabetic'
|
||||
offCtx.fillStyle = props.color
|
||||
offCtx.fillText(text, xOffset - actualLeft, actualAscent)
|
||||
|
||||
const horizontalMargin = 50
|
||||
const verticalMargin = 0
|
||||
canvas.width = offscreenWidth + horizontalMargin * 2
|
||||
canvas.height = tightHeight + verticalMargin * 2
|
||||
ctx.translate(horizontalMargin, verticalMargin)
|
||||
|
||||
const interactiveLeft = horizontalMargin + xOffset
|
||||
const interactiveTop = verticalMargin
|
||||
const interactiveRight = interactiveLeft + textBoundingWidth
|
||||
const interactiveBottom = interactiveTop + tightHeight
|
||||
|
||||
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
|
||||
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
|
||||
)
|
||||
}
|
||||
animationFrameId = window.requestAnimationFrame(run)
|
||||
}
|
||||
|
||||
run()
|
||||
|
||||
const isInsideTextArea = (x: number, y: number) =>
|
||||
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)
|
||||
}
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
isHovering = false
|
||||
}
|
||||
|
||||
const handleTouchMove = (e: TouchEvent) => {
|
||||
if (!props.enableHover) return
|
||||
e.preventDefault()
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
const touch = e.touches[0]
|
||||
const x = touch.clientX - rect.left
|
||||
const y = touch.clientY - rect.top
|
||||
isHovering = isInsideTextArea(x, y)
|
||||
}
|
||||
|
||||
const handleTouchEnd = () => {
|
||||
isHovering = false
|
||||
}
|
||||
|
||||
if (props.enableHover) {
|
||||
canvas.addEventListener('mousemove', handleMouseMove)
|
||||
canvas.addEventListener('mouseleave', handleMouseLeave)
|
||||
canvas.addEventListener('touchmove', handleTouchMove, { passive: false })
|
||||
canvas.addEventListener('touchend', handleTouchEnd)
|
||||
}
|
||||
|
||||
cleanup = () => {
|
||||
window.cancelAnimationFrame(animationFrameId)
|
||||
if (props.enableHover) {
|
||||
canvas.removeEventListener('mousemove', handleMouseMove)
|
||||
canvas.removeEventListener('mouseleave', handleMouseLeave)
|
||||
canvas.removeEventListener('touchmove', handleTouchMove)
|
||||
canvas.removeEventListener('touchend', handleTouchEnd)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
initCanvas()
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
isCancelled = true
|
||||
if (animationFrameId) {
|
||||
window.cancelAnimationFrame(animationFrameId)
|
||||
}
|
||||
if (cleanup) {
|
||||
cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
[
|
||||
() => props.text,
|
||||
() => props.fontSize,
|
||||
() => props.fontWeight,
|
||||
() => props.fontFamily,
|
||||
() => props.color,
|
||||
() => props.enableHover,
|
||||
() => props.baseIntensity,
|
||||
() => props.hoverIntensity
|
||||
],
|
||||
() => {
|
||||
isCancelled = true
|
||||
if (animationFrameId) {
|
||||
window.cancelAnimationFrame(animationFrameId)
|
||||
}
|
||||
if (cleanup) {
|
||||
cleanup()
|
||||
}
|
||||
isCancelled = false
|
||||
nextTick(() => {
|
||||
initCanvas()
|
||||
})
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<canvas ref="canvasRef" />
|
||||
</template>
|
||||
84
src/content/TextAnimations/GradientText/GradientText.vue
Normal file
84
src/content/TextAnimations/GradientText/GradientText.vue
Normal file
@@ -0,0 +1,84 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
interface GradientTextProps {
|
||||
text: string
|
||||
className?: string
|
||||
colors?: string[]
|
||||
animationSpeed?: number
|
||||
showBorder?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<GradientTextProps>(), {
|
||||
text: '',
|
||||
className: '',
|
||||
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>
|
||||
<div
|
||||
:class="`relative mx-auto flex max-w-fit flex-row items-center justify-center rounded-[1.25rem] font-medium backdrop-blur transition-shadow duration-500 overflow-hidden cursor-pointer ${className}`"
|
||||
>
|
||||
<div
|
||||
v-if="showBorder"
|
||||
class="absolute inset-0 bg-cover z-0 pointer-events-none animate-gradient"
|
||||
:style="borderStyle"
|
||||
>
|
||||
<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%);
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="inline-block relative z-2 text-transparent bg-cover animate-gradient"
|
||||
:style="textStyle"
|
||||
>
|
||||
{{ text }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@keyframes gradient {
|
||||
0% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
50% {
|
||||
background-position: 100% 50%;
|
||||
}
|
||||
100% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-gradient {
|
||||
animation: gradient var(--animation-duration, 8s) linear infinite;
|
||||
}
|
||||
</style>
|
||||
48
src/content/TextAnimations/ShinyText/ShinyText.vue
Normal file
48
src/content/TextAnimations/ShinyText/ShinyText.vue
Normal file
@@ -0,0 +1,48 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
interface ShinyTextProps {
|
||||
text: string
|
||||
disabled?: boolean
|
||||
speed?: number
|
||||
className?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<ShinyTextProps>(), {
|
||||
text: '',
|
||||
disabled: false,
|
||||
speed: 5,
|
||||
className: ''
|
||||
})
|
||||
|
||||
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%)',
|
||||
backgroundSize: '200% 100%',
|
||||
WebkitBackgroundClip: 'text',
|
||||
animationDuration: animationDuration
|
||||
}"
|
||||
>
|
||||
{{ props.text }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@keyframes shine {
|
||||
0% {
|
||||
background-position: 100%;
|
||||
}
|
||||
100% {
|
||||
background-position: -100%;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-shine {
|
||||
animation: shine 5s linear infinite;
|
||||
}
|
||||
</style>
|
||||
181
src/content/TextAnimations/TextCursor/TextCursor.vue
Normal file
181
src/content/TextAnimations/TextCursor/TextCursor.vue
Normal file
@@ -0,0 +1,181 @@
|
||||
<script setup lang="ts">
|
||||
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
|
||||
}
|
||||
|
||||
interface TrailItem {
|
||||
id: number
|
||||
x: number
|
||||
y: number
|
||||
angle: number
|
||||
randomX?: number
|
||||
randomY?: number
|
||||
randomRotate?: number
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<TextCursorProps>(), {
|
||||
text: '⚛️',
|
||||
delay: 0.01,
|
||||
spacing: 100,
|
||||
followMouseDirection: true,
|
||||
randomFloat: true,
|
||||
exitDuration: 0.5,
|
||||
removalInterval: 30,
|
||||
maxPoints: 5
|
||||
})
|
||||
|
||||
const containerRef = ref<HTMLDivElement>()
|
||||
const trail = ref<TrailItem[]>([])
|
||||
const lastMoveTime = ref(Date.now())
|
||||
const idCounter = ref(0)
|
||||
|
||||
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
|
||||
|
||||
let newTrail = [...trail.value]
|
||||
|
||||
if (newTrail.length === 0) {
|
||||
newTrail.push({
|
||||
id: idCounter.value++,
|
||||
x: mouseX,
|
||||
y: mouseY,
|
||||
angle: 0,
|
||||
...(props.randomFloat && {
|
||||
randomX: Math.random() * 10 - 5,
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
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
|
||||
newTrail.push({
|
||||
id: idCounter.value++,
|
||||
x: newX,
|
||||
y: newY,
|
||||
angle: computedAngle,
|
||||
...(props.randomFloat && {
|
||||
randomX: Math.random() * 10 - 5,
|
||||
randomY: Math.random() * 10 - 5,
|
||||
randomRotate: Math.random() * 10 - 5
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (newTrail.length > props.maxPoints) {
|
||||
newTrail = newTrail.slice(newTrail.length - props.maxPoints)
|
||||
}
|
||||
|
||||
trail.value = newTrail
|
||||
lastMoveTime.value = Date.now()
|
||||
}
|
||||
|
||||
const startRemovalInterval = () => {
|
||||
if (removalIntervalId) {
|
||||
clearInterval(removalIntervalId)
|
||||
}
|
||||
|
||||
removalIntervalId = setInterval(() => {
|
||||
if (Date.now() - lastMoveTime.value > 100) {
|
||||
if (trail.value.length > 0) {
|
||||
trail.value = trail.value.slice(1)
|
||||
}
|
||||
}
|
||||
}, props.removalInterval)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (containerRef.value) {
|
||||
containerRef.value.addEventListener('mousemove', handleMouseMove)
|
||||
startRemovalInterval()
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (containerRef.value) {
|
||||
containerRef.value.removeEventListener('mousemove', handleMouseMove)
|
||||
}
|
||||
if (removalIntervalId) {
|
||||
clearInterval(removalIntervalId)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="containerRef" class="w-full h-full relative">
|
||||
<div class="absolute inset-0 pointer-events-none">
|
||||
<Motion
|
||||
v-for="item in trail"
|
||||
:key="item.id"
|
||||
:initial="{ opacity: 0, scale: 1, rotate: item.angle }"
|
||||
: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
|
||||
}"
|
||||
:transition="{
|
||||
duration: props.randomFloat ? 2 : props.exitDuration,
|
||||
repeat: props.randomFloat ? Infinity : 0,
|
||||
repeatType: props.randomFloat ? 'mirror' : 'loop'
|
||||
}"
|
||||
class="absolute select-none whitespace-nowrap text-3xl"
|
||||
:style="{ left: `${item.x}px`, top: `${item.y}px` }"
|
||||
>
|
||||
{{ props.text }}
|
||||
</Motion>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.trail-enter-active,
|
||||
.trail-leave-active {
|
||||
transition: all 0.5s ease;
|
||||
}
|
||||
|
||||
.trail-enter-from {
|
||||
opacity: 0;
|
||||
transform: scale(0);
|
||||
}
|
||||
|
||||
.trail-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(0);
|
||||
}
|
||||
</style>
|
||||
232
src/content/TextAnimations/TextPressure/TextPressure.vue
Normal file
232
src/content/TextAnimations/TextPressure/TextPressure.vue
Normal file
@@ -0,0 +1,232 @@
|
||||
<script setup lang="ts">
|
||||
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
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<TextPressureProps>(), {
|
||||
text: 'Compressa',
|
||||
fontFamily: 'Compressa VF',
|
||||
fontUrl: 'https://res.cloudinary.com/dr6lvwubh/raw/upload/v1529908256/CompressaPRO-GX.woff2',
|
||||
width: true,
|
||||
weight: true,
|
||||
italic: true,
|
||||
alpha: false,
|
||||
flex: true,
|
||||
stroke: false,
|
||||
scale: false,
|
||||
textColor: '#FFFFFF',
|
||||
strokeColor: '#FF0000',
|
||||
strokeWidth: 2,
|
||||
className: '',
|
||||
minFontSize: 24
|
||||
})
|
||||
|
||||
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 fontSize = ref(props.minFontSize)
|
||||
const scaleY = ref(1)
|
||||
const lineHeight = ref(1)
|
||||
|
||||
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 handleMouseMove = (e: MouseEvent) => {
|
||||
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 setSize = () => {
|
||||
if (!containerRef.value || !titleRef.value) return
|
||||
|
||||
const { width: containerW, height: containerH } = containerRef.value.getBoundingClientRect()
|
||||
|
||||
let newFontSize = containerW / (chars.value.length / 2)
|
||||
newFontSize = Math.max(newFontSize, props.minFontSize)
|
||||
|
||||
fontSize.value = newFontSize
|
||||
scaleY.value = 1
|
||||
lineHeight.value = 1
|
||||
|
||||
nextTick(() => {
|
||||
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
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
if (titleRef.value) {
|
||||
const titleRect = titleRef.value.getBoundingClientRect()
|
||||
const maxDist = titleRect.width / 2
|
||||
|
||||
spansRef.value.forEach((span) => {
|
||||
if (!span) return
|
||||
|
||||
const rect = span.getBoundingClientRect()
|
||||
const charCenter = {
|
||||
x: rect.x + rect.width / 2,
|
||||
y: rect.y + rect.height / 2,
|
||||
}
|
||||
|
||||
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 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}`
|
||||
})
|
||||
}
|
||||
|
||||
rafId = requestAnimationFrame(animate)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const styleElement = document.createElement('style')
|
||||
styleElement.textContent = dynamicStyles.value
|
||||
document.head.appendChild(styleElement)
|
||||
styleElement.setAttribute('data-text-pressure', 'true')
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
animate()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
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)
|
||||
if (rafId) {
|
||||
cancelAnimationFrame(rafId)
|
||||
}
|
||||
})
|
||||
|
||||
watch([() => props.scale, () => props.text], () => {
|
||||
setSize()
|
||||
})
|
||||
|
||||
watch([() => props.width, () => props.weight, () => props.italic, () => props.alpha], () => {})
|
||||
|
||||
const titleStyle = computed(() => ({
|
||||
fontFamily: props.fontFamily,
|
||||
fontSize: fontSize.value + 'px',
|
||||
lineHeight: lineHeight.value,
|
||||
transform: `scale(1, ${scaleY.value})`,
|
||||
transformOrigin: 'center top',
|
||||
margin: 0,
|
||||
fontWeight: 100,
|
||||
color: props.stroke ? undefined : props.textColor,
|
||||
}))
|
||||
|
||||
const dynamicStyles = computed(() => `
|
||||
@font-face {
|
||||
font-family: '${props.fontFamily}';
|
||||
src: url('${props.fontUrl}');
|
||||
font-style: normal;
|
||||
}
|
||||
.stroke span {
|
||||
position: relative;
|
||||
color: ${props.textColor};
|
||||
}
|
||||
.stroke span::after {
|
||||
content: attr(data-char);
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
color: transparent;
|
||||
z-index: -1;
|
||||
-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)
|
||||
|
||||
styleElement.setAttribute('data-text-pressure', 'true')
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
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"
|
||||
: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">
|
||||
{{ char }}
|
||||
</span>
|
||||
</h1>
|
||||
</div>
|
||||
</template>
|
||||
451
src/content/TextAnimations/TextTrail/TextTrail.vue
Normal file
451
src/content/TextAnimations/TextTrail/TextTrail.vue
Normal file
@@ -0,0 +1,451 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||
import {
|
||||
CanvasTexture,
|
||||
Clock,
|
||||
Color,
|
||||
LinearFilter,
|
||||
LinearMipmapLinearFilter,
|
||||
Mesh,
|
||||
OrthographicCamera,
|
||||
PlaneGeometry,
|
||||
Scene,
|
||||
ShaderMaterial,
|
||||
Vector2,
|
||||
Vector3,
|
||||
WebGLRenderer,
|
||||
WebGLRenderTarget
|
||||
} 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
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<TextTrailProps>(), {
|
||||
text: 'Vibe',
|
||||
fontFamily: 'Figtree',
|
||||
fontWeight: '900',
|
||||
noiseFactor: 1,
|
||||
noiseScale: 0.0005,
|
||||
rgbPersistFactor: 0.98,
|
||||
alphaPersistFactor: 0.95,
|
||||
animateColor: false,
|
||||
startColor: '#ffffff',
|
||||
textColor: '#ffffff',
|
||||
backgroundColor: 0x151515,
|
||||
colorCycleInterval: 3000,
|
||||
supersample: 2
|
||||
})
|
||||
|
||||
const containerRef = ref<HTMLDivElement>()
|
||||
|
||||
const hexToRgb = (hex: string): [number, number, number] => {
|
||||
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]
|
||||
}
|
||||
|
||||
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 BASE_VERT = `
|
||||
varying vec2 v_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.;}
|
||||
vec4 mod289(vec4 x){return x-floor(x*(1./289.))*289.;}
|
||||
vec4 permute(vec4 x){return mod289(((x*34.)+1.)*x);}
|
||||
float snoise3(vec3 v){
|
||||
const vec2 C=vec2(1./6.,1./3.);
|
||||
const vec4 D=vec4(0.,.5,1.,2.);
|
||||
vec3 i=floor(v+dot(v,C.yyy));
|
||||
vec3 x0=v-i+dot(i,C.xxx);
|
||||
vec3 g=step(x0.yzx,x0.xyz);
|
||||
vec3 l=1.-g;
|
||||
vec3 i1=min(g.xyz,l.zxy);
|
||||
vec3 i2=max(g.xyz,l.zxy);
|
||||
vec3 x1=x0-i1+C.xxx;
|
||||
vec3 x2=x0-i2+C.yyy;
|
||||
vec3 x3=x0-D.yyy;
|
||||
i=mod289(i);
|
||||
vec4 p=permute(permute(permute(i.z+vec4(0.,i1.z,i2.z,1.))+i.y+vec4(0.,i1.y,i2.y,1.))+i.x+vec4(0.,i1.x,i2.x,1.));
|
||||
float n_=1./7.; vec3 ns=n_*D.wyz-D.xzx;
|
||||
vec4 j=p-49.*floor(p*ns.z*ns.z);
|
||||
vec4 x_=floor(j*ns.z);
|
||||
vec4 y_=floor(j-7.*x_);
|
||||
vec4 x=x_*ns.x+ns.yyyy;
|
||||
vec4 y=y_*ns.x+ns.yyyy;
|
||||
vec4 h=1.-abs(x)-abs(y);
|
||||
vec4 b0=vec4(x.xy,y.xy);
|
||||
vec4 b1=vec4(x.zw,y.zw);
|
||||
vec4 s0=floor(b0)*2.+1.;
|
||||
vec4 s1=floor(b1)*2.+1.;
|
||||
vec4 sh=-step(h,vec4(0.));
|
||||
vec4 a0=b0.xzyw+s0.xzyw*sh.xxyy;
|
||||
vec4 a1=b1.xzyw+s1.xzyw*sh.zzww;
|
||||
vec3 p0=vec3(a0.xy,h.x);
|
||||
vec3 p1=vec3(a0.zw,h.y);
|
||||
vec3 p2=vec3(a1.xy,h.z);
|
||||
vec3 p3=vec3(a1.zw,h.w);
|
||||
vec4 norm=inversesqrt(vec4(dot(p0,p0),dot(p1,p1),dot(p2,p2),dot(p3,p3)));
|
||||
p0*=norm.x; p1*=norm.y; p2*=norm.z; p3*=norm.w;
|
||||
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;
|
||||
uniform float time;
|
||||
uniform vec2 mousePos;
|
||||
uniform float noiseFactor,noiseScale,rgbPersistFactor,alphaPersistFactor;
|
||||
varying vec2 v_uv;
|
||||
${SIMPLEX}
|
||||
void main(){
|
||||
float a=snoise3(vec3(v_uv*noiseFactor,time*.1))*noiseScale;
|
||||
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;
|
||||
void main(){
|
||||
vec4 t=texture2D(sampler,v_uv);
|
||||
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
|
||||
|
||||
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])
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
const drawText = () => {
|
||||
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 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],
|
||||
[-0.1, 0],
|
||||
[0, 0.1],
|
||||
[0, -0.1],
|
||||
[0.1, 0.1],
|
||||
[-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
|
||||
|
||||
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
|
||||
|
||||
let { w, h } = getSize()
|
||||
|
||||
renderer = new WebGLRenderer({ antialias: true })
|
||||
renderer.setClearColor(
|
||||
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)
|
||||
|
||||
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()
|
||||
|
||||
quadMat = new ShaderMaterial({
|
||||
uniforms: {
|
||||
sampler: { value: null },
|
||||
time: { value: 0 },
|
||||
mousePos: { value: new Vector2(-1, 1) },
|
||||
noiseFactor: { value: props.noiseFactor },
|
||||
noiseScale: { value: props.noiseScale },
|
||||
rgbPersistFactor: { value: props.rgbPersistFactor },
|
||||
alphaPersistFactor: { value: props.alphaPersistFactor }
|
||||
},
|
||||
vertexShader: BASE_VERT,
|
||||
fragmentShader: PERSIST_FRAG,
|
||||
transparent: true
|
||||
})
|
||||
quad = new Mesh(new PlaneGeometry(w, h), quadMat)
|
||||
fluidScene.add(quad)
|
||||
|
||||
labelMat = new ShaderMaterial({
|
||||
uniforms: {
|
||||
sampler: { value: null },
|
||||
color: { value: new Vector3(...persistColor.value) }
|
||||
},
|
||||
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)
|
||||
|
||||
await loadFont(props.fontFamily)
|
||||
drawText()
|
||||
|
||||
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)
|
||||
|
||||
colorTimer = setInterval(() => {
|
||||
if (!props.textColor) {
|
||||
targetColor.value = [Math.random(), Math.random(), Math.random()]
|
||||
}
|
||||
}, props.colorCycleInterval)
|
||||
|
||||
const animate = () => {
|
||||
if (!renderer || !quadMat || !labelMat || !clock || !scene || !fluidScene || !cam || !rt0 || !rt1) return
|
||||
|
||||
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
|
||||
}
|
||||
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)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
const cleanup = () => {
|
||||
if (renderer) {
|
||||
renderer.setAnimationLoop(null)
|
||||
if (containerRef.value && renderer.domElement.parentNode === containerRef.value) {
|
||||
containerRef.value.removeChild(renderer.domElement)
|
||||
}
|
||||
renderer.dispose()
|
||||
renderer = null
|
||||
}
|
||||
|
||||
if (colorTimer) {
|
||||
clearInterval(colorTimer)
|
||||
colorTimer = null
|
||||
}
|
||||
|
||||
if (containerRef.value) {
|
||||
containerRef.value.removeEventListener('pointermove', onMove)
|
||||
}
|
||||
|
||||
if (resizeObserver) {
|
||||
resizeObserver.disconnect()
|
||||
resizeObserver = null
|
||||
}
|
||||
|
||||
if (rt0) {
|
||||
rt0.dispose()
|
||||
rt0 = null
|
||||
}
|
||||
|
||||
if (rt1) {
|
||||
rt1.dispose()
|
||||
rt1 = null
|
||||
}
|
||||
|
||||
if (quadMat) {
|
||||
quadMat.dispose()
|
||||
quadMat = null
|
||||
}
|
||||
|
||||
if (quad) {
|
||||
quad.geometry.dispose()
|
||||
quad = null
|
||||
}
|
||||
|
||||
if (labelMat) {
|
||||
labelMat.dispose()
|
||||
labelMat = null
|
||||
}
|
||||
|
||||
if (label) {
|
||||
label.geometry.dispose()
|
||||
label = null
|
||||
}
|
||||
|
||||
scene = null
|
||||
fluidScene = null
|
||||
clock = null
|
||||
cam = null
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [
|
||||
props.text,
|
||||
props.fontFamily,
|
||||
props.fontWeight,
|
||||
props.noiseFactor,
|
||||
props.noiseScale,
|
||||
props.rgbPersistFactor,
|
||||
props.alphaPersistFactor,
|
||||
props.animateColor,
|
||||
props.startColor,
|
||||
props.textColor,
|
||||
props.backgroundColor,
|
||||
props.colorCycleInterval,
|
||||
props.supersample
|
||||
],
|
||||
() => {
|
||||
cleanup()
|
||||
if (containerRef.value) {
|
||||
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()
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
cleanup()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="containerRef" class="w-full h-full" />
|
||||
</template>
|
||||
Reference in New Issue
Block a user