mirror of
https://github.com/DavidHDev/vue-bits.git
synced 2026-03-09 08:29:30 -06:00
Component Boom
This commit is contained in:
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>
|
||||
Reference in New Issue
Block a user