Component Boom

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

View File

@@ -0,0 +1,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>

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

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

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

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

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

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