{
  "name": "feature-gallery-01-default",
  "type": "registry:block",
  "tier": "free",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://www.craftui.space/r/craftui-base.json"
  ],
  "cssVars": {
    "light": {
      "gallery-scrim": "oklch(0 0 0 / 0.88)",
      "gallery-label": "oklch(1 0 0)",
      "gallery-label-muted": "oklch(1 0 0 / 0.74)",
      "gallery-frame": "oklch(1 0 0 / 0.55)"
    },
    "dark": {
      "gallery-scrim": "oklch(0 0 0 / 0.9)",
      "gallery-label": "oklch(1 0 0)",
      "gallery-label-muted": "oklch(1 0 0 / 0.74)",
      "gallery-frame": "oklch(1 0 0 / 0.55)"
    },
    "theme": {
      "--gallery-radius": "4px"
    }
  },
  "files": [
    {
      "path": "components/craftui/sections/feature-gallery-01/default.tsx",
      "content": "\"use client\";\n\nimport React, { useEffect, useRef, useState } from \"react\";\nimport { motion, useReducedMotion, type Transition } from \"motion/react\";\n\nexport interface GalleryImage {\n  label: string;\n  description?: string;\n  src: string;\n  tint: string;\n}\n\nexport interface FeatureGallery01Props {\n  eyebrow?: string;\n  heading?: React.ReactNode;\n  subheading?: React.ReactNode;\n  ctaLabel?: string;\n  ctaHref?: string;\n  secondaryLabel?: string;\n  secondaryHref?: string;\n  images?: GalleryImage[];\n  defaultOpenIndex?: number | null;\n  countNoun?: string;\n  className?: string;\n}\n\n/* ─────────────────────────────────────────────────────────\n * CAPTION STORYBOARD — opening a card\n *\n *    0ms   card widens ................. 300ms  easeOutQuart\n *  165ms   scrim fades in .............. 200ms  easeOutQuad\n *  225ms   label fades + rises ......... 200ms  easeOutQuad\n *  275ms   description fades + rises ... 200ms  easeOutQuad\n *  300ms   card settled\n *  310ms   corner frame fades in ....... 200ms  easeOutQuad\n *  510ms   frame settled\n *\n * The frame lands last, on stationary geometry: its bottom-right bracket is\n * anchored to the edge that animates, so fading it in earlier would drag it\n * across the card instead of placing it.\n *\n * Closing compresses and reverses: scrim and caption leave together in\n * 140ms on an ease-in, then geometry waits 150ms so the text is fully\n * gone before a moving card edge can clip it.\n * ───────────────────────────────────────────────────────── */\nconst TIMING = {\n  scrimIn: 165,\n  labelIn: 225,\n  descriptionIn: 275,\n  frameIn: 310,\n  collapseReset: 150,\n};\n\nconst MOTION = {\n  width: 300,\n  fadeIn: 200,\n  fadeOut: 140,\n};\n\n/** Geometry: front-loaded, so the card feels instant then settles. */\nconst EASE = [0.165, 0.84, 0.44, 1] as const;\n/** Opacity: closer to linear — a fade should read as a fade, not a pop. */\nconst EASE_FADE = [0.25, 0.46, 0.45, 0.94] as const;\n/** Exits: accelerate away, because the decision is already made. */\nconst EASE_EXIT = [0.55, 0.085, 0.68, 0.53] as const;\n\n/**\n * `scroll: false` fills the container — items share the width proportionally\n * via flex-grow, so the strip always spans exactly 100%.\n * `scroll: true` uses fixed pixel widths and overflows horizontally, so every\n * image stays reachable on narrow screens instead of being sliced off.\n */\nconst BREAKPOINTS = [\n  {\n    min: 1280,\n    collapsedWidth: 54,\n    expandedWidth: 320,\n    stripHeight: 300,\n    gap: 9,\n    count: 10,\n    scroll: false,\n  },\n  {\n    min: 768,\n    collapsedWidth: 48,\n    expandedWidth: 300,\n    stripHeight: 280,\n    gap: 8,\n    count: 10,\n    scroll: false,\n  },\n  {\n    min: 640,\n    collapsedWidth: 40,\n    expandedWidth: 260,\n    stripHeight: 260,\n    gap: 7,\n    count: 10,\n    scroll: true,\n  },\n  {\n    min: 0,\n    collapsedWidth: 34,\n    expandedWidth: 180,\n    stripHeight: 220,\n    gap: 5,\n    count: 10,\n    scroll: true,\n  },\n];\n\nconst SSR_PRESET = BREAKPOINTS[1];\n\nconst CORNER_RADIUS = 4;\nconst RADIUS = `var(--gallery-radius, ${CORNER_RADIUS}px)`;\n\n/** Arm length and viewBox of one corner bracket, in px. */\nconst FRAME_SIZE = 24;\n\n/**\n * An L-shaped crop mark whose elbow is arced to `CORNER_RADIUS`, so it sits\n * concentric with the card's own rounded corner. Inset by 1px inside the\n * viewBox so the stroke is never clipped at the edges.\n */\nfunction CornerFrame({ className }: { className?: string }) {\n  const r = CORNER_RADIUS;\n  const end = FRAME_SIZE - 1;\n\n  return (\n    <svg\n      viewBox={`0 0 ${FRAME_SIZE} ${FRAME_SIZE}`}\n      fill=\"none\"\n      aria-hidden\n      className={className}\n      style={{ width: FRAME_SIZE, height: FRAME_SIZE }}\n    >\n      <path\n        d={`M1 ${end} L1 ${1 + r} A${r} ${r} 0 0 1 ${1 + r} 1 L${end} 1`}\n        stroke=\"var(--gallery-frame, oklch(1 0 0 / 0.55))\"\n        strokeWidth=\"1.25\"\n        strokeLinecap=\"round\"\n      />\n    </svg>\n  );\n}\n\nconst photo = (id: number) =>\n  `https://images.pexels.com/photos/${id}/pexels-photo-${id}.jpeg?auto=compress&cs=tinysrgb&w=600&h=1200&fit=crop`;\n\nconst DEFAULT_IMAGES: GalleryImage[] = [\n  {\n    label: \"Biophilic\",\n    description: \"Living walls and raw timber in a north-facing loft.\",\n    src: photo(18108651),\n    tint: \"#6d7a5e\",\n  },\n  {\n    label: \"Heritage\",\n    description: \"A 1920s terrace restored down to its cornices.\",\n    src: photo(33803734),\n    tint: \"#a8757a\",\n  },\n  {\n    label: \"Quiet Luxury\",\n    description: \"Bouclé, limewash, and nothing that shouts.\",\n    src: photo(28853343),\n    tint: \"#b9b2ac\",\n  },\n  {\n    label: \"Edison Glow\",\n    description: \"Low light, dark walls, warm brass fittings.\",\n    src: photo(37930117),\n    tint: \"#17181a\",\n  },\n  {\n    label: \"Sage Living\",\n    description: \"Muted greens carried from kitchen to garden.\",\n    src: photo(31737841),\n    tint: \"#8a9683\",\n  },\n  {\n    label: \"Velvet Accent\",\n    description: \"One emerald sofa doing all the talking.\",\n    src: photo(12379606),\n    tint: \"#14653f\",\n  },\n  {\n    label: \"Open Loft\",\n    description: \"Two floors opened into a single bright volume.\",\n    src: photo(28456460),\n    tint: \"#e3e5e6\",\n  },\n  {\n    label: \"Powder Room\",\n    description: \"Deep oxblood lacquer in four square metres.\",\n    src: photo(37859439),\n    tint: \"#3b1f1c\",\n  },\n  {\n    label: \"Marble & Brass\",\n    description: \"Carrara counters against unlacquered brass.\",\n    src: photo(14613821),\n    tint: \"#cfd3d2\",\n  },\n  {\n    label: \"Dining\",\n    description: \"A ten-seat table built for long evenings.\",\n    src: photo(17947888),\n    tint: \"#6b2b33\",\n  },\n];\n\nconst tx = (t: unknown) => t as Transition;\n\nfunction usePreset() {\n  const [state, setState] = useState({ preset: SSR_PRESET, settled: false });\n\n  useEffect(() => {\n    const read = () =>\n      setState({\n        preset:\n          BREAKPOINTS.find((b) => window.innerWidth >= b.min) ?? SSR_PRESET,\n        settled: true,\n      });\n\n    read();\n    window.addEventListener(\"resize\", read);\n    return () => window.removeEventListener(\"resize\", read);\n  }, []);\n\n  return state;\n}\n\nexport default function FeatureGallery01({\n  eyebrow = \"What we do\",\n  heading = (\n    <>\n      Spaces <span className=\"font-semibold tracking-tight\">designed</span>{\" \"}\n      around how you live\n    </>\n  ),\n  subheading = (\n    <>\n      We <span>design and build interiors</span> that are{\" \"}\n      <span className=\"font-semibold text-foreground\">tailored</span> to your\n      lifestyle,{\" \"}\n      <span className=\"font-semibold text-foreground\">reflecting</span> your\n      personality and{\" \"}\n      <span className=\"font-semibold text-foreground\">enhancing</span> your\n      daily experiences.\n    </>\n  ),\n  ctaLabel = \"Book a consultation\",\n  ctaHref = \"#\",\n  secondaryLabel = \"View all projects\",\n  secondaryHref = \"#\",\n  images = DEFAULT_IMAGES,\n  defaultOpenIndex = 4,\n  countNoun = \"projects\",\n  className = \"\",\n}: FeatureGallery01Props) {\n  const reduceMotion = useReducedMotion();\n  const { preset, settled } = usePreset();\n\n  const { collapsedWidth, expandedWidth, stripHeight, gap, count, scroll } =\n    preset;\n  const visible = images.slice(0, count);\n\n  const initialOpen =\n    defaultOpenIndex === null || defaultOpenIndex >= images.length\n      ? null\n      : defaultOpenIndex;\n\n  const [selected, setSelected] = useState<number | null>(initialOpen);\n  const [geomSelected, setGeomSelected] = useState<number | null>(initialOpen);\n\n  const itemRefs = useRef<(HTMLButtonElement | null)[]>([]);\n  const interacted = useRef(false);\n  const stripRef = useRef<HTMLDivElement | null>(null);\n  const [stripWidth, setStripWidth] = useState(0);\n\n  useEffect(() => {\n    const el = stripRef.current;\n    if (!el) return;\n\n    const ro = new ResizeObserver(([entry]) =>\n      setStripWidth(entry.contentRect.width),\n    );\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, []);\n\n  // Hold the collapsed geometry back until the caption has fully cleared, so a\n  // moving card edge never clips text that is still on screen.\n  useEffect(() => {\n    if (reduceMotion || selected !== null) return;\n\n    const t = setTimeout(() => setGeomSelected(null), TIMING.collapseReset);\n    return () => clearTimeout(t);\n  }, [selected, reduceMotion]);\n\n  // Bring the opened panel into view once it has finished widening, so the\n  // scroll lands on final geometry rather than mid-animation.\n  useEffect(() => {\n    if (!scroll || selected === null || !interacted.current) return;\n\n    const t = setTimeout(\n      () => {\n        itemRefs.current[selected]?.scrollIntoView({\n          inline: \"nearest\",\n          block: \"nearest\",\n          behavior: reduceMotion ? \"auto\" : \"smooth\",\n        });\n      },\n      reduceMotion ? 0 : MOTION.width,\n    );\n\n    return () => clearTimeout(t);\n  }, [selected, scroll, reduceMotion]);\n\n  const select = (i: number) => {\n    interacted.current = true;\n    setSelected(i);\n    setGeomSelected(i);\n  };\n\n  const toggle = (i: number) => {\n    if (selected === i) {\n      interacted.current = true;\n      setSelected(null);\n      return;\n    }\n    select(i);\n  };\n\n  const activeGeom = reduceMotion ? selected : geomSelected;\n\n  const onKeyDown = (e: React.KeyboardEvent, i: number) => {\n    const last = visible.length - 1;\n    let next: number | null = null;\n\n    if (e.key === \"ArrowRight\") next = i === last ? 0 : i + 1;\n    else if (e.key === \"ArrowLeft\") next = i === 0 ? last : i - 1;\n    else if (e.key === \"Home\") next = 0;\n    else if (e.key === \"End\") next = last;\n\n    if (next !== null) {\n      e.preventDefault();\n      itemRefs.current[next]?.focus();\n      select(next);\n    }\n  };\n\n  const T = {\n    width: tx({ duration: MOTION.width / 1000, ease: EASE }),\n  };\n\n  /**\n   * Entering is staged and unhurried; leaving is one fast, undelayed beat.\n   * `delayMs` is what separates scrim, label and description on the way in.\n   */\n  const fade = (on: boolean, delayMs: number) =>\n    tx(\n      reduceMotion\n        ? { duration: 0 }\n        : {\n            duration: (on ? MOTION.fadeIn : MOTION.fadeOut) / 1000,\n            ease: on ? EASE_FADE : EASE_EXIT,\n            delay: on ? delayMs / 1000 : 0,\n          },\n    );\n\n  /**\n   * `collapsedWidth` and `expandedWidth` are a *ratio* in fill mode, not pixels:\n   * cards share the container via flex-grow, so an open card renders wider than\n   * `expandedWidth`. The photo and caption are laid out in real pixels, so they\n   * need that rendered width — otherwise the card's tint shows down both edges.\n   */\n  const openWidth =\n    scroll || stripWidth === 0\n      ? expandedWidth\n      : Math.round(\n          ((stripWidth - (visible.length - 1) * gap) *\n            (expandedWidth / collapsedWidth)) /\n            (expandedWidth / collapsedWidth + visible.length - 1),\n        );\n\n  return (\n    <section\n      className={`w-full bg-background px-4 py-16 sm:py-20 md:py-24 ${className}`}\n    >\n      <div className=\"mx-auto grid max-w-6xl grid-cols-1 gap-1 text-start lg:grid-cols-12 lg:items-start lg:gap-12\">\n        <div className=\"lg:col-span-7\">\n          <p className=\"text-xs font-medium tracking-widest uppercase text-primary\">\n            {eyebrow}\n          </p>\n          <h2 className=\"mt-3 text-[2.125rem] leading-[1.1] font-medium tracking-tighter text-balance text-foreground sm:text-5xl md:text-6xl\">\n            {heading}\n          </h2>\n        </div>\n\n        <div className=\"lg:col-span-5\">\n          <p className=\"max-w-prose text-base text-pretty text-left text-muted-foreground sm:text-lg\">\n            {subheading}\n          </p>\n\n          <div className=\"mt-10 flex flex-wrap items-center gap-3\">\n            <a\n              href={ctaHref}\n              className=\"inline-flex h-11 flex-1 items-center justify-center gap-2 rounded-[8px] bg-primary px-3 text-[16px] font-medium whitespace-nowrap text-white shadow-[0_2px_3px_rgba(0,0,0,0.30),0_3px_4px_rgba(0,0,0,0.15),inset_0_2px_2px_rgba(255,255,255,0.28),inset_0_-3px_0_rgba(0,0,0,0.20)] transition duration-150 hover:bg-primary/95 hover:shadow-[0_3px_5px_rgba(0,0,0,0.32),0_6px_10px_rgba(0,0,0,0.16),inset_0_2px_2px_rgba(255,255,255,0.34),inset_0_-3px_0_rgba(0,0,0,0.20)] active:translate-y-[2px] active:shadow-[0_1px_2px_rgba(0,0,0,0.25),inset_0_1px_3px_rgba(0,0,0,0.22),inset_0_-1px_0_rgba(0,0,0,0.10)] focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background focus-visible:outline-none sm:flex-none sm:px-6\"\n            >\n              <svg\n                viewBox=\"0 0 16 16\"\n                fill=\"none\"\n                aria-hidden\n                className=\"size-4 shrink-0\"\n              >\n                <path\n                  d=\"M3.5 3.5h9a1 1 0 0 1 1 1v8a1 1 0 0 1-1 1h-9a1 1 0 0 1-1-1v-8a1 1 0 0 1 1-1ZM2.5 7h11M5.5 1.75v3M10.5 1.75v3\"\n                  stroke=\"currentColor\"\n                  strokeWidth=\"1.5\"\n                  strokeLinecap=\"round\"\n                  strokeLinejoin=\"round\"\n                />\n              </svg>\n              {ctaLabel}\n            </a>\n            <a\n              href={secondaryHref}\n              className=\"group inline-flex h-11 flex-1 items-center justify-center gap-2 rounded-[8px] border border-border bg-background px-3 text-[16px] font-medium whitespace-nowrap text-foreground shadow-[0_1px_2px_rgba(0,0,0,0.16),0_2px_4px_rgba(0,0,0,0.08),inset_0_1px_1px_rgba(255,255,255,0.30),inset_0_-2px_0_rgba(0,0,0,0.10)] transition duration-150 hover:border-foreground/20 hover:bg-accent hover:shadow-[0_2px_4px_rgba(0,0,0,0.18),0_4px_8px_rgba(0,0,0,0.10),inset_0_1px_1px_rgba(255,255,255,0.36),inset_0_-2px_0_rgba(0,0,0,0.10)] active:translate-y-[2px] active:shadow-[0_1px_1px_rgba(0,0,0,0.14),inset_0_1px_2px_rgba(0,0,0,0.14),inset_0_-1px_0_rgba(0,0,0,0.06)] focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background focus-visible:outline-none sm:flex-none sm:px-6\"\n            >\n              {secondaryLabel}\n              <svg\n                viewBox=\"0 0 16 16\"\n                fill=\"none\"\n                aria-hidden\n                className=\"size-4 transition-transform duration-200 group-hover:translate-x-0.5 rtl:rotate-180 rtl:group-hover:-translate-x-0.5\"\n              >\n                <path\n                  d=\"M3 8h10M9 4l4 4-4 4\"\n                  stroke=\"currentColor\"\n                  strokeWidth=\"1.5\"\n                  strokeLinecap=\"round\"\n                  strokeLinejoin=\"round\"\n                />\n              </svg>\n            </a>\n          </div>\n        </div>\n      </div>\n\n      <div ref={stripRef} className=\"mx-auto mt-12 w-full max-w-6xl sm:mt-16\">\n        <div\n          className={\n            scroll\n              ? \"-mx-4 overflow-x-auto overscroll-x-contain px-4 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden\"\n              : \"\"\n          }\n        >\n          <div\n            className={`flex items-center ${scroll ? \"w-max\" : \"w-full\"}`}\n            style={{ gap, height: stripHeight }}\n          >\n            {visible.map((item, i) => {\n              const isWide = activeGeom === i;\n              const isLabelled = selected === i;\n\n              return (\n                <motion.button\n                  key={item.label + i}\n                  ref={(el) => {\n                    itemRefs.current[i] = el;\n                  }}\n                  type=\"button\"\n                  aria-expanded={isWide}\n                  aria-label={\n                    item.description\n                      ? `${item.label}. ${item.description}`\n                      : item.label\n                  }\n                  onClick={() => toggle(i)}\n                  onKeyDown={(e) => onKeyDown(e, i)}\n                  // Dual inset ring: white band 0–2px, dark band 2–4px. Drawn\n                  // inside so neighbouring cards are never overlapped, and\n                  // paired so it survives on both dark and pale photography.\n                  className=\"relative min-w-0 cursor-pointer overflow-hidden select-none focus-visible:shadow-[inset_0_0_0_2px_rgba(255,255,255,0.95),inset_0_0_0_4px_rgba(0,0,0,0.55)] focus-visible:outline-none\"\n                  style={{\n                    borderRadius: RADIUS,\n                    backgroundColor: item.tint,\n                    height: stripHeight,\n                    flexShrink: 0,\n                  }}\n                  initial={false}\n                  animate={{\n                    flexGrow: scroll\n                      ? 0\n                      : isWide\n                        ? expandedWidth / collapsedWidth\n                        : 1,\n                    // px units are explicit: Motion only auto-appends them for\n                    // known length properties, and flexBasis is not one.\n                    flexBasis: scroll\n                      ? `${isWide ? expandedWidth : collapsedWidth}px`\n                      : \"0px\",\n                  }}\n                  transition={\n                    settled\n                      ? { flexGrow: T.width, flexBasis: T.width }\n                      : { duration: 0 }\n                  }\n                >\n                  {/* eslint-disable-next-line @next/next/no-img-element */}\n                  <img\n                    src={item.src}\n                    alt=\"\"\n                    aria-hidden\n                    draggable={false}\n                    className=\"absolute top-1/2 left-1/2 max-w-none -translate-x-1/2 -translate-y-1/2 object-cover\"\n                    style={{ width: openWidth, height: stripHeight }}\n                  />\n\n                  <motion.div\n                    className=\"pointer-events-none absolute inset-x-0 bottom-0 h-3/5\"\n                    style={{\n                      backgroundImage:\n                        \"linear-gradient(to top, var(--gallery-scrim, oklch(0 0 0 / 0.88)) 0%, color-mix(in oklab, var(--gallery-scrim, oklch(0 0 0 / 0.88)) 62%, transparent) 34%, color-mix(in oklab, var(--gallery-scrim, oklch(0 0 0 / 0.88)) 22%, transparent) 64%, transparent 100%)\",\n                    }}\n                    initial={false}\n                    animate={{ opacity: isLabelled ? 1 : 0 }}\n                    transition={fade(isLabelled, TIMING.scrimIn)}\n                  />\n\n                  {/* Mirrored as a whole in RTL, so the pair becomes\n                      top-right + bottom-left and stays clear of the caption. */}\n                  <motion.div\n                    className=\"pointer-events-none absolute inset-0 rtl:-scale-x-100\"\n                    initial={false}\n                    animate={{ opacity: isLabelled ? 1 : 0 }}\n                    transition={fade(isLabelled, TIMING.frameIn)}\n                  >\n                    <CornerFrame className=\"absolute top-3 left-3 drop-shadow-[0_1px_2px_rgba(0,0,0,0.55)]\" />\n                    {/* Narrow cards wrap the description onto a second line\n                        that reaches the bottom-right corner, so below md the\n                        pair sits across the top instead of on the diagonal. */}\n                    <CornerFrame className=\"absolute top-3 right-3 rotate-90 drop-shadow-[0_1px_2px_rgba(0,0,0,0.55)] md:top-auto md:bottom-3 md:rotate-180\" />\n                  </motion.div>\n                  <div\n                    className=\"pointer-events-none absolute bottom-3 start-4 text-start\"\n                    style={{ width: openWidth - 32 }}\n                  >\n                    <motion.p\n                      className=\"text-sm font-medium tracking-normal whitespace-nowrap sm:text-xl\"\n                      style={{ color: \"var(--gallery-label, oklch(1 0 0))\" }}\n                      initial={false}\n                      animate={{\n                        opacity: isLabelled ? 1 : 0,\n                        y: isLabelled ? 0 : 10,\n                      }}\n                      transition={fade(isLabelled, TIMING.labelIn)}\n                    >\n                      {item.label}\n                    </motion.p>\n                    {item.description && (\n                      <motion.p\n                        className=\"line-clamp-1 sm:line-clamp-2 text-xs leading-snug text-pretty sm:text-sm\"\n                        style={{\n                          color:\n                            \"var(--gallery-label-muted, oklch(1 0 0 / 0.74))\",\n                        }}\n                        initial={false}\n                        animate={{\n                          opacity: isLabelled ? 1 : 0,\n                          y: isLabelled ? 0 : 10,\n                        }}\n                        transition={fade(isLabelled, TIMING.descriptionIn)}\n                      >\n                        {item.description}\n                      </motion.p>\n                    )}\n                  </div>\n\n                  {/*\n                    Inset hairline separating the photo from the page. Last in\n                    the DOM so it sits over the image, the scrim and the frame,\n                    and drawn as an overlay rather than an inset box-shadow on\n                    the button itself — an inset shadow paints below child\n                    content, so the image would cover it.\n\n                    Not an `outline` either: the card already spends its\n                    outline on `focus-visible:outline-none`, and a decorative\n                    outline would disappear exactly when the card is focused.\n\n                    Pure black at 10% on light, pure white at 10% on dark. A\n                    tinted neutral picks up the surface behind it and reads as\n                    dirt along the photo edge.\n                  */}\n                  <span\n                    aria-hidden=\"true\"\n                    className=\"pointer-events-none absolute inset-0 shadow-[inset_0_0_0_1px_rgba(0,0,0,0.10)] dark:shadow-[inset_0_0_0_1px_rgba(255,255,255,0.10)]\"\n                    style={{ borderRadius: RADIUS }}\n                  />\n                </motion.button>\n              );\n            })}\n          </div>\n        </div>\n\n        {settled && images.length > visible.length && (\n          <p className=\"mt-4 text-sm tabular-nums text-muted-foreground\">\n            Showing {visible.length} of {images.length} {countNoun}\n          </p>\n        )}\n      </div>\n    </section>\n  );\n}\n",
      "type": "registry:component",
      "target": "~/components/craftui/sections/feature-gallery-01/default.tsx"
    }
  ],
  "meta": {
    "displayName": "Feature Gallery 01 - Feature Gallery 01",
    "description": "Cards rise into place on scroll, then the clicked card widens around a stationary image while the rest narrow. Heights never change; the label and scrim fade in last.",
    "category": "feature-section",
    "tags": [
      "feature",
      "section",
      "gallery",
      "expand",
      "click",
      "interior"
    ],
    "animationType": "click"
  }
}