mirror of
https://github.com/DavidHDev/vue-bits.git
synced 2026-03-07 22:49:31 -07:00
1 line
11 KiB
JSON
1 line
11 KiB
JSON
{"name":"Cubes","title":"Cubes","description":"3D rotating cube cluster. Supports auto-rotation or hover interaction.","type":"registry:component","add":"when-added","files":[{"type":"registry:component","role":"file","content":"<template>\n <div class=\"relative w-1/2 max-md:w-11/12 aspect-square\" :style=\"wrapperStyle\">\n <div ref=\"sceneRef\" class=\"grid w-full h-full\" :style=\"sceneStyle\">\n <template v-for=\"(_, r) in cells\" :key=\"`row-${r}`\">\n <div\n v-for=\"(__, c) in cells\"\n :key=\"`${r}-${c}`\"\n class=\"cube relative w-full h-full aspect-square [transform-style:preserve-3d]\"\n :data-row=\"r\"\n :data-col=\"c\"\n >\n <span class=\"absolute pointer-events-none -inset-9\" />\n\n <div\n class=\"cube-face absolute inset-0 flex items-center justify-center\"\n :style=\"{\n background: 'var(--cube-face-bg)',\n border: 'var(--cube-face-border)',\n boxShadow: 'var(--cube-face-shadow)',\n transform: 'translateY(-50%) rotateX(90deg)'\n }\"\n />\n\n <div\n class=\"cube-face absolute inset-0 flex items-center justify-center\"\n :style=\"{\n background: 'var(--cube-face-bg)',\n border: 'var(--cube-face-border)',\n boxShadow: 'var(--cube-face-shadow)',\n transform: 'translateY(50%) rotateX(-90deg)'\n }\"\n />\n\n <div\n class=\"cube-face absolute inset-0 flex items-center justify-center\"\n :style=\"{\n background: 'var(--cube-face-bg)',\n border: 'var(--cube-face-border)',\n boxShadow: 'var(--cube-face-shadow)',\n transform: 'translateX(-50%) rotateY(-90deg)'\n }\"\n />\n\n <div\n class=\"cube-face absolute inset-0 flex items-center justify-center\"\n :style=\"{\n background: 'var(--cube-face-bg)',\n border: 'var(--cube-face-border)',\n boxShadow: 'var(--cube-face-shadow)',\n transform: 'translateX(50%) rotateY(90deg)'\n }\"\n />\n\n <div\n class=\"cube-face absolute inset-0 flex items-center justify-center\"\n :style=\"{\n background: 'var(--cube-face-bg)',\n border: 'var(--cube-face-border)',\n boxShadow: 'var(--cube-face-shadow)',\n transform: 'rotateY(-90deg) translateX(50%) rotateY(90deg)'\n }\"\n />\n\n <div\n class=\"cube-face absolute inset-0 flex items-center justify-center\"\n :style=\"{\n background: 'var(--cube-face-bg)',\n border: 'var(--cube-face-border)',\n boxShadow: 'var(--cube-face-shadow)',\n transform: 'rotateY(90deg) translateX(-50%) rotateY(-90deg)'\n }\"\n />\n </div>\n </template>\n </div>\n </div>\n</template>\n\n<script setup lang=\"ts\">\nimport { ref, computed, onMounted, onUnmounted, withDefaults, useTemplateRef } from 'vue';\nimport gsap from 'gsap';\n\ninterface Gap {\n row: number;\n col: number;\n}\n\ninterface Duration {\n enter: number;\n leave: number;\n}\n\ninterface Props {\n gridSize?: number;\n cubeSize?: number;\n maxAngle?: number;\n radius?: number;\n easing?: gsap.EaseString;\n duration?: Duration;\n cellGap?: number | Gap;\n borderStyle?: string;\n faceColor?: string;\n shadow?: boolean | string;\n autoAnimate?: boolean;\n rippleOnClick?: boolean;\n rippleColor?: string;\n rippleSpeed?: number;\n}\n\nconst props = withDefaults(defineProps<Props>(), {\n gridSize: 10,\n maxAngle: 45,\n radius: 3,\n easing: 'power3.out',\n duration: () => ({ enter: 0.3, leave: 0.6 }),\n borderStyle: '1px solid #fff',\n faceColor: '#0b0b0b',\n shadow: false,\n autoAnimate: true,\n rippleOnClick: true,\n rippleColor: '#fff',\n rippleSpeed: 2\n});\n\nconst sceneRef = useTemplateRef<HTMLDivElement>('sceneRef');\nconst rafRef = ref<number | null>(null);\nconst idleTimerRef = ref<number | null>(null);\nconst userActiveRef = ref(false);\nconst simPosRef = ref<{ x: number; y: number }>({ x: 0, y: 0 });\nconst simTargetRef = ref<{ x: number; y: number }>({ x: 0, y: 0 });\nconst simRAFRef = ref<number | null>(null);\n\nconst colGap = computed(() => {\n return typeof props.cellGap === 'number'\n ? `${props.cellGap}px`\n : (props.cellGap as Gap)?.col !== undefined\n ? `${(props.cellGap as Gap).col}px`\n : '5%';\n});\n\nconst rowGap = computed(() => {\n return typeof props.cellGap === 'number'\n ? `${props.cellGap}px`\n : (props.cellGap as Gap)?.row !== undefined\n ? `${(props.cellGap as Gap).row}px`\n : '5%';\n});\n\nconst enterDur = computed(() => props.duration.enter);\nconst leaveDur = computed(() => props.duration.leave);\n\nconst cells = computed(() => Array.from({ length: props.gridSize }));\n\nconst sceneStyle = computed(() => ({\n gridTemplateColumns: props.cubeSize\n ? `repeat(${props.gridSize}, ${props.cubeSize}px)`\n : `repeat(${props.gridSize}, 1fr)`,\n gridTemplateRows: props.cubeSize\n ? `repeat(${props.gridSize}, ${props.cubeSize}px)`\n : `repeat(${props.gridSize}, 1fr)`,\n columnGap: colGap.value,\n rowGap: rowGap.value,\n perspective: '99999999px',\n gridAutoRows: '1fr'\n}));\n\nconst wrapperStyle = computed(() => ({\n '--cube-face-border': props.borderStyle,\n '--cube-face-bg': props.faceColor,\n '--cube-face-shadow': props.shadow === true ? '0 0 6px rgba(0,0,0,.5)' : props.shadow || 'none',\n ...(props.cubeSize\n ? {\n width: `${props.gridSize * props.cubeSize}px`,\n height: `${props.gridSize * props.cubeSize}px`\n }\n : {})\n}));\n\nconst tiltAt = (rowCenter: number, colCenter: number) => {\n if (!sceneRef.value) return;\n\n sceneRef.value.querySelectorAll<HTMLDivElement>('.cube').forEach(cube => {\n const r = +cube.dataset.row!;\n const c = +cube.dataset.col!;\n const dist = Math.hypot(r - rowCenter, c - colCenter);\n\n if (dist <= props.radius) {\n const pct = 1 - dist / props.radius;\n const angle = pct * props.maxAngle;\n gsap.to(cube, {\n duration: enterDur.value,\n ease: props.easing,\n overwrite: true,\n rotateX: -angle,\n rotateY: angle\n });\n } else {\n gsap.to(cube, {\n duration: leaveDur.value,\n ease: 'power3.out',\n overwrite: true,\n rotateX: 0,\n rotateY: 0\n });\n }\n });\n};\n\nconst onPointerMove = (e: PointerEvent) => {\n userActiveRef.value = true;\n if (idleTimerRef.value) clearTimeout(idleTimerRef.value);\n\n const rect = sceneRef.value!.getBoundingClientRect();\n const cellW = rect.width / props.gridSize;\n const cellH = rect.height / props.gridSize;\n const colCenter = (e.clientX - rect.left) / cellW;\n const rowCenter = (e.clientY - rect.top) / cellH;\n\n if (rafRef.value) cancelAnimationFrame(rafRef.value);\n rafRef.value = requestAnimationFrame(() => tiltAt(rowCenter, colCenter));\n\n idleTimerRef.value = setTimeout(() => {\n userActiveRef.value = false;\n }, 3000);\n};\n\nconst resetAll = () => {\n if (!sceneRef.value) return;\n sceneRef.value.querySelectorAll<HTMLDivElement>('.cube').forEach(cube =>\n gsap.to(cube, {\n duration: leaveDur.value,\n rotateX: 0,\n rotateY: 0,\n ease: 'power3.out'\n })\n );\n};\n\nconst onClick = (e: MouseEvent) => {\n if (!props.rippleOnClick || !sceneRef.value) return;\n\n const rect = sceneRef.value.getBoundingClientRect();\n const cellW = rect.width / props.gridSize;\n const cellH = rect.height / props.gridSize;\n const colHit = Math.floor((e.clientX - rect.left) / cellW);\n const rowHit = Math.floor((e.clientY - rect.top) / cellH);\n\n const baseRingDelay = 0.15;\n const baseAnimDur = 0.3;\n const baseHold = 0.6;\n\n const spreadDelay = baseRingDelay / props.rippleSpeed;\n const animDuration = baseAnimDur / props.rippleSpeed;\n const holdTime = baseHold / props.rippleSpeed;\n\n const rings: Record<number, HTMLDivElement[]> = {};\n sceneRef.value.querySelectorAll<HTMLDivElement>('.cube').forEach(cube => {\n const r = +cube.dataset.row!;\n const c = +cube.dataset.col!;\n const dist = Math.hypot(r - rowHit, c - colHit);\n const ring = Math.round(dist);\n if (!rings[ring]) rings[ring] = [];\n rings[ring].push(cube);\n });\n\n Object.keys(rings)\n .map(Number)\n .sort((a, b) => a - b)\n .forEach(ring => {\n const delay = ring * spreadDelay;\n const faces = rings[ring].flatMap(cube => Array.from(cube.querySelectorAll<HTMLElement>('.cube-face')));\n\n gsap.to(faces, {\n backgroundColor: props.rippleColor,\n duration: animDuration,\n delay,\n ease: 'power3.out'\n });\n gsap.to(faces, {\n backgroundColor: props.faceColor,\n duration: animDuration,\n delay: delay + animDuration + holdTime,\n ease: 'power3.out'\n });\n });\n};\n\nconst startAutoAnimation = () => {\n if (!props.autoAnimate || !sceneRef.value) return;\n\n simPosRef.value = {\n x: Math.random() * props.gridSize,\n y: Math.random() * props.gridSize\n };\n simTargetRef.value = {\n x: Math.random() * props.gridSize,\n y: Math.random() * props.gridSize\n };\n\n const speed = 0.02;\n const loop = () => {\n if (!userActiveRef.value) {\n const pos = simPosRef.value;\n const tgt = simTargetRef.value;\n pos.x += (tgt.x - pos.x) * speed;\n pos.y += (tgt.y - pos.y) * speed;\n tiltAt(pos.y, pos.x);\n\n if (Math.hypot(pos.x - tgt.x, pos.y - tgt.y) < 0.1) {\n simTargetRef.value = {\n x: Math.random() * props.gridSize,\n y: Math.random() * props.gridSize\n };\n }\n }\n simRAFRef.value = requestAnimationFrame(loop);\n };\n simRAFRef.value = requestAnimationFrame(loop);\n};\n\nonMounted(() => {\n const el = sceneRef.value;\n if (!el) return;\n\n el.addEventListener('pointermove', onPointerMove);\n el.addEventListener('pointerleave', resetAll);\n el.addEventListener('click', onClick);\n\n startAutoAnimation();\n});\n\nonUnmounted(() => {\n const el = sceneRef.value;\n if (el) {\n el.removeEventListener('pointermove', onPointerMove);\n el.removeEventListener('pointerleave', resetAll);\n el.removeEventListener('click', onClick);\n }\n\n if (rafRef.value !== null) cancelAnimationFrame(rafRef.value);\n if (idleTimerRef.value !== null) clearTimeout(idleTimerRef.value);\n if (simRAFRef.value !== null) cancelAnimationFrame(simRAFRef.value);\n});\n</script>\n","path":"Cubes/Cubes.vue","_imports_":[],"registryDependencies":[],"dependencies":[],"devDependencies":[]}],"registryDependencies":[],"dependencies":[{"ecosystem":"js","name":"gsap","version":"^3.13.0"}],"devDependencies":[],"categories":["Animations"]} |