mirror of
https://github.com/DavidHDev/vue-bits.git
synced 2026-03-07 06:29:30 -07:00
1 line
7.7 KiB
JSON
1 line
7.7 KiB
JSON
{"name":"Masonry","title":"Masonry","description":"Responsive masonry layout with animated reflow + gaps optimization.","type":"registry:component","add":"when-added","files":[{"type":"registry:component","role":"file","content":"<template>\n <div ref=\"containerRef\" class=\"relative w-full h-full\">\n <div\n v-for=\"item in grid\"\n :key=\"item.id\"\n :data-key=\"item.id\"\n class=\"absolute box-content\"\n :style=\"{ willChange: 'transform, width, height, opacity' }\"\n @click=\"openUrl(item.url)\"\n @mouseenter=\"e => handleMouseEnter(item.id, e.currentTarget as HTMLElement)\"\n @mouseleave=\"e => handleMouseLeave(item.id, e.currentTarget as HTMLElement)\"\n >\n <div\n class=\"relative w-full h-full bg-cover bg-center rounded-[10px] shadow-[0px_10px_50px_-10px_rgba(0,0,0,0.2)] uppercase text-[10px] leading-[10px]\"\n :style=\"{ backgroundImage: `url(${item.img})` }\"\n >\n <div\n v-if=\"colorShiftOnHover\"\n class=\"color-overlay absolute inset-0 rounded-[10px] bg-gradient-to-tr from-pink-500/50 to-sky-500/50 opacity-0 pointer-events-none\"\n />\n </div>\n </div>\n </div>\n</template>\n\n<script setup lang=\"ts\">\nimport { ref, computed, onMounted, onUnmounted, watchEffect, nextTick, useTemplateRef } from 'vue';\nimport { gsap } from 'gsap';\n\ninterface Item {\n id: string;\n img: string;\n url: string;\n height: number;\n}\n\ninterface MasonryProps {\n items: Item[];\n ease?: string;\n duration?: number;\n stagger?: number;\n animateFrom?: 'bottom' | 'top' | 'left' | 'right' | 'center' | 'random';\n scaleOnHover?: boolean;\n hoverScale?: number;\n blurToFocus?: boolean;\n colorShiftOnHover?: boolean;\n}\n\nconst props = withDefaults(defineProps<MasonryProps>(), {\n ease: 'power3.out',\n duration: 0.6,\n stagger: 0.05,\n animateFrom: 'bottom',\n scaleOnHover: true,\n hoverScale: 0.95,\n blurToFocus: true,\n colorShiftOnHover: false\n});\n\nconst useMedia = (queries: string[], values: number[], defaultValue: number) => {\n const get = () => values[queries.findIndex(q => matchMedia(q).matches)] ?? defaultValue;\n const value = ref<number>(get());\n\n onMounted(() => {\n const handler = () => (value.value = get());\n queries.forEach(q => matchMedia(q).addEventListener('change', handler));\n\n onUnmounted(() => {\n queries.forEach(q => matchMedia(q).removeEventListener('change', handler));\n });\n });\n\n return value;\n};\n\nconst useMeasure = () => {\n const containerRef = useTemplateRef<HTMLDivElement>('containerRef');\n const size = ref({ width: 0, height: 0 });\n let resizeObserver: ResizeObserver | null = null;\n\n onMounted(() => {\n if (!containerRef.value) return;\n\n resizeObserver = new ResizeObserver(([entry]) => {\n const { width, height } = entry.contentRect;\n size.value = { width, height };\n });\n\n resizeObserver.observe(containerRef.value);\n });\n\n onUnmounted(() => {\n if (resizeObserver) {\n resizeObserver.disconnect();\n }\n });\n\n return [containerRef, size] as const;\n};\n\nconst preloadImages = async (urls: string[]): Promise<void> => {\n await Promise.all(\n urls.map(\n src =>\n new Promise<void>(resolve => {\n const img = new Image();\n img.src = src;\n img.onload = img.onerror = () => resolve();\n })\n )\n );\n};\n\nconst columns = useMedia(\n ['(min-width:1500px)', '(min-width:1000px)', '(min-width:600px)', '(min-width:400px)'],\n [5, 4, 3, 2],\n 1\n);\n\nconst [containerRef, size] = useMeasure();\nconst imagesReady = ref(false);\nconst hasMounted = ref(false);\n\nconst grid = computed(() => {\n if (!size.value.width) return [];\n const colHeights = new Array(columns.value).fill(0);\n const gap = 16;\n const totalGaps = (columns.value - 1) * gap;\n const columnWidth = (size.value.width - totalGaps) / columns.value;\n\n return props.items.map(child => {\n const col = colHeights.indexOf(Math.min(...colHeights));\n const x = col * (columnWidth + gap);\n const height = child.height / 2;\n const y = colHeights[col];\n\n colHeights[col] += height + gap;\n return { ...child, x, y, w: columnWidth, h: height };\n });\n});\n\nconst openUrl = (url: string) => {\n window.open(url, '_blank', 'noopener');\n};\n\ninterface GridItem extends Item {\n x: number;\n y: number;\n w: number;\n h: number;\n}\n\nconst getInitialPosition = (item: GridItem) => {\n const containerRect = containerRef.value?.getBoundingClientRect();\n if (!containerRect) return { x: item.x, y: item.y };\n\n let direction = props.animateFrom;\n if (props.animateFrom === 'random') {\n const dirs = ['top', 'bottom', 'left', 'right'];\n direction = dirs[Math.floor(Math.random() * dirs.length)] as typeof props.animateFrom;\n }\n\n switch (direction) {\n case 'top':\n return { x: item.x, y: -200 };\n case 'bottom':\n return { x: item.x, y: window.innerHeight + 200 };\n case 'left':\n return { x: -200, y: item.y };\n case 'right':\n return { x: window.innerWidth + 200, y: item.y };\n case 'center':\n return {\n x: containerRect.width / 2 - item.w / 2,\n y: containerRect.height / 2 - item.h / 2\n };\n default:\n return { x: item.x, y: item.y + 100 };\n }\n};\n\nconst handleMouseEnter = (id: string, element: HTMLElement) => {\n if (props.scaleOnHover) {\n gsap.to(`[data-key=\"${id}\"]`, {\n scale: props.hoverScale,\n duration: 0.3,\n ease: 'power2.out'\n });\n }\n if (props.colorShiftOnHover) {\n const overlay = element.querySelector('.color-overlay') as HTMLElement;\n if (overlay) gsap.to(overlay, { opacity: 0.3, duration: 0.3 });\n }\n};\n\nconst handleMouseLeave = (id: string, element: HTMLElement) => {\n if (props.scaleOnHover) {\n gsap.to(`[data-key=\"${id}\"]`, {\n scale: 1,\n duration: 0.3,\n ease: 'power2.out'\n });\n }\n if (props.colorShiftOnHover) {\n const overlay = element.querySelector('.color-overlay') as HTMLElement;\n if (overlay) gsap.to(overlay, { opacity: 0, duration: 0.3 });\n }\n};\n\nwatchEffect(() => {\n preloadImages(props.items.map(i => i.img)).then(() => {\n imagesReady.value = true;\n });\n});\n\nwatchEffect(() => {\n if (!imagesReady.value) return;\n\n const currentGrid = grid.value;\n void props.items.length;\n void columns.value;\n void size.value.width;\n\n if (!currentGrid.length) return;\n\n nextTick(() => {\n currentGrid.forEach((item, index) => {\n const selector = `[data-key=\"${item.id}\"]`;\n const animProps = { x: item.x, y: item.y, width: item.w, height: item.h };\n\n if (!hasMounted.value) {\n const start = getInitialPosition(item);\n gsap.fromTo(\n selector,\n {\n opacity: 0,\n x: start.x,\n y: start.y,\n width: item.w,\n height: item.h,\n ...(props.blurToFocus && { filter: 'blur(10px)' })\n },\n {\n opacity: 1,\n ...animProps,\n ...(props.blurToFocus && { filter: 'blur(0px)' }),\n duration: 0.8,\n ease: 'power3.out',\n delay: index * props.stagger\n }\n );\n } else {\n gsap.to(selector, {\n ...animProps,\n duration: props.duration,\n ease: props.ease,\n overwrite: 'auto'\n });\n }\n });\n\n hasMounted.value = true;\n });\n});\n</script>\n","path":"Masonry/Masonry.vue","_imports_":[],"registryDependencies":[],"dependencies":[],"devDependencies":[]}],"registryDependencies":[],"dependencies":[{"ecosystem":"js","name":"gsap","version":"^3.13.0"}],"devDependencies":[],"categories":["Components"]} |