{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"image-density-grid","title":"Image Density Grid","author":"CoDesign-Spa27","description":"A percentage-controlled image grid with animated density changes, soft focus, and optional controls.","dependencies":["motion"],"files":[{"path":"components/ui-components/image-density-grid.tsx","content":"\"use client\";\n\n/* eslint-disable @next/next/no-img-element */\n\nimport {\n  motion,\n  useMotionTemplate,\n  useMotionValue,\n  useReducedMotion,\n  useSpring,\n  useTransform,\n  useVelocity,\n} from \"motion/react\";\nimport {\n  useCallback,\n  useEffect,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\n\ntype DensityPercent = 20 | 40 | 80 | 120;\n\ntype DensityImage = {\n  id: string;\n  src: string;\n  alt: string;\n};\n\ntype ImageDensityGridProps = {\n  images?: DensityImage[];\n  options?: DensityPercent[];\n  initialPercent?: DensityPercent;\n  className?: string;\n\n  /**\n   * Show or hide the density controls.\n   */\n  showControls?: boolean;\n\n  /**\n   * Optional heading above the controls.\n   */\n  title?: string;\n\n  /**\n   * Limits the height of the image area.\n   * Leave undefined to allow the grid to grow naturally.\n   *\n   * Example:\n   * gridMaxHeight=\"42rem\"\n   */\n  gridMaxHeight?: string;\n\n  /**\n   * Adds internal padding around the component.\n   */\n  padded?: boolean;\n};\n\nconst DEFAULT_OPTIONS: DensityPercent[] = [20, 40, 80, 120];\n\nconst DEFAULT_IMAGES: DensityImage[] = Array.from(\n  { length: 40 },\n  (_, index) => {\n    const number = String(index + 1).padStart(2, \"0\");\n\n    return {\n      id: `frame-${number}`,\n      src: `https://picsum.photos/seed/image-density-grid-${number}/520/640`,\n      alt: `Random editorial image ${number}`,\n    };\n  },\n);\n\nconst tileEase = [0.9, 0.03, 0.69, 0.22] as const;\nconst overlayEase = [0.17, 0.84, 0.44, 1] as const;\n\nconst tileTransition = {\n  type: \"tween\" as const,\n  duration: 0.62,\n  ease: tileEase,\n};\n\nconst overlayTransition = {\n  duration: 0.3,\n  ease: overlayEase,\n};\n\nconst transitionMs = 620;\nconst layoutStaggerDelay = 0.012;\n\nconst densityLayout: Record<\n  DensityPercent,\n  {\n    desktopColumns: number;\n    tabletColumns: number;\n    mobileColumns: number;\n    gap: string;\n  }\n> = {\n  20: {\n    desktopColumns: 4,\n    tabletColumns: 3,\n    mobileColumns: 2,\n    gap: \"clamp(0.75rem, 1.5vw, 1.25rem)\",\n  },\n  40: {\n    desktopColumns: 5,\n    tabletColumns: 4,\n    mobileColumns: 2,\n    gap: \"clamp(0.625rem, 1.25vw, 1rem)\",\n  },\n  80: {\n    desktopColumns: 8,\n    tabletColumns: 5,\n    mobileColumns: 3,\n    gap: \"clamp(0.375rem, 0.9vw, 0.625rem)\",\n  },\n  120: {\n    desktopColumns: 10,\n    tabletColumns: 6,\n    mobileColumns: 4,\n    gap: \"clamp(0.25rem, 0.65vw, 0.375rem)\",\n  },\n};\n\nfunction cn(...classes: Array<string | false | null | undefined>) {\n  return classes.filter(Boolean).join(\" \");\n}\n\nfunction getTileTransition(\n  index: number,\n  shouldReduceMotion: boolean | null,\n) {\n  if (shouldReduceMotion) {\n    return {\n      duration: 0,\n    };\n  }\n\n  const delay = index * layoutStaggerDelay;\n\n  return {\n    layout: {\n      ...tileTransition,\n      delay,\n    },\n  };\n}\n\nexport function ImageDensityGrid({\n  images = DEFAULT_IMAGES,\n  options = DEFAULT_OPTIONS,\n  initialPercent = 120,\n  className,\n  showControls = true,\n  title = \"Image density\",\n  gridMaxHeight,\n  padded = true,\n}: ImageDensityGridProps) {\n  const shouldReduceMotion = useReducedMotion();\n\n  const safeImages = images.length > 0 ? images : DEFAULT_IMAGES;\n  const safeOptions = options.length > 0 ? options : DEFAULT_OPTIONS;\n\n  const fallbackPercent = safeOptions[0] ?? 40;\n\n  const [activePercent, setActivePercent] = useState<DensityPercent>(\n    safeOptions.includes(initialPercent) ? initialPercent : fallbackPercent,\n  );\n\n  const [isRearranging, setIsRearranging] = useState(false);\n  const [containerWidth, setContainerWidth] = useState(0);\n\n  const rootRef = useRef<HTMLDivElement | null>(null);\n  const controlsRef = useRef<HTMLDivElement | null>(null);\n\n  const optionRefs = useRef<\n    Partial<Record<DensityPercent, HTMLButtonElement | null>>\n  >({});\n\n  const targetX = useMotionValue(0);\n  const targetWidth = useMotionValue(0);\n\n  const x = useSpring(targetX, {\n    stiffness: 420,\n    damping: 34,\n    mass: 0.8,\n  });\n\n  const width = useSpring(targetWidth, {\n    stiffness: 420,\n    damping: 34,\n    mass: 0.8,\n  });\n\n  const velocity = useVelocity(x);\n\n  const scaleX = useTransform(\n    velocity,\n    [-1200, -250, 0, 250, 1200],\n    [1.35, 1.12, 1, 1.12, 1.35],\n  );\n\n  const blur = useTransform(\n    velocity,\n    [-1200, -250, 0, 250, 1200],\n    [2.5, 0.8, 0, 0.8, 2.5],\n  );\n\n  const filter = useMotionTemplate`blur(${blur}px)`;\n\n  const activeLayout = densityLayout[activePercent];\n\n  const columnCount = useMemo(() => {\n    let columns = activeLayout.desktopColumns;\n\n    if (containerWidth > 0 && containerWidth < 640) {\n      columns = activeLayout.mobileColumns;\n    } else if (containerWidth > 0 && containerWidth < 1024) {\n      columns = activeLayout.tabletColumns;\n    }\n\n    return Math.max(1, Math.min(columns, safeImages.length));\n  }, [activeLayout, containerWidth, safeImages.length]);\n\n  const updateActiveIndicator = useCallback(() => {\n    const controls = controlsRef.current;\n    const activeButton = optionRefs.current[activePercent];\n\n    if (!controls || !activeButton) {\n      return;\n    }\n\n    const controlsRect = controls.getBoundingClientRect();\n    const activeRect = activeButton.getBoundingClientRect();\n\n    targetX.set(activeRect.left - controlsRect.left);\n    targetWidth.set(activeRect.width);\n  }, [activePercent, targetWidth, targetX]);\n\n  useLayoutEffect(() => {\n    updateActiveIndicator();\n  }, [updateActiveIndicator, safeOptions]);\n\n  useEffect(() => {\n    const root = rootRef.current;\n\n    if (!root) {\n      return;\n    }\n\n    const resizeObserver = new ResizeObserver(([entry]) => {\n      setContainerWidth(entry.contentRect.width);\n      requestAnimationFrame(updateActiveIndicator);\n    });\n\n    resizeObserver.observe(root);\n\n    return () => resizeObserver.disconnect();\n  }, [updateActiveIndicator]);\n\n  useEffect(() => {\n    if (safeOptions.includes(activePercent)) {\n      return;\n    }\n\n    setActivePercent(fallbackPercent);\n  }, [activePercent, fallbackPercent, safeOptions]);\n\n  useEffect(() => {\n    if (!isRearranging) {\n      return;\n    }\n\n    const staggerSpanMs =\n      Math.max(0, safeImages.length - 1) *\n      layoutStaggerDelay *\n      1000;\n\n    const timeout = window.setTimeout(\n      () => setIsRearranging(false),\n      shouldReduceMotion ? 0 : transitionMs + staggerSpanMs,\n    );\n\n    return () => window.clearTimeout(timeout);\n  }, [isRearranging, safeImages.length, shouldReduceMotion]);\n\n  function selectPercent(percent: DensityPercent) {\n    if (percent === activePercent) {\n      return;\n    }\n\n    setActivePercent(percent);\n\n    if (!shouldReduceMotion) {\n      setIsRearranging(true);\n    }\n  }\n\n  return (\n    <section\n      ref={rootRef}\n      className={cn(\n        \"relative w-full min-w-0 overflow-hidden\",\n        padded && \"p-4 sm:p-5 md:p-6\",\n        className,\n      )}\n    >\n      <div className=\"flex min-w-0 flex-col gap-5 md:gap-6\">\n        {showControls && (\n          <header className=\"flex min-w-0 flex-col gap-3 sm:flex-row sm:items-center sm:justify-between\">\n            <div\n              ref={controlsRef}\n              aria-label=\"Image grid density\"\n              className=\"relative grid w-full grid-cols-4 gap-1 rounded-lg border bg-muted/40 p-1 sm:w-auto\"\n              role=\"group\"\n            >\n              <motion.span\n                aria-hidden=\"true\"\n                className=\"pointer-events-none absolute bottom-1 left-0 top-1 rounded-md text-background bg-foreground shadow-sm\"\n                style={{\n                  x,\n                  width,\n                  scaleX: shouldReduceMotion ? 1 : scaleX,\n                  filter: shouldReduceMotion ? \"none\" : filter,\n                }}\n              />\n\n              {safeOptions.map((percent) => {\n                const isActive = percent === activePercent;\n\n                return (\n                  <button\n                    key={percent}\n                    ref={(node) => {\n                      optionRefs.current[percent] = node;\n                    }}\n                    aria-pressed={isActive}\n                    className={cn(\n                      \"relative z-10 min-w-0 whitespace-nowrap rounded-md px-2.5 py-1.5 text-xs font-medium outline-none\",\n                      \"transition-[color,transform] duration-150\",\n                      \"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1\",\n                      \"active:scale-[0.97]\",\n                      isActive\n                        ? \"text-background\"\n                        : \"hover:text-foreground\",\n                    )}\n                    onClick={() => selectPercent(percent)}\n                    type=\"button\"\n                  >\n                    {percent}%\n                  </button>\n                );\n              })}\n            </div>\n          </header>\n        )}\n\n        <div\n          className={cn(\n            \"min-h-0 min-w-0\",\n            gridMaxHeight && \"overflow-y-auto overscroll-contain\",\n          )}\n          style={{\n            maxHeight: gridMaxHeight,\n          }}\n        >\n          <motion.div\n            layout\n            className=\"grid min-w-0 items-start\"\n            style={{\n              gap: activeLayout.gap,\n              gridTemplateColumns: `repeat(${columnCount}, minmax(0, 1fr))`,\n            }}\n            transition={\n              shouldReduceMotion\n                ? {\n                  duration: 0,\n                }\n                : tileTransition\n            }\n          >\n            {safeImages.map((image, index) => (\n              <motion.figure\n                key={image.id}\n                layout\n                className=\"relative min-w-0\"\n                transition={getTileTransition(index, shouldReduceMotion)}\n              >\n                <motion.div\n                  animate={{\n                    filter:\n                      isRearranging && !shouldReduceMotion\n                        ? \"blur(3px) brightness(1.08) saturate(2.35)\"\n                        : \"blur(0px) brightness(1) saturate(1)\",\n                    scale:\n                      isRearranging && !shouldReduceMotion\n                        ? 0.985\n                        : 1,\n                  }}\n                  className=\"relative min-w-0 origin-center\"\n                  transition={overlayTransition}\n                >\n                \n\n                  <div className=\"relative overflow-hidden rounded-lg border bg-muted\">\n                    <img\n                      alt={image.alt}\n                      className=\"aspect-[4/5] h-auto w-full select-none object-cover\"\n                      decoding=\"async\"\n                      draggable={false}\n                      height={640}\n                      loading={index < 8 ? \"eager\" : \"lazy\"}\n                      src={image.src}\n                      width={520}\n                    />\n                  </div>\n                </motion.div>\n              </motion.figure>\n            ))}\n          </motion.div>\n        </div>\n      </div>\n    </section>\n  );\n}\n\nexport default ImageDensityGrid;","type":"registry:ui"}],"categories":["gallery","layout"],"type":"registry:ui"}