{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"images-flow","type":"registry:component","title":"Images flow","description":"Images spread from the centre into 3D.","author":"DiMaac (HankTheTank) <https://dimaac.com>","dependencies":["gsap","@gsap/react"],"files":[{"path":"registry/dimaac/images-flow/images-flow.tsx","type":"registry:component","target":"components/dimaac/images-flow.tsx","content":"\"use client\";\n\nimport { useEffect, useRef, type CSSProperties } from \"react\";\nimport { gsap } from \"gsap\";\nimport { ScrollTrigger } from \"gsap/ScrollTrigger\";\nimport { useGSAP } from \"@gsap/react\";\n\n// An intro title, then a pinned \"tunnel\" where photos explode out of the middle of the screen toward\n// the camera as you scroll, then an outro title. The flow section pins to the screen and the\n// scrollbar drives every photo: scroll down and they fly out, scroll up and they fly back in.\n// The last image in the list is different: it is the full-screen backdrop that grows in behind the rest.\n//\n// Fonts: the demo uses Raleway for the titles and Instrument Serif for the flow text. Load them, or set\n// the CSS variables --images-flow-title-font and --images-flow-flow-font to use your own.\n\nif (typeof window !== \"undefined\") gsap.registerPlugin(useGSAP, ScrollTrigger);\n\nexport type ImagesFlowImage = {\n  /** Where the picture lives. */\n  src: string;\n  /** What it shows. Leave it empty for a purely decorative picture (the default). */\n  alt?: string;\n};\n\nexport type ImagesFlowProps = {\n  /** The pictures. The last one is the full-screen backdrop, the others fly past. Twelve to thirty look best. */\n  images: (string | ImagesFlowImage)[];\n  /** The big word before the flow starts. */\n  introTitle: string;\n  /** A line under the intro title. */\n  introSubtitle?: string;\n  /** The text that stays in the middle of the screen while the pictures fly. Use a line break for two lines. */\n  flowText?: string;\n  /** The big word after the flow ends. */\n  outroTitle: string;\n  /** A line under the outro title. */\n  outroSubtitle?: string;\n  /** How far the pictures spread sideways on a wide screen. 0.7 puts the outermost ones at 70% of the screen away from the middle. */\n  spread?: number;\n  /** The same, on a screen narrower than 800px, where the pictures need to travel further to leave the frame. */\n  mobileSpread?: number;\n  /** How long the flow takes, in screens of scrolling. */\n  scrollLength?: number;\n  /** How much of the scroll the start times of the pictures are spread over, from 0 (all leave together) to 0.9. */\n  stagger?: number;\n  /** How fast a picture flies, as a multiple of the whole scroll. 4 means one picture needs a quarter of the scroll. */\n  flightSpeed?: number;\n  /** Depth of the 3D scene in pixels. Pictures vanish as they pass this distance. Smaller is more dramatic. */\n  perspective?: number;\n  /** Width of each flying picture, in pixels. On a narrow screen it never goes wider than 90% of it. */\n  cardWidth?: number;\n  /** Height of each flying picture, in pixels. */\n  cardHeight?: number;\n  /** How dark the full-screen backdrop is, from 0 (not at all) to 1 (black). */\n  dim?: number;\n  /** Background of the intro and the outro. */\n  pageColor?: string;\n  /** Colour of the intro and outro text. */\n  inkColor?: string;\n  /** Background of the flow section, behind the pictures. */\n  stageColor?: string;\n  /** The tag of the two big titles: use \"h1\" if this is the main heading of your page. */\n  titleAs?: \"h1\" | \"h2\" | \"h3\";\n  /** The scrolling element. Leave it out to use the page; give it a selector or an element for an inner scroller. */\n  scroller?: string | HTMLElement;\n  /** Extra classes for the outer wrapper. */\n  className?: string;\n};\n\n// Where each picture ends up, as a fraction of the screen (-1 left/top to 1 right/bottom).\n// A hand-picked scatter, so no two neighbours fly the same way.\nconst SCATTER = [\n  [-0.8, -0.6], [0.7, 0.4], [-0.5, 0.7], [0.6, -0.5], [-0.8, 0.2], [0.8, -0.3], [-0.6, -0.8],\n  [0.4, 0.6], [-0.7, 0.5], [0.5, -0.7], [-0.4, -0.4], [0.3, 0.8], [-0.8, 0.3], [0.6, 0.2],\n  [-0.2, -0.7], [0.7, -0.6], [-0.5, 0.4], [0.4, -0.4], [-0.6, 0.6], [0.8, 0.5], [-0.3, -0.5],\n  [0.5, 0.3], [-0.7, -0.2], [0.2, 0.7], [-0.4, 0.8], [0.6, -0.8], [-0.8, 0.1],\n] as const;\n\n/** The target for picture number `index`. Past the hand-picked list it follows a fixed spiral, never random. */\nfunction scatterAt(index: number): { x: number; y: number } {\n  const picked = SCATTER[index];\n  if (picked) return { x: picked[0], y: picked[1] };\n  const angle = index * 2.399963; // the golden angle, so neighbours never line up\n  const radius = 0.35 + 0.5 * ((index * 0.618) % 1);\n  return { x: Math.cos(angle) * radius, y: Math.sin(angle) * radius };\n}\n\nexport function ImagesFlow({\n  images,\n  introTitle,\n  introSubtitle,\n  flowText = \"Twenty-eight photos,\\none scroll\",\n  outroTitle,\n  outroSubtitle,\n  spread = 0.7,\n  mobileSpread = 1.5,\n  scrollLength = 10,\n  stagger = 0.81,\n  flightSpeed = 4,\n  perspective = 2000,\n  cardWidth = 500,\n  cardHeight = 350,\n  dim = 0.4,\n  pageColor = \"#e9e9e9\",\n  inkColor = \"#1c1c1c\",\n  stageColor = \"#1c1c1c\",\n  titleAs: Heading = \"h2\",\n  scroller,\n  className = \"\",\n}: ImagesFlowProps) {\n  const rootRef = useRef<HTMLDivElement>(null);\n  const flowRef = useRef<HTMLElement>(null);\n  const cardRefs = useRef<(HTMLDivElement | null)[]>([]);\n\n  // These change how the pictures are placed, not how the scroll is wired up. The animation reads\n  // them on every frame, so moving a slider doesn't rebuild the pin.\n  const live = useRef({ spread, mobileSpread, stagger, flightSpeed, perspective });\n  live.current = { spread, mobileSpread, stagger, flightSpeed, perspective };\n  // Draws the pictures for the current scroll position. Set by the effect below.\n  const redraw = useRef<(() => void) | null>(null);\n  useEffect(() => {\n    redraw.current?.();\n  }, [spread, mobileSpread, stagger, flightSpeed, perspective]);\n\n  const items = images.map((image) => (typeof image === \"string\" ? { src: image, alt: \"\" } : { src: image.src, alt: image.alt ?? \"\" }));\n  const lastIndex = items.length - 1;\n\n  useGSAP(\n    () => {\n      const root = rootRef.current;\n      const flow = flowRef.current;\n      const cards = cardRefs.current.slice(0, items.length).filter((card): card is HTMLDivElement => card !== null);\n      if (!root || !flow || cards.length === 0) return;\n\n      const scrollEl = typeof scroller === \"string\" ? document.querySelector<HTMLElement>(scroller) : scroller;\n      if (typeof scroller === \"string\" && !scrollEl) return;\n\n      // Pictures start in the middle, tiny, and are placed by x / y / z / scale from here on\n      gsap.set(cards, { xPercent: -50, yPercent: -50, x: 0, y: 0, z: 0, scale: 0, force3D: true });\n      const place = cards.map((card) => ({\n        x: gsap.quickSetter(card, \"x\", \"px\"),\n        y: gsap.quickSetter(card, \"y\", \"px\"),\n        z: gsap.quickSetter(card, \"z\", \"px\"),\n        scaleX: gsap.quickSetter(card, \"scaleX\"),\n        scaleY: gsap.quickSetter(card, \"scaleY\"),\n      }));\n\n      let width = flow.clientWidth;\n      let height = flow.clientHeight;\n      let progress = 0;\n\n      // Draws every picture for a scroll position between 0 and 1\n      const draw = () => {\n        const { spread, mobileSpread, stagger, flightSpeed, perspective } = live.current;\n        const reach = width < 800 ? mobileSpread : spread;\n        const last = cards.length - 1;\n        cards.forEach((card, index) => {\n          // Each picture starts a little after the one before it, and flies for a fixed slice of the scroll.\n          // Clamped to 0..1: a picture that has arrived stays arrived instead of flying on forever.\n          const delay = last > 0 ? (index / last) * stagger : 0;\n          const t = gsap.utils.clamp(0, 1, (progress - delay) * flightSpeed);\n          const isBackdrop = index === last && last > 0;\n          const target = scatterAt(index);\n\n          let x = target.x * width * reach * t;\n          let y = target.y * height * reach * t;\n          let z = gsap.utils.interpolate(-perspective / 2, perspective, t);\n          if (isBackdrop) {\n            // The backdrop stays in the middle and only grows, at 40% of the speed\n            x = 0;\n            y = 0;\n            z *= 0.4;\n          }\n          place[index]!.x(x);\n          place[index]!.y(y);\n          place[index]!.z(z);\n          place[index]!.scaleX(t);\n          place[index]!.scaleY(t);\n          // A picture that has reached the camera is gone: hide it so it can't flash at the edges\n          card.style.visibility = t <= 0 || (!isBackdrop && z >= perspective - 1) ? \"hidden\" : \"visible\";\n        });\n      };\n      redraw.current = draw;\n\n      const measure = () => {\n        width = flow.clientWidth;\n        height = flow.clientHeight;\n      };\n\n      const mm = gsap.matchMedia();\n\n      // Reduced motion: no pin and no flying. The flow section is one still frame of the tunnel.\n      mm.add(\"(prefers-reduced-motion: reduce)\", () => {\n        progress = 0.38;\n        measure();\n        draw();\n        const onResize = () => {\n          measure();\n          draw();\n        };\n        window.addEventListener(\"resize\", onResize);\n        return () => window.removeEventListener(\"resize\", onResize);\n      });\n\n      mm.add(\"(prefers-reduced-motion: no-preference)\", () => {\n        // Sections are one screen tall. For an inner scroller that is the scroller's height, which CSS can't know.\n        const sizeSections = () => {\n          if (scrollEl) root.style.setProperty(\"--images-flow-height\", `${scrollEl.clientHeight}px`);\n        };\n        sizeSections();\n\n        const trigger = ScrollTrigger.create({\n          trigger: flow,\n          scroller: scrollEl ?? undefined,\n          start: \"top top\",\n          end: () => `+=${Math.round(flow.clientHeight * scrollLength)}`,\n          pin: true,\n          pinSpacing: true,\n          scrub: 1,\n          // Measure again whenever ScrollTrigger refreshes (window resize, fonts, pictures), not on every resize event\n          invalidateOnRefresh: true,\n          onRefresh: (self) => {\n            measure();\n            progress = self.progress;\n            draw();\n          },\n          onUpdate: (self) => {\n            progress = self.progress;\n            draw();\n          },\n        });\n\n        // An inner scroller can change size without the window knowing. Wait for the resizing to settle, then measure once.\n        let settle: gsap.core.Tween | undefined;\n        const observer = scrollEl\n          ? new ResizeObserver(() => {\n              settle?.kill();\n              settle = gsap.delayedCall(0.2, () => {\n                sizeSections();\n                ScrollTrigger.refresh();\n              });\n            })\n          : null;\n        if (scrollEl) observer?.observe(scrollEl);\n\n        return () => {\n          settle?.kill();\n          observer?.disconnect();\n          trigger.kill();\n        };\n      });\n\n      return () => {\n        redraw.current = null;\n        mm.revert();\n      };\n    },\n    { scope: rootRef, dependencies: [items.length, scrollLength, scroller], revertOnUpdate: true },\n  );\n\n  const titleStyle: CSSProperties = {\n    fontFamily: \"var(--images-flow-title-font, Raleway, ui-sans-serif, system-ui, sans-serif)\",\n    fontSize: \"clamp(2rem, 8vw, 6rem)\",\n    fontWeight: 200,\n    letterSpacing: \"0.3em\",\n    // The letter spacing also trails the last letter, so push the word back to the middle\n    textIndent: \"0.3em\",\n    textTransform: \"uppercase\",\n    lineHeight: 1.1,\n    marginBottom: \"1rem\",\n    // A soft fade from the ink to a lighter ink across the word\n    backgroundImage: `linear-gradient(135deg, ${inkColor} 0%, color-mix(in srgb, ${inkColor} 78%, ${pageColor}) 100%)`,\n    WebkitBackgroundClip: \"text\",\n    backgroundClip: \"text\",\n    WebkitTextFillColor: \"transparent\",\n  };\n  const subtitleStyle: CSSProperties = {\n    fontFamily: \"var(--images-flow-title-font, Raleway, ui-sans-serif, system-ui, sans-serif)\",\n    fontSize: \"clamp(0.75rem, 2vw, 1.2rem)\",\n    fontWeight: 300,\n    letterSpacing: \"0.15em\",\n    textIndent: \"0.15em\",\n    opacity: 0.7,\n  };\n  const sectionStyle: CSSProperties = { height: \"var(--images-flow-height, 100svh)\" };\n\n  return (\n    <div ref={rootRef} className={`relative w-full overflow-x-clip ${className}`}>\n      {/* Intro */}\n      <section className=\"relative flex w-full flex-col items-center justify-center overflow-hidden px-6 text-center\" style={{ ...sectionStyle, backgroundColor: pageColor, color: inkColor }}>\n        <Heading style={titleStyle}>{introTitle}</Heading>\n        {introSubtitle ? <p style={subtitleStyle}>{introSubtitle}</p> : null}\n      </section>\n\n      {/* Flow: this is the part that pins */}\n      <section ref={flowRef} className=\"relative w-full overflow-hidden\" style={{ ...sectionStyle, backgroundColor: stageColor }}>\n        <p\n          className=\"pointer-events-none absolute top-1/2 left-1/2 z-[100] w-full -translate-x-1/2 -translate-y-1/2 px-6 text-center whitespace-pre-line text-white opacity-80\"\n          style={{ fontFamily: \"var(--images-flow-flow-font, 'Instrument Serif', Georgia, serif)\", fontSize: \"clamp(1.5rem, 5vw, 2rem)\", letterSpacing: \"0.05em\" }}\n        >\n          {flowText}\n        </p>\n        <div className=\"absolute inset-0\" style={{ perspective, transformStyle: \"preserve-3d\" }}>\n          {items.map((item, index) => {\n            const backdrop = index === lastIndex && lastIndex > 0;\n            return (\n              <div\n                key={`${index}-${item.src}`}\n                ref={(card) => {\n                  cardRefs.current[index] = card;\n                }}\n                className={backdrop ? \"absolute top-1/2 left-1/2 h-full w-full\" : \"absolute top-1/2 left-1/2 max-w-[90vw]\"}\n                // Before the script runs the pictures sit invisible in the middle, so nothing flashes on load\n                style={{ ...(backdrop ? null : { width: cardWidth, height: cardHeight }), transform: \"translate(-50%, -50%) scale(0)\", visibility: \"hidden\", transformStyle: \"preserve-3d\", willChange: \"transform\" }}\n              >\n                <div className=\"relative h-full w-full overflow-hidden\">\n                  <img src={item.src} alt={item.alt} className=\"block h-full w-full object-cover\" draggable={false} decoding=\"async\" />\n                  {backdrop ? <div aria-hidden=\"true\" className=\"absolute inset-0 bg-black\" style={{ opacity: dim }} /> : null}\n                </div>\n              </div>\n            );\n          })}\n        </div>\n      </section>\n\n      {/* Outro */}\n      <section className=\"relative flex w-full flex-col items-center justify-center overflow-hidden px-6 text-center\" style={{ ...sectionStyle, backgroundColor: pageColor, color: inkColor }}>\n        <Heading style={titleStyle}>{outroTitle}</Heading>\n        {outroSubtitle ? <p style={subtitleStyle}>{outroSubtitle}</p> : null}\n      </section>\n    </div>\n  );\n}\n"}],"categories":["scroll","scrolltrigger","pin","3d","images","scrub"],"docs":"import { ImagesFlow } from \"@/components/dimaac/images-flow\". Give its parent a size: it fills it. Docs, playground and props: https://ui.dimaac.com/components/images-flow","meta":{"tier":"free","preview":"https://ui.dimaac.com/components/images-flow","credits":[{"by":"Pablo Stanley","url":"https://www.lummi.ai/photo/desert-dunes-and-mountain-range-bfczd","what":"Desert Dunes and Mountain Range","source":"Lummi","licence":"Lummi License"},{"by":"Ricardo Matos","url":"https://www.lummi.ai/photo/minimalist-urban-design-abiri","what":"Minimalist Urban Design","source":"Lummi","licence":"Lummi License"},{"by":"Shaun Duval","url":"https://www.lummi.ai/photo/portrait-with-neon-sunglasses-gindg","what":"Portrait with Neon Sunglasses","source":"Lummi","licence":"Lummi License"},{"by":"Axel Photography","url":"https://www.lummi.ai/photo/futuristic-fashion-pose-6nymn","what":"Futuristic Fashion Pose","source":"Lummi","licence":"Lummi License"},{"by":"Ricardo Matos","url":"https://www.lummi.ai/photo/surreal-3d-planet-scene-ofhhw","what":"Surreal 3D Planet Scene","source":"Lummi","licence":"Lummi License"},{"by":"Dona Mara","url":"https://www.lummi.ai/photo/elegant-perfume-bottle-j5t5p","what":"Elegant Perfume Bottle","source":"Lummi","licence":"Lummi License"},{"by":"Mikiwa","url":"https://www.lummi.ai/photo/serene-ocean-aerial-view-kjab6","what":"Serene Ocean Aerial View","source":"Lummi","licence":"Lummi License"},{"by":"Siren Media","url":"https://www.lummi.ai/photo/modern-interior-with-red-wall-6k-hl","what":"Modern Interior with Red Wall","source":"Lummi","licence":"Lummi License"},{"by":"West Kast","url":"https://www.lummi.ai/photo/monochrome-portrait-of-a-young-woman-shb7s","what":"Monochrome Portrait of a Young Woman","source":"Lummi","licence":"Lummi License"},{"by":"West Kast","url":"https://www.lummi.ai/photo/fashion-portrait-with-orange-visor-damql","what":"Fashion Portrait with Orange Visor","source":"Lummi","licence":"Lummi License"},{"by":"SHIHO","url":"https://www.lummi.ai/photo/lush-green-hills-mdvvk","what":"Lush Green Hills","source":"Lummi","licence":"Lummi License"},{"by":"rena","url":"https://www.lummi.ai/photo/minimalist-still-life-gi7oj","what":"Minimalist Still Life","source":"Lummi","licence":"Lummi License"},{"by":"Atelier Aji","url":"https://www.lummi.ai/photo/contemplative-urban-sunset-ttp5t","what":"Contemplative Urban Sunset","source":"Lummi","licence":"Lummi License"},{"by":"SHIHO","url":"https://www.lummi.ai/photo/vibrant-cylindrical-arrangement-pj9pp","what":"Vibrant Cylindrical Arrangement","source":"Lummi","licence":"Lummi License"},{"by":"rena","url":"https://www.lummi.ai/photo/modern-architectural-design-aozka","what":"Modern Architectural Design","source":"Lummi","licence":"Lummi License"},{"by":"Amino","url":"https://www.lummi.ai/photo/vivid-portrait-on-pink-oxbow","what":"Vivid Portrait on Pink","source":"Lummi","licence":"Lummi License"},{"by":"alexaperios","url":"https://www.lummi.ai/photo/solitary-tree-in-desert-wsyns","what":"Solitary Tree in Desert","source":"Lummi","licence":"Lummi License"},{"by":"West Kast","url":"https://www.lummi.ai/photo/futuristic-gold-profile-jnio5","what":"Futuristic Gold Profile","source":"Lummi","licence":"Lummi License"},{"by":"Pablo Stanley","url":"https://www.lummi.ai/photo/elegant-vase-with-dried-flowers-dhxnk","what":"Elegant Vase with Dried Flowers","source":"Lummi","licence":"Lummi License"},{"by":"Binks AI","url":"https://www.lummi.ai/photo/serene-minimalist-scene-with-palm-trees-pba-c","what":"Serene Minimalist Scene with Palm Trees","source":"Lummi","licence":"Lummi License"},{"by":"Nika","url":"https://www.lummi.ai/photo/serene-gradient-landscape-80b2l","what":"Serene Gradient Landscape","source":"Lummi","licence":"Lummi License"},{"by":"Gerson Portillas","url":"https://www.lummi.ai/photo/warm-and-cool-portrait-6s3_","what":"Warm and Cool Portrait","source":"Lummi","licence":"Lummi License"},{"by":"Cayetano Gros","url":"https://www.lummi.ai/photo/dramatic-coastline-aerial-view-jml-7","what":"Dramatic Coastline Aerial View","source":"Lummi","licence":"Lummi License"},{"by":"Benginur Hajjaj","url":"https://www.lummi.ai/photo/professional-portrait-yps74","what":"Professional Portrait","source":"Lummi","licence":"Lummi License"},{"by":"Viri Gutiérrez","url":"https://www.lummi.ai/photo/contemplative-silhouette-against-modern-minimalist-architecture-1-haa","what":"Contemplative Silhouette against Modern Minimalist Architecture","source":"Lummi","licence":"Lummi License"},{"by":"Milan Bundalo","url":"https://www.lummi.ai/photo/minimalist-still-life-rrgma","what":"Minimalist Still Life","source":"Lummi","licence":"Lummi License"},{"by":"Semihan Dosunmu","url":"https://www.lummi.ai/photo/avant-garde-fashion-pose-1yqoo","what":"Avant-Garde Fashion Pose","source":"Lummi","licence":"Lummi License"},{"by":"Viri Gutiérrez","url":"https://www.lummi.ai/photo/an-empty-snowy-mountain-with-pink-sunrise-on-the-hori-270fdea6-887e-4d89-8205-3eab907f3fb0-HgoCN","what":"Alpine Dawn Serenity","source":"Lummi","licence":"Lummi License"}]}}