{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "masonry", "title": "Masonry", "description": "An animated masonry grid that lays out image items by explicit height, with configurable entry and hover animations.", "dependencies": [ "framer-motion" ], "registryDependencies": [], "files": [ { "path": "registry/spark-ui/masonry.tsx", "content": "\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport { motion, type Easing } from \"framer-motion\";\nimport * as React from \"react\";\n\nexport interface MasonryItem {\n id: string;\n img: string;\n url?: string;\n height: number;\n}\n\nexport type GsapEaseName = keyof typeof GSAP_EASE_MAP;\n\nexport interface MasonryProps {\n items: T[];\n className?: string;\n ease?: Easing | GsapEaseName;\n duration?: number;\n stagger?: number;\n animateFrom?: \"top\" | \"bottom\" | \"left\" | \"right\" | \"center\" | \"random\";\n scaleOnHover?: boolean;\n hoverScale?: number;\n blurToFocus?: boolean;\n colorShiftOnHover?: boolean;\n /** Overrides the default image tile. Use for richer card content while keeping the same layout, entry and hover animations. */\n renderItem?: (item: T, index: number) => React.ReactNode;\n}\n\nconst GAP = 16;\nconst BREAKPOINTS: [width: number, columns: number][] = [\n [1400, 4],\n [900, 3],\n [500, 2],\n [0, 1],\n];\n\n// gsap-style ease names (as used by the reactbits-style API) mapped to their closest framer-motion equivalent\nconst GSAP_EASE_MAP: Record = {\n \"power1.out\": \"easeOut\",\n \"power2.out\": \"easeOut\",\n \"power3.out\": \"easeOut\",\n \"power4.out\": \"easeOut\",\n \"power1.in\": \"easeIn\",\n \"power2.in\": \"easeIn\",\n \"power3.in\": \"easeIn\",\n \"power1.inOut\": \"easeInOut\",\n \"power2.inOut\": \"easeInOut\",\n \"power3.inOut\": \"easeInOut\",\n \"back.out\": \"backOut\",\n \"back.in\": \"backIn\",\n \"circ.out\": \"circOut\",\n \"circ.in\": \"circIn\",\n};\n\nfunction resolveEase(ease: Easing | GsapEaseName | undefined): Easing {\n if (!ease) return \"easeOut\";\n if (typeof ease === \"string\" && ease in GSAP_EASE_MAP) {\n return GSAP_EASE_MAP[ease as GsapEaseName];\n }\n return ease as Easing;\n}\n\nfunction getEntryOffset(\n from: NonNullable,\n x: number,\n y: number,\n containerWidth: number,\n containerHeight: number,\n): { x: number; y: number } {\n switch (from) {\n case \"top\":\n return { x: 0, y: -y - 200 };\n case \"bottom\":\n return { x: 0, y: containerHeight - y + 200 };\n case \"left\":\n return { x: -x - 200, y: 0 };\n case \"right\":\n return { x: containerWidth - x + 200, y: 0 };\n case \"random\": {\n const dirs = [\"top\", \"bottom\", \"left\", \"right\"] as const;\n return getEntryOffset(dirs[Math.floor(Math.random() * dirs.length)], x, y, containerWidth, containerHeight);\n }\n default:\n return { x: 0, y: 0 };\n }\n}\n\nexport function Masonry({\n items,\n className,\n ease,\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 renderItem,\n}: MasonryProps) {\n const containerRef = React.useRef(null);\n const [width, setWidth] = React.useState(0);\n const resolvedEase = resolveEase(ease);\n\n React.useEffect(() => {\n const el = containerRef.current;\n if (!el) return;\n setWidth(el.offsetWidth);\n const observer = new ResizeObserver(([entry]) => setWidth(entry.contentRect.width));\n observer.observe(el);\n return () => observer.disconnect();\n }, []);\n\n const { boxes, containerHeight } = React.useMemo(() => {\n if (!width) return { boxes: [], containerHeight: 0 };\n const columns = BREAKPOINTS.find(([breakpoint]) => width >= breakpoint)?.[1] ?? 1;\n const columnWidth = (width - GAP * (columns - 1)) / columns;\n const columnHeights = new Array(columns).fill(0) as number[];\n\n const placed = items.map((item) => {\n const column = columnHeights.indexOf(Math.min(...columnHeights));\n const x = column * (columnWidth + GAP);\n const y = columnHeights[column];\n columnHeights[column] += item.height + GAP;\n return { ...item, x, y, width: columnWidth };\n });\n\n return { boxes: placed, containerHeight: Math.max(0, Math.max(...columnHeights) - GAP) };\n }, [items, width]);\n\n return (\n \n {boxes.map((box, index) => {\n const offset = getEntryOffset(animateFrom, box.x, box.y, width, containerHeight);\n return (\n \n {renderItem ? (\n renderItem(box, index)\n ) : (\n \"\"\n )}\n {colorShiftOnHover && (\n
\n )}\n \n );\n })}\n
\n );\n}\n\nexport default Masonry;\n", "type": "registry:component" } ], "type": "registry:component" }