Component Boom

This commit is contained in:
David Haz
2025-07-10 15:36:38 +03:00
parent a4982577ad
commit 9b3465b04d
135 changed files with 16697 additions and 60 deletions

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>