mirror of
https://github.com/DavidHDev/vue-bits.git
synced 2026-03-08 16:09:31 -06:00
Landing Page
This commit is contained in:
163
src/content/Animations/CountUp/CountUp.vue
Normal file
163
src/content/Animations/CountUp/CountUp.vue
Normal 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>
|
||||
66
src/content/Animations/FadeContent/FadeContent.vue
Normal file
66
src/content/Animations/FadeContent/FadeContent.vue
Normal 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>
|
||||
315
src/content/Backgrounds/DotGrid/DotGrid.vue
Normal file
315
src/content/Backgrounds/DotGrid/DotGrid.vue
Normal 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>
|
||||
246
src/content/Backgrounds/LetterGlitch/LetterGlitch.vue
Normal file
246
src/content/Backgrounds/LetterGlitch/LetterGlitch.vue
Normal 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>
|
||||
197
src/content/Backgrounds/Squares/Squares.vue
Normal file
197
src/content/Backgrounds/Squares/Squares.vue
Normal 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>
|
||||
192
src/content/TextAnimations/SplitText/SplitText.vue
Normal file
192
src/content/TextAnimations/SplitText/SplitText.vue
Normal 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>
|
||||
|
||||
Reference in New Issue
Block a user