Landing Page

This commit is contained in:
David Haz
2025-07-08 12:39:14 +03:00
parent fa9392fa47
commit 9ddb731258
41 changed files with 4584 additions and 8 deletions

View File

@@ -0,0 +1,163 @@
<template>
<span ref="elementRef" :class="className" />
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch, computed } from 'vue'
interface Props {
to: number
from?: number
direction?: "up" | "down"
delay?: number
duration?: number
className?: string
startWhen?: boolean
separator?: string
onStart?: () => void
onEnd?: () => void
}
const props = withDefaults(defineProps<Props>(), {
from: 0,
direction: "up",
delay: 0,
duration: 2,
className: "",
startWhen: true,
separator: ""
})
const elementRef = ref<HTMLSpanElement | null>(null)
const currentValue = ref(props.direction === "down" ? props.to : props.from)
const isInView = ref(false)
const animationId = ref<number | null>(null)
const hasStarted = ref(false)
let intersectionObserver: IntersectionObserver | null = null
const damping = computed(() => 20 + 40 * (1 / props.duration))
const stiffness = computed(() => 100 * (1 / props.duration))
let velocity = 0
let startTime = 0
const formatNumber = (value: number) => {
const options = {
useGrouping: !!props.separator,
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}
const formattedNumber = Intl.NumberFormat("en-US", options).format(
Number(value.toFixed(0))
)
return props.separator
? formattedNumber.replace(/,/g, props.separator)
: formattedNumber
}
const updateDisplay = () => {
if (elementRef.value) {
elementRef.value.textContent = formatNumber(currentValue.value)
}
}
const springAnimation = (timestamp: number) => {
if (!startTime) startTime = timestamp
const target = props.direction === "down" ? props.from : props.to
const current = currentValue.value
const displacement = target - current
const springForce = displacement * stiffness.value
const dampingForce = velocity * damping.value
const acceleration = springForce - dampingForce
velocity += acceleration * 0.016 // Assuming 60fps
currentValue.value += velocity * 0.016
updateDisplay()
if (Math.abs(displacement) > 0.01 || Math.abs(velocity) > 0.01) {
animationId.value = requestAnimationFrame(springAnimation)
} else {
currentValue.value = target
updateDisplay()
animationId.value = null
if (props.onEnd) {
props.onEnd()
}
}
}
const startAnimation = () => {
if (hasStarted.value || !isInView.value || !props.startWhen) return
hasStarted.value = true
if (props.onStart) {
props.onStart()
}
setTimeout(() => {
startTime = 0
velocity = 0
animationId.value = requestAnimationFrame(springAnimation)
}, props.delay * 1000)
}
const setupIntersectionObserver = () => {
if (!elementRef.value) return
intersectionObserver = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting && !isInView.value) {
isInView.value = true
startAnimation()
}
},
{
threshold: 0,
rootMargin: "0px"
}
)
intersectionObserver.observe(elementRef.value)
}
const cleanup = () => {
if (animationId.value) {
cancelAnimationFrame(animationId.value)
animationId.value = null
}
if (intersectionObserver) {
intersectionObserver.disconnect()
intersectionObserver = null
}
}
watch([() => props.from, () => props.to, () => props.direction], () => {
currentValue.value = props.direction === "down" ? props.to : props.from
updateDisplay()
hasStarted.value = false
}, { immediate: true })
watch(() => props.startWhen, () => {
if (props.startWhen && isInView.value && !hasStarted.value) {
startAnimation()
}
})
onMounted(() => {
updateDisplay()
setupIntersectionObserver()
})
onUnmounted(() => {
cleanup()
})
</script>

View File

@@ -0,0 +1,66 @@
<template>
<div
ref="elementRef"
:class="className"
:style="{
opacity: inView ? 1 : initialOpacity,
transition: `opacity ${duration}ms ${easing}, filter ${duration}ms ${easing}`,
filter: blur ? (inView ? 'blur(0px)' : 'blur(10px)') : 'none',
}"
>
<slot />
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
interface Props {
blur?: boolean
duration?: number
easing?: string
delay?: number
threshold?: number
initialOpacity?: number
className?: string
}
const props = withDefaults(defineProps<Props>(), {
blur: false,
duration: 1000,
easing: 'ease-out',
delay: 0,
threshold: 0.1,
initialOpacity: 0,
className: ''
})
const inView = ref(false)
const elementRef = ref<HTMLDivElement | null>(null)
let observer: IntersectionObserver | null = null
onMounted(() => {
const element = elementRef.value
if (!element) return
observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
observer?.unobserve(element)
setTimeout(() => {
inView.value = true
}, props.delay)
}
},
{ threshold: props.threshold }
)
observer.observe(element)
})
onUnmounted(() => {
if (observer) {
observer.disconnect()
}
})
</script>

View File

@@ -0,0 +1,315 @@
<template>
<section :class="`flex items-center justify-center h-full w-full relative ${className}`" :style="style">
<div ref="wrapperRef" class="w-full h-full relative">
<canvas ref="canvasRef" class="absolute inset-0 w-full h-full pointer-events-none" />
</div>
</section>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed, watch, nextTick } from 'vue'
import { gsap } from 'gsap'
import { InertiaPlugin } from 'gsap/InertiaPlugin'
gsap.registerPlugin(InertiaPlugin)
const throttle = <T extends unknown[]>(func: (...args: T) => void, limit: number) => {
let lastCall = 0
return function (this: unknown, ...args: T) {
const now = performance.now()
if (now - lastCall >= limit) {
lastCall = now
func.apply(this, args)
}
}
}
interface Dot {
cx: number
cy: number
xOffset: number
yOffset: number
_inertiaApplied: boolean
}
export interface DotGridProps {
dotSize?: number
gap?: number
baseColor?: string
activeColor?: string
proximity?: number
speedTrigger?: number
shockRadius?: number
shockStrength?: number
maxSpeed?: number
resistance?: number
returnDuration?: number
className?: string
style?: Record<string, string | number>
}
const props = withDefaults(defineProps<DotGridProps>(), {
dotSize: 16,
gap: 32,
baseColor: '#5227FF',
activeColor: '#5227FF',
proximity: 150,
speedTrigger: 100,
shockRadius: 250,
shockStrength: 5,
maxSpeed: 5000,
resistance: 750,
returnDuration: 1.5,
className: '',
style: () => ({})
})
const wrapperRef = ref<HTMLDivElement>()
const canvasRef = ref<HTMLCanvasElement>()
const dots = ref<Dot[]>([])
const pointer = ref({
x: 0,
y: 0,
vx: 0,
vy: 0,
speed: 0,
lastTime: 0,
lastX: 0,
lastY: 0,
})
function hexToRgb(hex: string) {
const m = hex.match(/^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i)
if (!m) return { r: 0, g: 0, b: 0 }
return {
r: parseInt(m[1], 16),
g: parseInt(m[2], 16),
b: parseInt(m[3], 16),
}
}
const baseRgb = computed(() => hexToRgb(props.baseColor))
const activeRgb = computed(() => hexToRgb(props.activeColor))
const circlePath = computed(() => {
if (typeof window === 'undefined' || !window.Path2D) return null
const p = new Path2D()
p.arc(0, 0, props.dotSize / 2, 0, Math.PI * 2)
return p
})
const buildGrid = () => {
const wrap = wrapperRef.value
const canvas = canvasRef.value
if (!wrap || !canvas) return
const { width, height } = wrap.getBoundingClientRect()
const dpr = window.devicePixelRatio || 1
canvas.width = width * dpr
canvas.height = height * dpr
canvas.style.width = `${width}px`
canvas.style.height = `${height}px`
const ctx = canvas.getContext('2d')
if (ctx) ctx.scale(dpr, dpr)
const cols = Math.floor((width + props.gap) / (props.dotSize + props.gap))
const rows = Math.floor((height + props.gap) / (props.dotSize + props.gap))
const cell = props.dotSize + props.gap
const gridW = cell * cols - props.gap
const gridH = cell * rows - props.gap
const extraX = width - gridW
const extraY = height - gridH
const startX = extraX / 2 + props.dotSize / 2
const startY = extraY / 2 + props.dotSize / 2
const newDots: Dot[] = []
for (let y = 0; y < rows; y++) {
for (let x = 0; x < cols; x++) {
const cx = startX + x * cell
const cy = startY + y * cell
newDots.push({ cx, cy, xOffset: 0, yOffset: 0, _inertiaApplied: false })
}
}
dots.value = newDots
}
let rafId: number
let resizeObserver: ResizeObserver | null = null
const draw = () => {
const canvas = canvasRef.value
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
ctx.clearRect(0, 0, canvas.width, canvas.height)
const { x: px, y: py } = pointer.value
const proxSq = props.proximity * props.proximity
for (const dot of dots.value) {
const ox = dot.cx + dot.xOffset
const oy = dot.cy + dot.yOffset
const dx = dot.cx - px
const dy = dot.cy - py
const dsq = dx * dx + dy * dy
let style = props.baseColor
if (dsq <= proxSq) {
const dist = Math.sqrt(dsq)
const t = 1 - dist / props.proximity
const r = Math.round(baseRgb.value.r + (activeRgb.value.r - baseRgb.value.r) * t)
const g = Math.round(baseRgb.value.g + (activeRgb.value.g - baseRgb.value.g) * t)
const b = Math.round(baseRgb.value.b + (activeRgb.value.b - baseRgb.value.b) * t)
style = `rgb(${r},${g},${b})`
}
if (circlePath.value) {
ctx.save()
ctx.translate(ox, oy)
ctx.fillStyle = style
ctx.fill(circlePath.value)
ctx.restore()
}
}
rafId = requestAnimationFrame(draw)
}
const onMove = (e: MouseEvent) => {
const now = performance.now()
const pr = pointer.value
const dt = pr.lastTime ? now - pr.lastTime : 16
const dx = e.clientX - pr.lastX
const dy = e.clientY - pr.lastY
let vx = (dx / dt) * 1000
let vy = (dy / dt) * 1000
let speed = Math.hypot(vx, vy)
if (speed > props.maxSpeed) {
const scale = props.maxSpeed / speed
vx *= scale
vy *= scale
speed = props.maxSpeed
}
pr.lastTime = now
pr.lastX = e.clientX
pr.lastY = e.clientY
pr.vx = vx
pr.vy = vy
pr.speed = speed
const canvas = canvasRef.value
if (!canvas) return
const rect = canvas.getBoundingClientRect()
pr.x = e.clientX - rect.left
pr.y = e.clientY - rect.top
for (const dot of dots.value) {
const dist = Math.hypot(dot.cx - pr.x, dot.cy - pr.y)
if (speed > props.speedTrigger && dist < props.proximity && !dot._inertiaApplied) {
dot._inertiaApplied = true
gsap.killTweensOf(dot)
const pushX = dot.cx - pr.x + vx * 0.005
const pushY = dot.cy - pr.y + vy * 0.005
gsap.to(dot, {
inertia: { xOffset: pushX, yOffset: pushY, resistance: props.resistance },
onComplete: () => {
gsap.to(dot, {
xOffset: 0,
yOffset: 0,
duration: props.returnDuration,
ease: 'elastic.out(1,0.75)',
})
dot._inertiaApplied = false
},
})
}
}
}
const onClick = (e: MouseEvent) => {
const canvas = canvasRef.value
if (!canvas) return
const rect = canvas.getBoundingClientRect()
const cx = e.clientX - rect.left
const cy = e.clientY - rect.top
for (const dot of dots.value) {
const dist = Math.hypot(dot.cx - cx, dot.cy - cy)
if (dist < props.shockRadius && !dot._inertiaApplied) {
dot._inertiaApplied = true
gsap.killTweensOf(dot)
const falloff = Math.max(0, 1 - dist / props.shockRadius)
const pushX = (dot.cx - cx) * props.shockStrength * falloff
const pushY = (dot.cy - cy) * props.shockStrength * falloff
gsap.to(dot, {
inertia: { xOffset: pushX, yOffset: pushY, resistance: props.resistance },
onComplete: () => {
gsap.to(dot, {
xOffset: 0,
yOffset: 0,
duration: props.returnDuration,
ease: 'elastic.out(1,0.75)',
})
dot._inertiaApplied = false
},
})
}
}
}
const throttledMove = throttle(onMove, 50)
onMounted(async () => {
await nextTick()
buildGrid()
if (circlePath.value) {
draw()
}
if ('ResizeObserver' in window) {
resizeObserver = new ResizeObserver(buildGrid)
if (wrapperRef.value) {
resizeObserver.observe(wrapperRef.value)
}
} else {
(window as Window).addEventListener('resize', buildGrid)
}
window.addEventListener('mousemove', throttledMove, { passive: true })
window.addEventListener('click', onClick)
})
onUnmounted(() => {
if (rafId) {
cancelAnimationFrame(rafId)
}
if (resizeObserver) {
resizeObserver.disconnect()
} else {
window.removeEventListener('resize', buildGrid)
}
window.removeEventListener('mousemove', throttledMove)
window.removeEventListener('click', onClick)
})
watch([() => props.dotSize, () => props.gap], () => {
buildGrid()
})
watch([() => props.proximity, () => props.baseColor, activeRgb, baseRgb, circlePath], () => {
if (rafId) {
cancelAnimationFrame(rafId)
}
if (circlePath.value) {
draw()
}
})
</script>

View File

@@ -0,0 +1,246 @@
<template>
<div class="relative w-full h-full overflow-hidden">
<canvas ref="canvasRef" class="block w-full h-full" />
<div v-if="outerVignette"
class="absolute top-0 left-0 w-full h-full pointer-events-none bg-[radial-gradient(circle,_rgba(0,0,0,0)_60%,_rgba(0,0,0,1)_100%)]" />
<div v-if="centerVignette"
class="absolute top-0 left-0 w-full h-full pointer-events-none bg-[radial-gradient(circle,_rgba(0,0,0,0.8)_0%,_rgba(0,0,0,0)_60%)]" />
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from 'vue'
interface Props {
glitchColors?: string[]
glitchSpeed?: number
centerVignette?: boolean
outerVignette?: boolean
smooth?: boolean
}
const props = withDefaults(defineProps<Props>(), {
glitchColors: () => ["#2b4539", "#61dca3", "#61b3dc"],
glitchSpeed: 50,
centerVignette: false,
outerVignette: false,
smooth: true
})
const canvasRef = ref<HTMLCanvasElement | null>(null)
const animationRef = ref<number | null>(null)
const letters = ref<{
char: string
color: string
targetColor: string
colorProgress: number
}[]>([])
const grid = ref({ columns: 0, rows: 0 })
const context = ref<CanvasRenderingContext2D | null>(null)
const lastGlitchTime = ref(Date.now())
const fontSize = 16
const charWidth = 10
const charHeight = 20
const lettersAndSymbols = [
"A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M",
"N", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z",
"!", "@", "#", "$", "&", "*", "(", ")", "-", "_", "+", "=", "/",
"[", "]", "{", "}", ";", ":", "<", ">", ",", "0", "1", "2", "3",
"4", "5", "6", "7", "8", "9"
]
const getRandomChar = () => {
return lettersAndSymbols[Math.floor(Math.random() * lettersAndSymbols.length)]
}
const getRandomColor = () => {
return props.glitchColors[Math.floor(Math.random() * props.glitchColors.length)]
}
const hexToRgb = (hex: string) => {
const shorthandRegex = /^#?([a-f\d])([a-f\d])([a-f\d])$/i
hex = hex.replace(shorthandRegex, (m, r, g, b) => {
return r + r + g + g + b + b
})
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
return result
? {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16),
}
: null
}
const interpolateColor = (
start: { r: number; g: number; b: number },
end: { r: number; g: number; b: number },
factor: number
) => {
const result = {
r: Math.round(start.r + (end.r - start.r) * factor),
g: Math.round(start.g + (end.g - start.g) * factor),
b: Math.round(start.b + (end.b - start.b) * factor),
}
return `rgb(${result.r}, ${result.g}, ${result.b})`
}
const calculateGrid = (width: number, height: number) => {
const columns = Math.ceil(width / charWidth)
const rows = Math.ceil(height / charHeight)
return { columns, rows }
}
const initializeLetters = (columns: number, rows: number) => {
grid.value = { columns, rows }
const totalLetters = columns * rows
letters.value = Array.from({ length: totalLetters }, () => ({
char: getRandomChar(),
color: getRandomColor(),
targetColor: getRandomColor(),
colorProgress: 1,
}))
}
const resizeCanvas = () => {
const canvas = canvasRef.value
if (!canvas) return
const parent = canvas.parentElement
if (!parent) return
const dpr = window.devicePixelRatio || 1
const rect = parent.getBoundingClientRect()
canvas.width = rect.width * dpr
canvas.height = rect.height * dpr
canvas.style.width = `${rect.width}px`
canvas.style.height = `${rect.height}px`
if (context.value) {
context.value.setTransform(dpr, 0, 0, dpr, 0, 0)
}
const { columns, rows } = calculateGrid(rect.width, rect.height)
initializeLetters(columns, rows)
drawLetters()
}
const drawLetters = () => {
if (!context.value || letters.value.length === 0) return
const ctx = context.value
const { width, height } = canvasRef.value!.getBoundingClientRect()
ctx.clearRect(0, 0, width, height)
ctx.font = `${fontSize}px monospace`
ctx.textBaseline = "top"
letters.value.forEach((letter, index) => {
const x = (index % grid.value.columns) * charWidth
const y = Math.floor(index / grid.value.columns) * charHeight
ctx.fillStyle = letter.color
ctx.fillText(letter.char, x, y)
})
}
const updateLetters = () => {
if (!letters.value || letters.value.length === 0) return
const updateCount = Math.max(1, Math.floor(letters.value.length * 0.05))
for (let i = 0; i < updateCount; i++) {
const index = Math.floor(Math.random() * letters.value.length)
if (!letters.value[index]) continue
letters.value[index].char = getRandomChar()
letters.value[index].targetColor = getRandomColor()
if (!props.smooth) {
letters.value[index].color = letters.value[index].targetColor
letters.value[index].colorProgress = 1
} else {
letters.value[index].colorProgress = 0
}
}
}
const handleSmoothTransitions = () => {
let needsRedraw = false
letters.value.forEach((letter) => {
if (letter.colorProgress < 1) {
letter.colorProgress += 0.05
if (letter.colorProgress > 1) letter.colorProgress = 1
const startRgb = hexToRgb(letter.color)
const endRgb = hexToRgb(letter.targetColor)
if (startRgb && endRgb) {
letter.color = interpolateColor(
startRgb,
endRgb,
letter.colorProgress
)
needsRedraw = true
}
}
})
if (needsRedraw) {
drawLetters()
}
}
const animate = () => {
const now = Date.now()
if (now - lastGlitchTime.value >= props.glitchSpeed) {
updateLetters()
drawLetters()
lastGlitchTime.value = now
}
if (props.smooth) {
handleSmoothTransitions()
}
animationRef.value = requestAnimationFrame(animate)
}
let resizeTimeout: number
const handleResize = () => {
clearTimeout(resizeTimeout)
resizeTimeout = setTimeout(() => {
if (animationRef.value) {
cancelAnimationFrame(animationRef.value)
}
resizeCanvas()
animate()
}, 100)
}
onMounted(() => {
const canvas = canvasRef.value
if (!canvas) return
context.value = canvas.getContext("2d")
resizeCanvas()
animate()
window.addEventListener("resize", handleResize)
})
onUnmounted(() => {
if (animationRef.value) {
cancelAnimationFrame(animationRef.value)
}
window.removeEventListener("resize", handleResize)
})
watch([() => props.glitchSpeed, () => props.smooth], () => {
if (animationRef.value) {
cancelAnimationFrame(animationRef.value)
}
animate()
})
</script>

View File

@@ -0,0 +1,197 @@
<template>
<canvas ref="canvasRef" class="w-full h-full border-none block" />
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from 'vue'
type CanvasStrokeStyle = string | CanvasGradient | CanvasPattern
interface GridOffset {
x: number
y: number
}
interface Props {
direction?: "diagonal" | "up" | "right" | "down" | "left"
speed?: number
borderColor?: CanvasStrokeStyle
squareSize?: number
hoverFillColor?: CanvasStrokeStyle
}
const props = withDefaults(defineProps<Props>(), {
direction: "right",
speed: 1,
borderColor: "#999",
squareSize: 40,
hoverFillColor: "#222"
})
const canvasRef = ref<HTMLCanvasElement | null>(null)
const requestRef = ref<number | null>(null)
const numSquaresX = ref<number>(0)
const numSquaresY = ref<number>(0)
const gridOffset = ref<GridOffset>({ x: 0, y: 0 })
const hoveredSquareRef = ref<GridOffset | null>(null)
let ctx: CanvasRenderingContext2D | null = null
const resizeCanvas = () => {
const canvas = canvasRef.value
if (!canvas) return
canvas.width = canvas.offsetWidth
canvas.height = canvas.offsetHeight
numSquaresX.value = Math.ceil(canvas.width / props.squareSize) + 1
numSquaresY.value = Math.ceil(canvas.height / props.squareSize) + 1
}
const drawGrid = () => {
const canvas = canvasRef.value
if (!ctx || !canvas) return
ctx.clearRect(0, 0, canvas.width, canvas.height)
const startX = Math.floor(gridOffset.value.x / props.squareSize) * props.squareSize
const startY = Math.floor(gridOffset.value.y / props.squareSize) * props.squareSize
for (let x = startX; x < canvas.width + props.squareSize; x += props.squareSize) {
for (let y = startY; y < canvas.height + props.squareSize; y += props.squareSize) {
const squareX = x - (gridOffset.value.x % props.squareSize)
const squareY = y - (gridOffset.value.y % props.squareSize)
if (
hoveredSquareRef.value &&
Math.floor((x - startX) / props.squareSize) === hoveredSquareRef.value.x &&
Math.floor((y - startY) / props.squareSize) === hoveredSquareRef.value.y
) {
ctx.fillStyle = props.hoverFillColor
ctx.fillRect(squareX, squareY, props.squareSize, props.squareSize)
}
ctx.strokeStyle = props.borderColor
ctx.strokeRect(squareX, squareY, props.squareSize, props.squareSize)
}
}
const gradient = ctx.createRadialGradient(
canvas.width / 2,
canvas.height / 2,
0,
canvas.width / 2,
canvas.height / 2,
Math.sqrt(canvas.width ** 2 + canvas.height ** 2) / 2
)
gradient.addColorStop(0, "rgba(0, 0, 0, 0)")
gradient.addColorStop(1, "#0e0e0e")
ctx.fillStyle = gradient
ctx.fillRect(0, 0, canvas.width, canvas.height)
}
const updateAnimation = () => {
const effectiveSpeed = Math.max(props.speed, 0.1)
switch (props.direction) {
case "right":
gridOffset.value.x = (gridOffset.value.x - effectiveSpeed + props.squareSize) % props.squareSize
break
case "left":
gridOffset.value.x = (gridOffset.value.x + effectiveSpeed + props.squareSize) % props.squareSize
break
case "up":
gridOffset.value.y = (gridOffset.value.y + effectiveSpeed + props.squareSize) % props.squareSize
break
case "down":
gridOffset.value.y = (gridOffset.value.y - effectiveSpeed + props.squareSize) % props.squareSize
break
case "diagonal":
gridOffset.value.x = (gridOffset.value.x - effectiveSpeed + props.squareSize) % props.squareSize
gridOffset.value.y = (gridOffset.value.y - effectiveSpeed + props.squareSize) % props.squareSize
break
default:
break
}
drawGrid()
requestRef.value = requestAnimationFrame(updateAnimation)
}
const handleMouseMove = (event: MouseEvent) => {
const canvas = canvasRef.value
if (!canvas) return
const rect = canvas.getBoundingClientRect()
const mouseX = event.clientX - rect.left
const mouseY = event.clientY - rect.top
const startX = Math.floor(gridOffset.value.x / props.squareSize) * props.squareSize
const startY = Math.floor(gridOffset.value.y / props.squareSize) * props.squareSize
const hoveredSquareX = Math.floor(
(mouseX + gridOffset.value.x - startX) / props.squareSize
)
const hoveredSquareY = Math.floor(
(mouseY + gridOffset.value.y - startY) / props.squareSize
)
if (
!hoveredSquareRef.value ||
hoveredSquareRef.value.x !== hoveredSquareX ||
hoveredSquareRef.value.y !== hoveredSquareY
) {
hoveredSquareRef.value = { x: hoveredSquareX, y: hoveredSquareY }
}
}
const handleMouseLeave = () => {
hoveredSquareRef.value = null
}
const initializeCanvas = () => {
const canvas = canvasRef.value
if (!canvas) return
ctx = canvas.getContext("2d")
resizeCanvas()
canvas.addEventListener("mousemove", handleMouseMove)
canvas.addEventListener("mouseleave", handleMouseLeave)
window.addEventListener("resize", resizeCanvas)
requestRef.value = requestAnimationFrame(updateAnimation)
}
const cleanup = () => {
const canvas = canvasRef.value
if (requestRef.value) {
cancelAnimationFrame(requestRef.value)
requestRef.value = null
}
if (canvas) {
canvas.removeEventListener("mousemove", handleMouseMove)
canvas.removeEventListener("mouseleave", handleMouseLeave)
}
window.removeEventListener("resize", resizeCanvas)
}
onMounted(() => {
initializeCanvas()
})
onUnmounted(() => {
cleanup()
})
watch(
[() => props.direction, () => props.speed, () => props.borderColor, () => props.hoverFillColor, () => props.squareSize],
() => {
cleanup()
initializeCanvas()
}
)
</script>

View File

@@ -0,0 +1,192 @@
<template>
<p
ref="textRef"
:class="`split-parent overflow-hidden inline-block whitespace-normal ${className}`"
:style="{
textAlign,
wordWrap: 'break-word',
}"
>
{{ text }}
</p>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { gsap } from 'gsap'
import { ScrollTrigger } from 'gsap/ScrollTrigger'
import { SplitText as GSAPSplitText } from 'gsap/SplitText'
gsap.registerPlugin(ScrollTrigger, GSAPSplitText)
export interface SplitTextProps {
text: string
className?: string
delay?: number
duration?: number
ease?: string | ((t: number) => number)
splitType?: 'chars' | 'words' | 'lines' | 'words, chars'
from?: gsap.TweenVars
to?: gsap.TweenVars
threshold?: number
rootMargin?: string
textAlign?: 'left' | 'center' | 'right' | 'justify'
onLetterAnimationComplete?: () => void
}
const props = withDefaults(defineProps<SplitTextProps>(), {
className: '',
delay: 100,
duration: 0.6,
ease: 'power3.out',
splitType: 'chars',
from: () => ({ opacity: 0, y: 40 }),
to: () => ({ opacity: 1, y: 0 }),
threshold: 0.1,
rootMargin: '-100px',
textAlign: 'center'
})
const textRef = ref<HTMLParagraphElement | null>(null)
const animationCompletedRef = ref(false)
const scrollTriggerRef = ref<ScrollTrigger | null>(null)
const timelineRef = ref<gsap.core.Timeline | null>(null)
const splitterRef = ref<GSAPSplitText | null>(null)
const initializeAnimation = async () => {
if (typeof window === 'undefined' || !textRef.value || !props.text) return
// Wait for DOM to be fully updated
await nextTick()
const el = textRef.value
animationCompletedRef.value = false
const absoluteLines = props.splitType === 'lines'
if (absoluteLines) el.style.position = 'relative'
let splitter: GSAPSplitText
try {
splitter = new GSAPSplitText(el, {
type: props.splitType,
absolute: absoluteLines,
linesClass: 'split-line',
})
splitterRef.value = splitter
} catch (error) {
console.error('Failed to create SplitText:', error)
return
}
let targets: Element[]
switch (props.splitType) {
case 'lines':
targets = splitter.lines
break
case 'words':
targets = splitter.words
break
case 'chars':
targets = splitter.chars
break
default:
targets = splitter.chars
}
if (!targets || targets.length === 0) {
console.warn('No targets found for SplitText animation')
splitter.revert()
return
}
targets.forEach((t) => {
;(t as HTMLElement).style.willChange = 'transform, opacity'
})
const startPct = (1 - props.threshold) * 100
const marginMatch = /^(-?\d+(?:\.\d+)?)(px|em|rem|%)?$/.exec(props.rootMargin)
const marginValue = marginMatch ? parseFloat(marginMatch[1]) : 0
const marginUnit = marginMatch ? (marginMatch[2] || 'px') : 'px'
const sign = marginValue < 0 ? `-=${Math.abs(marginValue)}${marginUnit}` : `+=${marginValue}${marginUnit}`
const start = `top ${startPct}%${sign}`
const tl = gsap.timeline({
scrollTrigger: {
trigger: el,
start,
toggleActions: 'play none none none',
once: true,
onToggle: (self) => {
scrollTriggerRef.value = self
},
},
smoothChildTiming: true,
onComplete: () => {
animationCompletedRef.value = true
gsap.set(targets, {
...props.to,
clearProps: 'willChange',
immediateRender: true,
})
props.onLetterAnimationComplete?.()
},
})
timelineRef.value = tl
tl.set(targets, { ...props.from, immediateRender: false, force3D: true })
tl.to(targets, {
...props.to,
duration: props.duration,
ease: props.ease,
stagger: props.delay / 1000,
force3D: true,
})
}
const cleanup = () => {
if (timelineRef.value) {
timelineRef.value.kill()
timelineRef.value = null
}
if (scrollTriggerRef.value) {
scrollTriggerRef.value.kill()
scrollTriggerRef.value = null
}
if (splitterRef.value) {
gsap.killTweensOf(textRef.value)
splitterRef.value.revert()
splitterRef.value = null
}
}
onMounted(() => {
initializeAnimation()
})
onUnmounted(() => {
cleanup()
})
// Watch for prop changes and reinitialize animation
watch(
[
() => props.text,
() => props.delay,
() => props.duration,
() => props.ease,
() => props.splitType,
() => props.from,
() => props.to,
() => props.threshold,
() => props.rootMargin,
() => props.onLetterAnimationComplete,
],
() => {
cleanup()
initializeAnimation()
}
)
</script>