{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"social-posts-image-grid","type":"registry:component","title":"Social posts · Photo grid","description":"Photo post whose drawn grid tiles expand into a viewer with a shared-layout morph, arrow paging and Escape to close.","author":"GetUrUI <hello@geturui.dev>","dependencies":["motion","lucide-react"],"registryDependencies":[],"files":[{"path":"registry/geturui/examples/social-posts/image-grid.tsx","type":"registry:component","target":"components/examples/social-posts/image-grid.tsx","content":"\"use client\";\n\nimport * as React from \"react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { ChevronLeft, ChevronRight, Heart, MessageCircle, X } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\n\nexport type SceneKind = \"dunes\" | \"harbour\" | \"ridge\" | \"market\";\n\nexport interface PostImage {\n  scene: SceneKind;\n  alt: string;\n}\n\nexport interface ImageGridPostProps {\n  author?: string;\n  place?: string;\n  time?: string;\n  caption?: string;\n  images?: PostImage[];\n  likes?: number;\n  comments?: number;\n  onLikeChange?: (liked: boolean) => void;\n  className?: string;\n}\n\nconst DEFAULT_IMAGES: PostImage[] = [\n  { scene: \"dunes\", alt: \"Sand dunes at first light under a pale sun\" },\n  { scene: \"harbour\", alt: \"Fishing boats in a harbour wrapped in fog\" },\n  { scene: \"ridge\", alt: \"Pine ridge in layers of blue haze\" },\n  { scene: \"market\", alt: \"Night market lanterns strung over a street\" },\n];\n\nfunction Scene({ kind }: { kind: SceneKind }) {\n  return (\n    <svg viewBox=\"0 0 160 120\" preserveAspectRatio=\"xMidYMid slice\" className=\"size-full\" aria-hidden>\n      {kind === \"dunes\" && (\n        <>\n          <rect width=\"160\" height=\"120\" fill=\"#f1d9b5\" />\n          <circle cx=\"112\" cy=\"40\" r=\"16\" fill=\"#fff4dd\" />\n          <path d=\"M0 78 Q40 58 84 76 T160 70 V120 H0 Z\" fill=\"#d9a46a\" />\n          <path d=\"M0 96 Q50 80 100 98 T160 92 V120 H0 Z\" fill=\"#b97a45\" />\n        </>\n      )}\n      {kind === \"harbour\" && (\n        <>\n          <rect width=\"160\" height=\"120\" fill=\"#c9d3d8\" />\n          <rect y=\"74\" width=\"160\" height=\"46\" fill=\"#8fa3ad\" />\n          <path d=\"M30 74 L40 52 L40 74 Z M90 74 L102 44 L102 74 Z\" fill=\"#4c5f69\" />\n          <path d=\"M22 74 H54 L50 82 H26 Z M82 74 H118 L113 83 H87 Z\" fill=\"#33434b\" />\n          <rect y=\"60\" width=\"160\" height=\"10\" fill=\"#ffffff\" opacity=\"0.35\" />\n        </>\n      )}\n      {kind === \"ridge\" && (\n        <>\n          <rect width=\"160\" height=\"120\" fill=\"#dfe8ee\" />\n          <path d=\"M0 70 L30 44 L56 62 L88 34 L124 60 L160 40 V120 H0 Z\" fill=\"#9db3c4\" />\n          <path d=\"M0 88 L24 70 L52 84 L80 64 L112 86 L140 70 L160 80 V120 H0 Z\" fill=\"#5d7a8f\" />\n          <path d=\"M0 104 L36 90 L70 104 L110 92 L160 102 V120 H0 Z\" fill=\"#2f4656\" />\n        </>\n      )}\n      {kind === \"market\" && (\n        <>\n          <rect width=\"160\" height=\"120\" fill=\"#1f1d2b\" />\n          <path d=\"M0 26 Q80 50 160 22\" fill=\"none\" stroke=\"#3a3650\" strokeWidth=\"1.5\" />\n          {[16, 40, 64, 88, 112, 136].map((x, i) => (\n            <circle key={x} cx={x} cy={30 + Math.sin(i) * 3 + (i === 2 || i === 3 ? 9 : 4)} r=\"5\" fill={i % 2 ? \"#ffb35c\" : \"#ff7a45\"} />\n          ))}\n          <rect x=\"10\" y=\"78\" width=\"44\" height=\"42\" fill=\"#2c2940\" />\n          <rect x=\"104\" y=\"72\" width=\"48\" height=\"48\" fill=\"#2c2940\" />\n          <rect x=\"16\" y=\"84\" width=\"32\" height=\"8\" fill=\"#ffb35c\" opacity=\"0.6\" />\n        </>\n      )}\n    </svg>\n  );\n}\n\n/** Photo post with a drawn image grid: a tile expands into a viewer with a shared-layout morph, arrow-key paging and Escape to close. */\nexport function ImageGridPost({\n  author = \"Lena Ortiz\",\n  place = \"Field notes, week 38\",\n  time = \"5h\",\n  caption = \"Four mornings, four places, one camera. The fog in the harbour lifted about ten minutes after this.\",\n  images = DEFAULT_IMAGES,\n  likes = 326,\n  comments = 18,\n  onLikeChange,\n  className,\n}: ImageGridPostProps) {\n  const reduce = useReducedMotion() ?? false;\n  const [view, setView] = React.useState<{ origin: number; index: number } | null>(null);\n  const open = view?.index ?? null;\n  const [liked, setLiked] = React.useState(false);\n  const tiles = React.useRef<(HTMLButtonElement | null)[]>([]);\n  const closeBtn = React.useRef<HTMLButtonElement>(null);\n  const id = React.useId();\n  const shown = images.slice(0, 4);\n\n  const isOpen = view !== null;\n  React.useEffect(() => {\n    if (isOpen) closeBtn.current?.focus();\n  }, [isOpen]);\n\n  function close() {\n    const i = view?.index;\n    setView(null);\n    if (i !== undefined) requestAnimationFrame(() => tiles.current[i]?.focus());\n  }\n\n  const page = (d: number) => setView((v) => (v ? { ...v, index: (v.index + d + shown.length) % shown.length } : v));\n  const navBtn = \"grid size-9 place-items-center rounded-full bg-glass text-ink shadow-sm outline-none backdrop-blur focus-visible:shadow-[var(--focus-ring)]\";\n  const layout = (i: number) => (reduce ? undefined : `${id}-img-${i}`);\n\n  return (\n    <article aria-label={`Photos from ${author}`} className={cn(\"w-full max-w-[520px] rounded-xl border border-border bg-surface-raised p-4 shadow-sm\", className)}>\n      <header className=\"flex items-center gap-3\">\n        <span aria-hidden className=\"grid size-10 place-items-center rounded-full bg-ink font-display text-[13px] font-semibold text-bg\">\n          {author.split(\" \").map((p) => p[0]).join(\"\")}\n        </span>\n        <div className=\"min-w-0\">\n          <p className=\"text-[14px] font-semibold text-ink\">{author}</p>\n          <p className=\"text-[12.5px] text-ink-subtle\">\n            {place} · {time}\n          </p>\n        </div>\n      </header>\n      <p className=\"mt-3 text-[14px] leading-[1.5] text-ink\">{caption}</p>\n\n      <div\n        className=\"relative mt-3 aspect-[4/3] overflow-hidden rounded-lg\"\n        onKeyDown={(e) => {\n          if (open === null) return;\n          if (e.key === \"Escape\") close();\n          if (e.key === \"ArrowRight\") page(1);\n          if (e.key === \"ArrowLeft\") page(-1);\n        }}\n      >\n        <div className={cn(\"grid size-full gap-1\", shown.length > 1 && \"grid-cols-2\", shown.length > 2 && \"grid-rows-2\")}>\n          {shown.map((img, i) => (\n            <button\n              key={i}\n              ref={(el) => {\n                tiles.current[i] = el;\n              }}\n              type=\"button\"\n              aria-label={`Open photo ${i + 1} of ${shown.length}: ${img.alt}`}\n              onClick={() => setView({ origin: i, index: i })}\n              className={cn(\"group relative overflow-hidden outline-none focus-visible:shadow-[inset_0_0_0_3px_var(--volt-ink)]\", shown.length === 3 && i === 0 && \"row-span-2\")}\n            >\n              {view?.origin !== i && (\n                <motion.div layoutId={layout(i)} transition={{ type: \"spring\", stiffness: 300, damping: 32 }} className=\"absolute inset-0 transition-transform duration-500 group-hover:scale-105\">\n                  <Scene kind={img.scene} />\n                </motion.div>\n              )}\n            </button>\n          ))}\n        </div>\n\n        <AnimatePresence>\n          {view !== null && open !== null && (\n            <motion.div key=\"viewer\" role=\"dialog\" aria-label={shown[open].alt} exit={view.origin === open ? undefined : { opacity: 0 }} className=\"absolute inset-0 z-10\">\n              <motion.div layoutId={view.origin === open ? layout(view.origin) : undefined} transition={{ type: \"spring\", stiffness: 300, damping: 32 }} className=\"absolute inset-0\">\n                <AnimatePresence mode=\"popLayout\" initial={false}>\n                  <motion.div key={open} initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className=\"absolute inset-0\">\n                    <Scene kind={shown[open].scene} />\n                  </motion.div>\n                </AnimatePresence>\n              </motion.div>\n              <motion.div initial={{ opacity: 0 }} animate={{ opacity: 1, transition: { delay: 0.15 } }} exit={{ opacity: 0, transition: { duration: 0.1 } }} className=\"absolute inset-0\">\n                <button ref={closeBtn} type=\"button\" aria-label=\"Close photo\" onClick={close} className={cn(navBtn, \"absolute right-3 top-3\")}>\n                  <X aria-hidden className=\"size-4\" />\n                </button>\n                <button type=\"button\" aria-label=\"Previous photo\" onClick={() => page(-1)} className={cn(navBtn, \"absolute left-3 top-1/2 -translate-y-1/2\")}>\n                  <ChevronLeft aria-hidden className=\"size-4\" />\n                </button>\n                <button type=\"button\" aria-label=\"Next photo\" onClick={() => page(1)} className={cn(navBtn, \"absolute right-3 top-1/2 -translate-y-1/2\")}>\n                  <ChevronRight aria-hidden className=\"size-4\" />\n                </button>\n                <p aria-live=\"polite\" className=\"absolute bottom-3 left-3 right-3 truncate rounded-full bg-glass px-3 py-1.5 text-[12px] text-ink backdrop-blur\">\n                  {open + 1} of {shown.length} · {shown[open].alt}\n                </p>\n              </motion.div>\n            </motion.div>\n          )}\n        </AnimatePresence>\n      </div>\n\n      <div className=\"-mx-2 mt-2 flex items-center gap-1\">\n        <motion.button\n          type=\"button\"\n          aria-pressed={liked}\n          aria-label={liked ? \"Unlike\" : \"Like\"}\n          onClick={() => {\n            setLiked(!liked);\n            onLikeChange?.(!liked);\n          }}\n          whileTap={reduce ? undefined : { scale: 0.85 }}\n          className={cn(\"inline-flex h-8 items-center gap-1.5 rounded-full px-2 text-[12.5px] outline-none transition-colors hover:bg-surface-hover focus-visible:shadow-[var(--focus-ring)]\", liked ? \"font-semibold text-volt-ink\" : \"text-ink-subtle\")}\n        >\n          <Heart aria-hidden className={cn(\"size-[18px]\", liked && \"fill-current\")} />\n          {(likes + (liked ? 1 : 0)).toLocaleString(\"en-US\")}\n        </motion.button>\n        <span className=\"inline-flex h-8 items-center gap-1.5 px-2 text-[12.5px] text-ink-subtle\">\n          <MessageCircle aria-hidden className=\"size-[18px]\" />\n          {comments}\n          <span className=\"sr-only\">comments</span>\n        </span>\n      </div>\n    </article>\n  );\n}\n"},{"path":"LICENSE-geturui.md","type":"registry:file","target":"LICENSE-geturui.md","content":"MIT License\n\nCopyright (c) 2026 geturui.com\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"}],"categories":["examples","social","post","feed","like","repost","poll","image grid","link preview","timeline"],"meta":{"tier":"free","license":"MIT","docs":"https://geturui.com/docs/social-posts#image-grid"}}