mirror of
https://github.com/DavidHDev/vue-bits.git
synced 2026-03-09 00:19:31 -06:00
Component Boom
This commit is contained in:
142
src/content/Animations/AnimatedContent/AnimatedContent.vue
Normal file
142
src/content/Animations/AnimatedContent/AnimatedContent.vue
Normal file
@@ -0,0 +1,142 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { gsap } from 'gsap'
|
||||
import { ScrollTrigger } from 'gsap/ScrollTrigger'
|
||||
|
||||
gsap.registerPlugin(ScrollTrigger)
|
||||
|
||||
interface AnimatedContentProps {
|
||||
distance?: number
|
||||
direction?: 'vertical' | 'horizontal'
|
||||
reverse?: boolean
|
||||
duration?: number
|
||||
ease?: string | ((progress: number) => number)
|
||||
initialOpacity?: number
|
||||
animateOpacity?: boolean
|
||||
scale?: number
|
||||
threshold?: number
|
||||
delay?: number
|
||||
className?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<AnimatedContentProps>(), {
|
||||
distance: 100,
|
||||
direction: 'vertical',
|
||||
reverse: false,
|
||||
duration: 0.8,
|
||||
ease: 'power3.out',
|
||||
initialOpacity: 0,
|
||||
animateOpacity: true,
|
||||
scale: 1,
|
||||
threshold: 0.1,
|
||||
delay: 0,
|
||||
className: ''
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
complete: []
|
||||
}>()
|
||||
|
||||
const containerRef = ref<HTMLDivElement>()
|
||||
|
||||
onMounted(() => {
|
||||
const el = containerRef.value
|
||||
if (!el) return
|
||||
|
||||
const axis = props.direction === 'horizontal' ? 'x' : 'y'
|
||||
const offset = props.reverse ? -props.distance : props.distance
|
||||
const startPct = (1 - props.threshold) * 100
|
||||
|
||||
gsap.set(el, {
|
||||
[axis]: offset,
|
||||
scale: props.scale,
|
||||
opacity: props.animateOpacity ? props.initialOpacity : 1,
|
||||
})
|
||||
|
||||
gsap.to(el, {
|
||||
[axis]: 0,
|
||||
scale: 1,
|
||||
opacity: 1,
|
||||
duration: props.duration,
|
||||
ease: props.ease,
|
||||
delay: props.delay,
|
||||
onComplete: () => emit('complete'),
|
||||
scrollTrigger: {
|
||||
trigger: el,
|
||||
start: `top ${startPct}%`,
|
||||
toggleActions: 'play none none none',
|
||||
once: true,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
watch(
|
||||
() => [
|
||||
props.distance,
|
||||
props.direction,
|
||||
props.reverse,
|
||||
props.duration,
|
||||
props.ease,
|
||||
props.initialOpacity,
|
||||
props.animateOpacity,
|
||||
props.scale,
|
||||
props.threshold,
|
||||
props.delay,
|
||||
],
|
||||
() => {
|
||||
const el = containerRef.value
|
||||
if (!el) return
|
||||
|
||||
ScrollTrigger.getAll().forEach((t) => t.kill())
|
||||
gsap.killTweensOf(el)
|
||||
|
||||
const axis = props.direction === 'horizontal' ? 'x' : 'y'
|
||||
const offset = props.reverse ? -props.distance : props.distance
|
||||
const startPct = (1 - props.threshold) * 100
|
||||
|
||||
gsap.set(el, {
|
||||
[axis]: offset,
|
||||
scale: props.scale,
|
||||
opacity: props.animateOpacity ? props.initialOpacity : 1,
|
||||
})
|
||||
|
||||
gsap.to(el, {
|
||||
[axis]: 0,
|
||||
scale: 1,
|
||||
opacity: 1,
|
||||
duration: props.duration,
|
||||
ease: props.ease,
|
||||
delay: props.delay,
|
||||
onComplete: () => emit('complete'),
|
||||
scrollTrigger: {
|
||||
trigger: el,
|
||||
start: `top ${startPct}%`,
|
||||
toggleActions: 'play none none none',
|
||||
once: true,
|
||||
},
|
||||
})
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
onUnmounted(() => {
|
||||
const el = containerRef.value
|
||||
if (el) {
|
||||
ScrollTrigger.getAll().forEach((t) => t.kill())
|
||||
gsap.killTweensOf(el)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="containerRef"
|
||||
:class="`animated-content ${props.className}`"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* GSAP will handle all transforms and opacity */
|
||||
</style>
|
||||
188
src/content/Animations/ClickSpark/ClickSpark.vue
Normal file
188
src/content/Animations/ClickSpark/ClickSpark.vue
Normal file
@@ -0,0 +1,188 @@
|
||||
<template>
|
||||
<div
|
||||
ref="containerRef"
|
||||
class="relative w-full h-full"
|
||||
@click="handleClick"
|
||||
>
|
||||
<canvas
|
||||
ref="canvasRef"
|
||||
class="absolute inset-0 pointer-events-none"
|
||||
/>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
|
||||
|
||||
interface Spark {
|
||||
x: number
|
||||
y: number
|
||||
angle: number
|
||||
startTime: number
|
||||
}
|
||||
|
||||
interface Props {
|
||||
sparkColor?: string
|
||||
sparkSize?: number
|
||||
sparkRadius?: number
|
||||
sparkCount?: number
|
||||
duration?: number
|
||||
easing?: "linear" | "ease-in" | "ease-out" | "ease-in-out"
|
||||
extraScale?: number
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
sparkColor: '#fff',
|
||||
sparkSize: 10,
|
||||
sparkRadius: 15,
|
||||
sparkCount: 8,
|
||||
duration: 400,
|
||||
easing: 'ease-out',
|
||||
extraScale: 1.0
|
||||
})
|
||||
|
||||
const containerRef = ref<HTMLDivElement | null>(null)
|
||||
const canvasRef = ref<HTMLCanvasElement | null>(null)
|
||||
const sparks = ref<Spark[]>([])
|
||||
const startTimeRef = ref<number | null>(null)
|
||||
const animationId = ref<number | null>(null)
|
||||
|
||||
const easeFunc = computed(() => {
|
||||
return (t: number) => {
|
||||
switch (props.easing) {
|
||||
case "linear":
|
||||
return t
|
||||
case "ease-in":
|
||||
return t * t
|
||||
case "ease-in-out":
|
||||
return t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t
|
||||
default:
|
||||
return t * (2 - t)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
const canvas = canvasRef.value
|
||||
if (!canvas) return
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
const x = e.clientX - rect.left
|
||||
const y = e.clientY - rect.top
|
||||
|
||||
const now = performance.now()
|
||||
const newSparks: Spark[] = Array.from({ length: props.sparkCount }, (_, i) => ({
|
||||
x,
|
||||
y,
|
||||
angle: (2 * Math.PI * i) / props.sparkCount,
|
||||
startTime: now,
|
||||
}))
|
||||
|
||||
sparks.value.push(...newSparks)
|
||||
}
|
||||
|
||||
const draw = (timestamp: number) => {
|
||||
if (!startTimeRef.value) {
|
||||
startTimeRef.value = timestamp
|
||||
}
|
||||
|
||||
const canvas = canvasRef.value
|
||||
const ctx = canvas?.getContext('2d')
|
||||
if (!ctx || !canvas) return
|
||||
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||
|
||||
sparks.value = sparks.value.filter((spark: Spark) => {
|
||||
const elapsed = timestamp - spark.startTime
|
||||
if (elapsed >= props.duration) {
|
||||
return false
|
||||
}
|
||||
|
||||
const progress = elapsed / props.duration
|
||||
const eased = easeFunc.value(progress)
|
||||
|
||||
const distance = eased * props.sparkRadius * props.extraScale
|
||||
const lineLength = props.sparkSize * (1 - eased)
|
||||
|
||||
const x1 = spark.x + distance * Math.cos(spark.angle)
|
||||
const y1 = spark.y + distance * Math.sin(spark.angle)
|
||||
const x2 = spark.x + (distance + lineLength) * Math.cos(spark.angle)
|
||||
const y2 = spark.y + (distance + lineLength) * Math.sin(spark.angle)
|
||||
|
||||
ctx.strokeStyle = props.sparkColor
|
||||
ctx.lineWidth = 2
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(x1, y1)
|
||||
ctx.lineTo(x2, y2)
|
||||
ctx.stroke()
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
animationId.value = requestAnimationFrame(draw)
|
||||
}
|
||||
|
||||
const resizeCanvas = () => {
|
||||
const canvas = canvasRef.value
|
||||
if (!canvas) return
|
||||
|
||||
const parent = canvas.parentElement
|
||||
if (!parent) return
|
||||
|
||||
const { width, height } = parent.getBoundingClientRect()
|
||||
if (canvas.width !== width || canvas.height !== height) {
|
||||
canvas.width = width
|
||||
canvas.height = height
|
||||
}
|
||||
}
|
||||
|
||||
let resizeTimeout: number
|
||||
|
||||
const handleResize = () => {
|
||||
clearTimeout(resizeTimeout)
|
||||
resizeTimeout = setTimeout(resizeCanvas, 100)
|
||||
}
|
||||
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
|
||||
onMounted(() => {
|
||||
const canvas = canvasRef.value
|
||||
if (!canvas) return
|
||||
|
||||
const parent = canvas.parentElement
|
||||
if (!parent) return
|
||||
|
||||
resizeObserver = new ResizeObserver(handleResize)
|
||||
resizeObserver.observe(parent)
|
||||
|
||||
resizeCanvas()
|
||||
|
||||
animationId.value = requestAnimationFrame(draw)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (resizeObserver) {
|
||||
resizeObserver.disconnect()
|
||||
}
|
||||
clearTimeout(resizeTimeout)
|
||||
|
||||
if (animationId.value) {
|
||||
cancelAnimationFrame(animationId.value)
|
||||
}
|
||||
})
|
||||
|
||||
watch([
|
||||
() => props.sparkColor,
|
||||
() => props.sparkSize,
|
||||
() => props.sparkRadius,
|
||||
() => props.sparkCount,
|
||||
() => props.duration,
|
||||
easeFunc,
|
||||
() => props.extraScale
|
||||
], () => {
|
||||
if (animationId.value) {
|
||||
cancelAnimationFrame(animationId.value)
|
||||
}
|
||||
animationId.value = requestAnimationFrame(draw)
|
||||
})
|
||||
</script>
|
||||
321
src/content/Animations/Cubes/Cubes.vue
Normal file
321
src/content/Animations/Cubes/Cubes.vue
Normal file
@@ -0,0 +1,321 @@
|
||||
<template>
|
||||
<div class="relative w-1/2 max-md:w-11/12 aspect-square" :style="wrapperStyle">
|
||||
<div ref="sceneRef" class="grid w-full h-full" :style="sceneStyle">
|
||||
<template v-for="(_, r) in cells" :key="`row-${r}`">
|
||||
<div v-for="(__, c) in cells" :key="`${r}-${c}`"
|
||||
class="cube relative w-full h-full aspect-square [transform-style:preserve-3d]" :data-row="r" :data-col="c">
|
||||
<span class="absolute pointer-events-none -inset-9" />
|
||||
|
||||
<div class="cube-face absolute inset-0 flex items-center justify-center" :style="{
|
||||
background: 'var(--cube-face-bg)',
|
||||
border: 'var(--cube-face-border)',
|
||||
boxShadow: 'var(--cube-face-shadow)',
|
||||
transform: 'translateY(-50%) rotateX(90deg)',
|
||||
}" />
|
||||
<div class="cube-face absolute inset-0 flex items-center justify-center" :style="{
|
||||
background: 'var(--cube-face-bg)',
|
||||
border: 'var(--cube-face-border)',
|
||||
boxShadow: 'var(--cube-face-shadow)',
|
||||
transform: 'translateY(50%) rotateX(-90deg)',
|
||||
}" />
|
||||
<div class="cube-face absolute inset-0 flex items-center justify-center" :style="{
|
||||
background: 'var(--cube-face-bg)',
|
||||
border: 'var(--cube-face-border)',
|
||||
boxShadow: 'var(--cube-face-shadow)',
|
||||
transform: 'translateX(-50%) rotateY(-90deg)',
|
||||
}" />
|
||||
<div class="cube-face absolute inset-0 flex items-center justify-center" :style="{
|
||||
background: 'var(--cube-face-bg)',
|
||||
border: 'var(--cube-face-border)',
|
||||
boxShadow: 'var(--cube-face-shadow)',
|
||||
transform: 'translateX(50%) rotateY(90deg)',
|
||||
}" />
|
||||
<div class="cube-face absolute inset-0 flex items-center justify-center" :style="{
|
||||
background: 'var(--cube-face-bg)',
|
||||
border: 'var(--cube-face-border)',
|
||||
boxShadow: 'var(--cube-face-shadow)',
|
||||
transform: 'rotateY(-90deg) translateX(50%) rotateY(90deg)',
|
||||
}" />
|
||||
<div class="cube-face absolute inset-0 flex items-center justify-center" :style="{
|
||||
background: 'var(--cube-face-bg)',
|
||||
border: 'var(--cube-face-border)',
|
||||
boxShadow: 'var(--cube-face-shadow)',
|
||||
transform: 'rotateY(90deg) translateX(-50%) rotateY(-90deg)',
|
||||
}" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, withDefaults } from 'vue'
|
||||
import gsap from 'gsap'
|
||||
|
||||
interface Gap {
|
||||
row: number
|
||||
col: number
|
||||
}
|
||||
|
||||
interface Duration {
|
||||
enter: number
|
||||
leave: number
|
||||
}
|
||||
|
||||
interface Props {
|
||||
gridSize?: number
|
||||
cubeSize?: number
|
||||
maxAngle?: number
|
||||
radius?: number
|
||||
easing?: gsap.EaseString
|
||||
duration?: Duration
|
||||
cellGap?: number | Gap
|
||||
borderStyle?: string
|
||||
faceColor?: string
|
||||
shadow?: boolean | string
|
||||
autoAnimate?: boolean
|
||||
rippleOnClick?: boolean
|
||||
rippleColor?: string
|
||||
rippleSpeed?: number
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
gridSize: 10,
|
||||
maxAngle: 45,
|
||||
radius: 3,
|
||||
easing: 'power3.out',
|
||||
duration: () => ({ enter: 0.3, leave: 0.6 }),
|
||||
borderStyle: '1px solid #fff',
|
||||
faceColor: '#0b0b0b',
|
||||
shadow: false,
|
||||
autoAnimate: true,
|
||||
rippleOnClick: true,
|
||||
rippleColor: '#fff',
|
||||
rippleSpeed: 2,
|
||||
})
|
||||
|
||||
const sceneRef = ref<HTMLDivElement | null>(null)
|
||||
const rafRef = ref<number | null>(null)
|
||||
const idleTimerRef = ref<number | null>(null)
|
||||
const userActiveRef = ref(false)
|
||||
const simPosRef = ref<{ x: number; y: number }>({ x: 0, y: 0 })
|
||||
const simTargetRef = ref<{ x: number; y: number }>({ x: 0, y: 0 })
|
||||
const simRAFRef = ref<number | null>(null)
|
||||
|
||||
const colGap = computed(() => {
|
||||
return typeof props.cellGap === 'number'
|
||||
? `${props.cellGap}px`
|
||||
: (props.cellGap as Gap)?.col !== undefined
|
||||
? `${(props.cellGap as Gap).col}px`
|
||||
: '5%'
|
||||
})
|
||||
|
||||
const rowGap = computed(() => {
|
||||
return typeof props.cellGap === 'number'
|
||||
? `${props.cellGap}px`
|
||||
: (props.cellGap as Gap)?.row !== undefined
|
||||
? `${(props.cellGap as Gap).row}px`
|
||||
: '5%'
|
||||
})
|
||||
|
||||
const enterDur = computed(() => props.duration.enter)
|
||||
const leaveDur = computed(() => props.duration.leave)
|
||||
|
||||
const cells = computed(() => Array.from({ length: props.gridSize }))
|
||||
|
||||
const sceneStyle = computed(() => ({
|
||||
gridTemplateColumns: props.cubeSize
|
||||
? `repeat(${props.gridSize}, ${props.cubeSize}px)`
|
||||
: `repeat(${props.gridSize}, 1fr)`,
|
||||
gridTemplateRows: props.cubeSize
|
||||
? `repeat(${props.gridSize}, ${props.cubeSize}px)`
|
||||
: `repeat(${props.gridSize}, 1fr)`,
|
||||
columnGap: colGap.value,
|
||||
rowGap: rowGap.value,
|
||||
perspective: '99999999px',
|
||||
gridAutoRows: '1fr',
|
||||
}))
|
||||
|
||||
const wrapperStyle = computed(() => ({
|
||||
'--cube-face-border': props.borderStyle,
|
||||
'--cube-face-bg': props.faceColor,
|
||||
'--cube-face-shadow':
|
||||
props.shadow === true ? '0 0 6px rgba(0,0,0,.5)' : props.shadow || 'none',
|
||||
...(props.cubeSize
|
||||
? {
|
||||
width: `${props.gridSize * props.cubeSize}px`,
|
||||
height: `${props.gridSize * props.cubeSize}px`,
|
||||
}
|
||||
: {}),
|
||||
}))
|
||||
|
||||
const tiltAt = (rowCenter: number, colCenter: number) => {
|
||||
if (!sceneRef.value) return
|
||||
|
||||
sceneRef.value.querySelectorAll<HTMLDivElement>('.cube').forEach((cube) => {
|
||||
const r = +(cube.dataset.row!)
|
||||
const c = +(cube.dataset.col!)
|
||||
const dist = Math.hypot(r - rowCenter, c - colCenter)
|
||||
|
||||
if (dist <= props.radius) {
|
||||
const pct = 1 - dist / props.radius
|
||||
const angle = pct * props.maxAngle
|
||||
gsap.to(cube, {
|
||||
duration: enterDur.value,
|
||||
ease: props.easing,
|
||||
overwrite: true,
|
||||
rotateX: -angle,
|
||||
rotateY: angle,
|
||||
})
|
||||
} else {
|
||||
gsap.to(cube, {
|
||||
duration: leaveDur.value,
|
||||
ease: 'power3.out',
|
||||
overwrite: true,
|
||||
rotateX: 0,
|
||||
rotateY: 0,
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const onPointerMove = (e: PointerEvent) => {
|
||||
userActiveRef.value = true
|
||||
if (idleTimerRef.value) clearTimeout(idleTimerRef.value)
|
||||
|
||||
const rect = sceneRef.value!.getBoundingClientRect()
|
||||
const cellW = rect.width / props.gridSize
|
||||
const cellH = rect.height / props.gridSize
|
||||
const colCenter = (e.clientX - rect.left) / cellW
|
||||
const rowCenter = (e.clientY - rect.top) / cellH
|
||||
|
||||
if (rafRef.value) cancelAnimationFrame(rafRef.value)
|
||||
rafRef.value = requestAnimationFrame(() =>
|
||||
tiltAt(rowCenter, colCenter)
|
||||
)
|
||||
|
||||
idleTimerRef.value = setTimeout(() => {
|
||||
userActiveRef.value = false
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
const resetAll = () => {
|
||||
if (!sceneRef.value) return
|
||||
sceneRef.value.querySelectorAll<HTMLDivElement>('.cube').forEach((cube) =>
|
||||
gsap.to(cube, {
|
||||
duration: leaveDur.value,
|
||||
rotateX: 0,
|
||||
rotateY: 0,
|
||||
ease: 'power3.out',
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
const onClick = (e: MouseEvent) => {
|
||||
if (!props.rippleOnClick || !sceneRef.value) return
|
||||
|
||||
const rect = sceneRef.value.getBoundingClientRect()
|
||||
const cellW = rect.width / props.gridSize
|
||||
const cellH = rect.height / props.gridSize
|
||||
const colHit = Math.floor((e.clientX - rect.left) / cellW)
|
||||
const rowHit = Math.floor((e.clientY - rect.top) / cellH)
|
||||
|
||||
const baseRingDelay = 0.15
|
||||
const baseAnimDur = 0.3
|
||||
const baseHold = 0.6
|
||||
|
||||
const spreadDelay = baseRingDelay / props.rippleSpeed
|
||||
const animDuration = baseAnimDur / props.rippleSpeed
|
||||
const holdTime = baseHold / props.rippleSpeed
|
||||
|
||||
const rings: Record<number, HTMLDivElement[]> = {}
|
||||
sceneRef.value.querySelectorAll<HTMLDivElement>('.cube').forEach((cube) => {
|
||||
const r = +(cube.dataset.row!)
|
||||
const c = +(cube.dataset.col!)
|
||||
const dist = Math.hypot(r - rowHit, c - colHit)
|
||||
const ring = Math.round(dist)
|
||||
if (!rings[ring]) rings[ring] = []
|
||||
rings[ring].push(cube)
|
||||
})
|
||||
|
||||
Object.keys(rings)
|
||||
.map(Number)
|
||||
.sort((a, b) => a - b)
|
||||
.forEach((ring) => {
|
||||
const delay = ring * spreadDelay
|
||||
const faces = rings[ring].flatMap((cube) =>
|
||||
Array.from(cube.querySelectorAll<HTMLElement>('.cube-face'))
|
||||
)
|
||||
|
||||
gsap.to(faces, {
|
||||
backgroundColor: props.rippleColor,
|
||||
duration: animDuration,
|
||||
delay,
|
||||
ease: 'power3.out',
|
||||
})
|
||||
gsap.to(faces, {
|
||||
backgroundColor: props.faceColor,
|
||||
duration: animDuration,
|
||||
delay: delay + animDuration + holdTime,
|
||||
ease: 'power3.out',
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const startAutoAnimation = () => {
|
||||
if (!props.autoAnimate || !sceneRef.value) return
|
||||
|
||||
simPosRef.value = {
|
||||
x: Math.random() * props.gridSize,
|
||||
y: Math.random() * props.gridSize,
|
||||
}
|
||||
simTargetRef.value = {
|
||||
x: Math.random() * props.gridSize,
|
||||
y: Math.random() * props.gridSize,
|
||||
}
|
||||
|
||||
const speed = 0.02
|
||||
const loop = () => {
|
||||
if (!userActiveRef.value) {
|
||||
const pos = simPosRef.value
|
||||
const tgt = simTargetRef.value
|
||||
pos.x += (tgt.x - pos.x) * speed
|
||||
pos.y += (tgt.y - pos.y) * speed
|
||||
tiltAt(pos.y, pos.x)
|
||||
|
||||
if (Math.hypot(pos.x - tgt.x, pos.y - tgt.y) < 0.1) {
|
||||
simTargetRef.value = {
|
||||
x: Math.random() * props.gridSize,
|
||||
y: Math.random() * props.gridSize,
|
||||
}
|
||||
}
|
||||
}
|
||||
simRAFRef.value = requestAnimationFrame(loop)
|
||||
}
|
||||
simRAFRef.value = requestAnimationFrame(loop)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const el = sceneRef.value
|
||||
if (!el) return
|
||||
|
||||
el.addEventListener('pointermove', onPointerMove)
|
||||
el.addEventListener('pointerleave', resetAll)
|
||||
el.addEventListener('click', onClick)
|
||||
|
||||
startAutoAnimation()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
const el = sceneRef.value
|
||||
if (el) {
|
||||
el.removeEventListener('pointermove', onPointerMove)
|
||||
el.removeEventListener('pointerleave', resetAll)
|
||||
el.removeEventListener('click', onClick)
|
||||
}
|
||||
|
||||
if (rafRef.value !== null) cancelAnimationFrame(rafRef.value)
|
||||
if (idleTimerRef.value !== null) clearTimeout(idleTimerRef.value)
|
||||
if (simRAFRef.value !== null) cancelAnimationFrame(simRAFRef.value)
|
||||
})
|
||||
</script>
|
||||
112
src/content/Animations/GlareHover/GlareHover.vue
Normal file
112
src/content/Animations/GlareHover/GlareHover.vue
Normal file
@@ -0,0 +1,112 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
interface GlareHoverProps {
|
||||
width?: string
|
||||
height?: string
|
||||
background?: string
|
||||
borderRadius?: string
|
||||
borderColor?: string
|
||||
glareColor?: string
|
||||
glareOpacity?: number
|
||||
glareAngle?: number
|
||||
glareSize?: number
|
||||
transitionDuration?: number
|
||||
playOnce?: boolean
|
||||
className?: string
|
||||
style?: Record<string, string | number>
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<GlareHoverProps>(), {
|
||||
width: '500px',
|
||||
height: '500px',
|
||||
background: '#000',
|
||||
borderRadius: '10px',
|
||||
borderColor: '#333',
|
||||
glareColor: '#ffffff',
|
||||
glareOpacity: 0.5,
|
||||
glareAngle: -45,
|
||||
glareSize: 250,
|
||||
transitionDuration: 650,
|
||||
playOnce: false,
|
||||
className: '',
|
||||
style: () => ({})
|
||||
})
|
||||
|
||||
const overlayRef = ref<HTMLDivElement | null>(null)
|
||||
|
||||
const rgba = computed(() => {
|
||||
const hex = props.glareColor.replace('#', '')
|
||||
let result = props.glareColor
|
||||
|
||||
if (/^[\dA-Fa-f]{6}$/.test(hex)) {
|
||||
const r = parseInt(hex.slice(0, 2), 16)
|
||||
const g = parseInt(hex.slice(2, 4), 16)
|
||||
const b = parseInt(hex.slice(4, 6), 16)
|
||||
result = `rgba(${r}, ${g}, ${b}, ${props.glareOpacity})`
|
||||
} else if (/^[\dA-Fa-f]{3}$/.test(hex)) {
|
||||
const r = parseInt(hex[0] + hex[0], 16)
|
||||
const g = parseInt(hex[1] + hex[1], 16)
|
||||
const b = parseInt(hex[2] + hex[2], 16)
|
||||
result = `rgba(${r}, ${g}, ${b}, ${props.glareOpacity})`
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
const overlayStyle = computed(() => ({
|
||||
position: 'absolute' as const,
|
||||
inset: '0',
|
||||
background: `linear-gradient(${props.glareAngle}deg,
|
||||
hsla(0,0%,0%,0) 60%,
|
||||
${rgba.value} 70%,
|
||||
hsla(0,0%,0%,0) 100%)`,
|
||||
backgroundSize: `${props.glareSize}% ${props.glareSize}%, 100% 100%`,
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundPosition: '-100% -100%, 0 0',
|
||||
pointerEvents: 'none' as const,
|
||||
}))
|
||||
|
||||
const animateIn = () => {
|
||||
const el = overlayRef.value
|
||||
if (!el) return
|
||||
|
||||
el.style.transition = 'none'
|
||||
el.style.backgroundPosition = '-100% -100%, 0 0'
|
||||
void el.offsetHeight
|
||||
el.style.transition = `${props.transitionDuration}ms ease`
|
||||
el.style.backgroundPosition = '100% 100%, 0 0'
|
||||
}
|
||||
|
||||
const animateOut = () => {
|
||||
const el = overlayRef.value
|
||||
if (!el) return
|
||||
|
||||
if (props.playOnce) {
|
||||
el.style.transition = 'none'
|
||||
el.style.backgroundPosition = '-100% -100%, 0 0'
|
||||
} else {
|
||||
el.style.transition = `${props.transitionDuration}ms ease`
|
||||
el.style.backgroundPosition = '-100% -100%, 0 0'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="`relative grid place-items-center overflow-hidden border cursor-pointer ${props.className}`"
|
||||
:style="{
|
||||
width: props.width,
|
||||
height: props.height,
|
||||
background: props.background,
|
||||
borderRadius: props.borderRadius,
|
||||
borderColor: props.borderColor,
|
||||
...props.style,
|
||||
}"
|
||||
@mouseenter="animateIn"
|
||||
@mouseleave="animateOut"
|
||||
>
|
||||
<div ref="overlayRef" :style="overlayStyle" />
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
84
src/content/Animations/Magnet/Magnet.vue
Normal file
84
src/content/Animations/Magnet/Magnet.vue
Normal file
@@ -0,0 +1,84 @@
|
||||
<template>
|
||||
<div ref="magnetRef" :class="wrapperClassName" :style="{ position: 'relative', display: 'inline-block' }"
|
||||
v-bind="$attrs">
|
||||
<div :class="innerClassName" :style="{
|
||||
transform: `translate3d(${position.x}px, ${position.y}px, 0)`,
|
||||
transition: transitionStyle,
|
||||
willChange: 'transform',
|
||||
}">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
|
||||
interface Props {
|
||||
padding?: number
|
||||
disabled?: boolean
|
||||
magnetStrength?: number
|
||||
activeTransition?: string
|
||||
inactiveTransition?: string
|
||||
wrapperClassName?: string
|
||||
innerClassName?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
padding: 100,
|
||||
disabled: false,
|
||||
magnetStrength: 2,
|
||||
activeTransition: 'transform 0.3s ease-out',
|
||||
inactiveTransition: 'transform 0.5s ease-in-out',
|
||||
wrapperClassName: '',
|
||||
innerClassName: ''
|
||||
})
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false
|
||||
})
|
||||
|
||||
const magnetRef = ref<HTMLDivElement | null>(null)
|
||||
const isActive = ref(false)
|
||||
const position = ref({ x: 0, y: 0 })
|
||||
|
||||
const transitionStyle = computed(() =>
|
||||
isActive.value ? props.activeTransition : props.inactiveTransition
|
||||
)
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!magnetRef.value || props.disabled) return
|
||||
|
||||
const { left, top, width, height } = magnetRef.value.getBoundingClientRect()
|
||||
const centerX = left + width / 2
|
||||
const centerY = top + height / 2
|
||||
|
||||
const distX = Math.abs(centerX - e.clientX)
|
||||
const distY = Math.abs(centerY - e.clientY)
|
||||
|
||||
if (distX < width / 2 + props.padding && distY < height / 2 + props.padding) {
|
||||
isActive.value = true
|
||||
const offsetX = (e.clientX - centerX) / props.magnetStrength
|
||||
const offsetY = (e.clientY - centerY) / props.magnetStrength
|
||||
position.value = { x: offsetX, y: offsetY }
|
||||
} else {
|
||||
isActive.value = false
|
||||
position.value = { x: 0, y: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('mousemove', handleMouseMove)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('mousemove', handleMouseMove)
|
||||
})
|
||||
|
||||
watch(() => props.disabled, (newDisabled) => {
|
||||
if (newDisabled) {
|
||||
position.value = { x: 0, y: 0 }
|
||||
isActive.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
101
src/content/Animations/MagnetLines/MagnetLines.vue
Normal file
101
src/content/Animations/MagnetLines/MagnetLines.vue
Normal file
@@ -0,0 +1,101 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, computed } from 'vue'
|
||||
|
||||
interface MagnetLinesProps {
|
||||
rows?: number
|
||||
columns?: number
|
||||
containerSize?: string
|
||||
lineColor?: string
|
||||
lineWidth?: string
|
||||
lineHeight?: string
|
||||
baseAngle?: number
|
||||
className?: string
|
||||
style?: Record<string, string | number>
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<MagnetLinesProps>(), {
|
||||
rows: 9,
|
||||
columns: 9,
|
||||
containerSize: '80vmin',
|
||||
lineColor: '#efefef',
|
||||
lineWidth: '1vmin',
|
||||
lineHeight: '6vmin',
|
||||
baseAngle: -10,
|
||||
className: '',
|
||||
style: () => ({})
|
||||
})
|
||||
|
||||
const containerRef = ref<HTMLDivElement | null>(null)
|
||||
|
||||
const total = computed(() => props.rows * props.columns)
|
||||
|
||||
const onPointerMove = (pointer: { x: number; y: number }) => {
|
||||
const container = containerRef.value
|
||||
if (!container) return
|
||||
|
||||
const items = container.querySelectorAll<HTMLSpanElement>('span')
|
||||
|
||||
items.forEach((item) => {
|
||||
const rect = item.getBoundingClientRect()
|
||||
const centerX = rect.x + rect.width / 2
|
||||
const centerY = rect.y + rect.height / 2
|
||||
|
||||
const b = pointer.x - centerX
|
||||
const a = pointer.y - centerY
|
||||
const c = Math.sqrt(a * a + b * b) || 1
|
||||
const r = ((Math.acos(b / c) * 180) / Math.PI) * (pointer.y > centerY ? 1 : -1)
|
||||
|
||||
item.style.setProperty('--rotate', `${r}deg`)
|
||||
})
|
||||
}
|
||||
|
||||
const handlePointerMove = (e: PointerEvent) => {
|
||||
onPointerMove({ x: e.x, y: e.y })
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const container = containerRef.value
|
||||
if (!container) return
|
||||
|
||||
window.addEventListener('pointermove', handlePointerMove)
|
||||
|
||||
const items = container.querySelectorAll<HTMLSpanElement>('span')
|
||||
if (items.length) {
|
||||
const middleIndex = Math.floor(items.length / 2)
|
||||
const rect = items[middleIndex].getBoundingClientRect()
|
||||
onPointerMove({ x: rect.x, y: rect.y })
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('pointermove', handlePointerMove)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="containerRef"
|
||||
:class="`grid place-items-center ${props.className}`"
|
||||
:style="{
|
||||
gridTemplateColumns: `repeat(${props.columns}, 1fr)`,
|
||||
gridTemplateRows: `repeat(${props.rows}, 1fr)`,
|
||||
width: props.containerSize,
|
||||
height: props.containerSize,
|
||||
...props.style,
|
||||
}"
|
||||
>
|
||||
<span
|
||||
v-for="i in total"
|
||||
:key="i"
|
||||
class="block origin-center"
|
||||
:style="{
|
||||
backgroundColor: props.lineColor,
|
||||
width: props.lineWidth,
|
||||
height: props.lineHeight,
|
||||
'--rotate': `${props.baseAngle}deg`,
|
||||
transform: 'rotate(var(--rotate))',
|
||||
willChange: 'transform',
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
137
src/content/Animations/PixelTransition/PixelTransition.vue
Normal file
137
src/content/Animations/PixelTransition/PixelTransition.vue
Normal file
@@ -0,0 +1,137 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch, onUnmounted, nextTick } from 'vue'
|
||||
import { gsap } from 'gsap'
|
||||
|
||||
interface PixelTransitionProps {
|
||||
gridSize?: number
|
||||
pixelColor?: string
|
||||
animationStepDuration?: number
|
||||
className?: string
|
||||
style?: Record<string, string | number>
|
||||
aspectRatio?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<PixelTransitionProps>(), {
|
||||
gridSize: 7,
|
||||
pixelColor: 'currentColor',
|
||||
animationStepDuration: 0.3,
|
||||
className: '',
|
||||
style: () => ({}),
|
||||
aspectRatio: '100%'
|
||||
})
|
||||
|
||||
const containerRef = ref<HTMLDivElement | null>(null)
|
||||
const pixelGridRef = ref<HTMLDivElement | null>(null)
|
||||
const activeRef = ref<HTMLDivElement | null>(null)
|
||||
const isActive = ref(false)
|
||||
let delayedCall: gsap.core.Tween | null = null
|
||||
|
||||
const isTouchDevice =
|
||||
typeof window !== 'undefined' &&
|
||||
('ontouchstart' in window ||
|
||||
(navigator && navigator.maxTouchPoints > 0) ||
|
||||
(window.matchMedia && window.matchMedia('(pointer: coarse)').matches))
|
||||
|
||||
function buildPixelGrid() {
|
||||
const pixelGridEl = pixelGridRef.value
|
||||
if (!pixelGridEl) return
|
||||
pixelGridEl.innerHTML = ''
|
||||
for (let row = 0; row < props.gridSize; row++) {
|
||||
for (let col = 0; col < props.gridSize; col++) {
|
||||
const pixel = document.createElement('div')
|
||||
pixel.classList.add('pixelated-image-card__pixel', 'absolute', 'hidden')
|
||||
pixel.style.backgroundColor = props.pixelColor
|
||||
const size = 100 / props.gridSize
|
||||
pixel.style.width = `${size}%`
|
||||
pixel.style.height = `${size}%`
|
||||
pixel.style.left = `${col * size}%`
|
||||
pixel.style.top = `${row * size}%`
|
||||
pixelGridEl.appendChild(pixel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function animatePixels(activate: boolean) {
|
||||
isActive.value = activate
|
||||
await nextTick()
|
||||
const pixelGridEl = pixelGridRef.value
|
||||
const activeEl = activeRef.value
|
||||
if (!pixelGridEl || !activeEl) return
|
||||
const pixels = pixelGridEl.querySelectorAll<HTMLDivElement>('.pixelated-image-card__pixel')
|
||||
if (!pixels.length) return
|
||||
gsap.killTweensOf(pixels)
|
||||
if (delayedCall) delayedCall.kill()
|
||||
gsap.set(pixels, { display: 'none' })
|
||||
const totalPixels = pixels.length
|
||||
const staggerDuration = props.animationStepDuration / totalPixels
|
||||
gsap.to(pixels, {
|
||||
display: 'block',
|
||||
duration: 0,
|
||||
stagger: {
|
||||
each: staggerDuration,
|
||||
from: 'random',
|
||||
},
|
||||
})
|
||||
delayedCall = gsap.delayedCall(props.animationStepDuration, () => {
|
||||
activeEl.style.display = activate ? 'block' : 'none'
|
||||
activeEl.style.pointerEvents = activate ? 'none' : ''
|
||||
})
|
||||
gsap.to(pixels, {
|
||||
display: 'none',
|
||||
duration: 0,
|
||||
delay: props.animationStepDuration,
|
||||
stagger: {
|
||||
each: staggerDuration,
|
||||
from: 'random',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function handleMouseEnter() {
|
||||
if (isTouchDevice) return
|
||||
if (!isActive.value) animatePixels(true)
|
||||
}
|
||||
function handleMouseLeave() {
|
||||
if (isTouchDevice) return
|
||||
if (isActive.value) animatePixels(false)
|
||||
}
|
||||
function handleClick() {
|
||||
if (!isTouchDevice) return
|
||||
animatePixels(!isActive.value)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick()
|
||||
buildPixelGrid()
|
||||
})
|
||||
|
||||
watch(() => [props.gridSize, props.pixelColor], () => {
|
||||
buildPixelGrid()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (delayedCall) delayedCall.kill()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="containerRef" :class="[
|
||||
props.className,
|
||||
'bg-[#222] text-white rounded-[15px] border-2 border-white w-[300px] max-w-full relative overflow-hidden'
|
||||
]" :style="props.style" @mouseenter="handleMouseEnter" @mouseleave="handleMouseLeave" @click="handleClick">
|
||||
<div :style="{ paddingTop: props.aspectRatio }" />
|
||||
<div class="absolute inset-0 w-full h-full">
|
||||
<slot name="firstContent" />
|
||||
</div>
|
||||
<div ref="activeRef" class="absolute inset-0 w-full h-full z-[2]" style="display: none;">
|
||||
<slot name="secondContent" />
|
||||
</div>
|
||||
<div ref="pixelGridRef" class="absolute inset-0 w-full h-full pointer-events-none z-[3]" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pixelated-image-card__pixel {
|
||||
transition: none;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user