Files
vue-bits/src/content/Animations/Cubes/Cubes.vue
2025-07-12 11:59:33 +03:00

345 lines
9.9 KiB
Vue

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