{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"section-3d","type":"registry:component","title":"Section 3D","description":"Tall cards that fall back into 3D as the next one arrives.","author":"DiMaac (HankTheTank) <https://dimaac.com>","dependencies":["gsap","@gsap/react"],"files":[{"path":"registry/dimaac/section-3d/section-3d.tsx","type":"registry:component","target":"components/dimaac/section-3d.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// A stack of tall photo cards that arrive one after another. The page pins a single stage; as you\n// scroll, the next card rises from the bottom while the card under it fades, lifts and tips back\n// into the screen, as if it were falling away into a room behind the page.\n// One pin and one scrubbed timeline run the whole thing (the first version pinned every card on its\n// own, which got heavy with a dozen cards). The animation is tied to the scrollbar, so it also\n// plays backwards when you scroll up.\n\nif (typeof window !== \"undefined\") gsap.registerPlugin(useGSAP, ScrollTrigger);\n\nexport type Section3DCard = {\n  /** The photo. */\n  image: string;\n  /** Alt text for the photo. Falls back to the name. */\n  imageAlt?: string;\n  /** A small label above the name, e.g. \"01 / 08\". */\n  number: string;\n  /** The big line on the card. */\n  name: string;\n  /** The line under the name. */\n  role: string;\n  /** A short quote at the bottom of the card. Leave it out for no quote. */\n  quote?: string;\n};\n\nexport type Section3DProps = {\n  /** The cards, in the order they arrive. */\n  cards: Section3DCard[];\n  /** The big heading of the opening screen. */\n  introTitle?: string;\n  /** The paragraph under it. Pass an empty string to leave it out. */\n  introDescription?: string;\n  /** How far a card tips back while the next one arrives, in degrees. */\n  tilt?: number;\n  /** How far a card sinks away from you, in pixels. */\n  depth?: number;\n  /** How far a card rises while it recedes, as a percentage of its own height. */\n  lift?: number;\n  /** How much a receding card fades out, from 0 (stays solid) to 1 (fully gone). */\n  fade?: number;\n  /** Depth of the 3D effect, in pixels. Smaller is more dramatic. */\n  perspective?: number;\n  /** How much scrolling one hand-over takes, in screens. 1 moves the next card exactly with your scroll. */\n  scrollPerCard?: number;\n  /** The start colour of the heading underline and the small line on each card. */\n  accent?: string;\n  /** The end colour of those two gradients. */\n  accentEnd?: string;\n  /** The colour behind the cards. Use a dark one: the text on the page is light. */\n  backdrop?: string;\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\nexport function Section3D({\n  cards,\n  introTitle = \"Portraits\",\n  introDescription = \"Eight photos in one stack. Scroll to bring the next one up.\",\n  tilt = 80,\n  depth = 800,\n  lift = 25,\n  fade = 1,\n  perspective = 1000,\n  scrollPerCard = 1,\n  accent = \"#ff6b35\",\n  accentEnd = \"#f7c59f\",\n  backdrop = \"#160f03\",\n  scroller,\n  className = \"\",\n}: Section3DProps) {\n  const rootRef = useRef<HTMLDivElement>(null);\n  const cardCount = cards.length;\n  // The motion settings are read by the tweens when they (re)measure, so dragging a slider in a\n  // playground doesn't rebuild the pin and throw the visitor back up the page\n  const settings = useRef({ tilt, depth, lift, fade, scrollPerCard });\n  settings.current = { tilt, depth, lift, fade, scrollPerCard };\n  const trigger = useRef<ScrollTrigger | null>(null);\n\n  useGSAP(\n    () => {\n      const root = rootRef.current;\n      if (!root || cardCount === 0) return;\n\n      const mm = gsap.matchMedia();\n\n      // Reduced motion: nothing is pinned or tipped. The cards are just a tall page, one after another.\n      mm.add(\"(prefers-reduced-motion: no-preference)\", () => {\n        const stage = root.querySelector<HTMLElement>(\"[data-s3d-stage]\");\n        const slots = gsap.utils.toArray<HTMLElement>(\"[data-s3d-slot]\", root);\n        const faces = gsap.utils.toArray<HTMLElement>(\"[data-s3d-card]\", root);\n        const line = root.querySelector<HTMLElement>(\"[data-s3d-line]\");\n        if (!stage) return;\n\n        // Inside an inner scroller, \"a screen\" means that scroller's height, not the window's\n        const scrollEl = typeof scroller === \"string\" ? document.querySelector<HTMLElement>(scroller) : scroller;\n        const syncHeight = (): void => {\n          root.style.setProperty(\"--s3d-h\", scrollEl ? `${scrollEl.clientHeight}px` : \"100svh\");\n        };\n        syncHeight();\n        ScrollTrigger.addEventListener(\"refreshInit\", syncHeight);\n\n        // The slow light that runs down the line at the bottom of the opening screen\n        if (line) gsap.fromTo(line, { yPercent: -100 }, { yPercent: 100, duration: 2, ease: \"sine.inOut\", repeat: -1 });\n\n        // One timeline, one step per hand-over: card i+1 rises into view while card i falls back.\n        // Every step is one unit long and the steps run back to back, so scrub keeps them in order.\n        if (cardCount > 1) {\n          const handOvers = gsap.timeline({\n            defaults: { ease: \"none\", duration: 1 },\n            scrollTrigger: {\n              trigger: stage,\n              scroller,\n              start: \"top top\",\n              // Measured on every refresh, so rotating a phone or resizing the window stays correct\n              end: () => `+=${(cardCount - 1) * stage.offsetHeight * settings.current.scrollPerCard}`,\n              pin: true,\n              scrub: true,\n              anticipatePin: 1,\n              invalidateOnRefresh: true,\n            },\n          });\n\n          trigger.current = handOvers.scrollTrigger ?? null;\n\n          for (let i = 1; i < cardCount; i++) {\n            // The new card starts just below the stage (its slot sits at top: 100%) and climbs to the top\n            handOvers.fromTo(slots[i]!, { yPercent: 0 }, { yPercent: -100 }, i - 1);\n            handOvers.fromTo(\n              faces[i - 1]!,\n              { opacity: 1, yPercent: 0, z: 0, rotationX: 0 },\n              {\n                opacity: () => 1 - settings.current.fade,\n                yPercent: () => -settings.current.lift,\n                z: () => -settings.current.depth,\n                rotationX: () => settings.current.tilt,\n                transformOrigin: \"50% 50%\",\n                force3D: true,\n              },\n              i - 1,\n            );\n          }\n        }\n\n        // A scroller that changes size (a resizable panel, for example) needs the pin measured again\n        let measure: gsap.core.Tween | undefined;\n        const observer = scrollEl ? new ResizeObserver(() => {\n          measure?.kill();\n          measure = gsap.delayedCall(0.2, () => ScrollTrigger.refresh());\n        }) : null;\n        if (scrollEl) observer?.observe(scrollEl);\n\n        return () => {\n          measure?.kill();\n          observer?.disconnect();\n          ScrollTrigger.removeEventListener(\"refreshInit\", syncHeight);\n          trigger.current = null;\n          root.style.removeProperty(\"--s3d-h\");\n        };\n      });\n\n      return () => mm.revert();\n    },\n    { scope: rootRef, dependencies: [cardCount, scroller], revertOnUpdate: true },\n  );\n\n  // New motion settings: measure the pin again, keeping the scroll position\n  useEffect(() => {\n    trigger.current?.refresh();\n  }, [tilt, depth, lift, fade, scrollPerCard]);\n\n  const style = { backgroundColor: backdrop, \"--s3d-h\": \"100svh\" } as CSSProperties;\n  const gradient = `linear-gradient(90deg, ${accent}, ${accentEnd})`;\n\n  return (\n    <div ref={rootRef} className={`relative w-full overflow-x-clip text-white ${className}`} style={style}>\n      {/* The opening screen. It scrolls like a normal page. */}\n      <section className=\"flex h-[var(--s3d-h)] flex-col items-center justify-center px-6 text-center md:px-8\">\n        <h2\n          className=\"mt-auto mb-6 bg-clip-text text-[clamp(48px,8vw,80px)] leading-[1.05] font-extrabold tracking-[-0.02em] text-transparent\"\n          style={{ backgroundImage: `linear-gradient(135deg, ${accent} 0%, ${accentEnd} 50%, #fff 100%)` }}\n        >\n          {introTitle}\n        </h2>\n        {introDescription ? <p className=\"max-w-[500px] px-4 text-[clamp(16px,1.4vw,18px)] leading-normal tracking-[0.02em] text-white/50\">{introDescription}</p> : null}\n        <div aria-hidden=\"true\" className=\"relative mt-auto h-24 w-px overflow-hidden bg-white/10 md:h-48\">\n          <span data-s3d-line className=\"absolute inset-0 block bg-gradient-to-b from-transparent via-white/60 to-transparent\" />\n        </div>\n      </section>\n\n      {/* The stage that gets pinned. The cards sit stacked inside it. */}\n      <ul data-s3d-stage className=\"relative m-0 block h-[var(--s3d-h)] list-none overflow-hidden p-0 motion-reduce:h-auto motion-reduce:overflow-visible\">\n        {cards.map((card, index) => (\n          <li\n            key={`${index}-${card.image}`}\n            data-s3d-slot\n            // The first card fills the stage; the others wait just below it. Reduced motion stacks them in the flow.\n            className={`absolute left-0 flex h-[var(--s3d-h)] w-full items-center justify-center p-4 md:p-8 motion-reduce:static motion-reduce:py-10 ${index === 0 ? \"top-0\" : \"top-full\"}`}\n            style={{ perspective, transformStyle: \"preserve-3d\" }}\n          >\n            <article\n              data-s3d-card\n              className=\"relative h-[80%] w-[92%] max-w-[500px] overflow-hidden rounded-2xl bg-black/40 md:w-1/2 md:max-w-[360px] md:min-w-[280px] md:rounded-[2rem] lg:w-[35%] lg:max-w-[500px] lg:min-w-[380px]\"\n              style={{ willChange: \"transform, opacity\" }}\n            >\n              <img\n                src={card.image}\n                alt={card.imageAlt ?? card.name}\n                className=\"absolute inset-0 h-full w-full object-cover\"\n                loading={index < 2 ? \"eager\" : \"lazy\"}\n                decoding=\"async\"\n                draggable={false}\n              />\n              <div className=\"absolute inset-0 flex flex-col justify-end bg-gradient-to-t from-black/90 via-black/40 via-30% to-transparent to-60% p-6 md:p-10\">\n                <span className=\"mb-3 block text-[11px] font-semibold tracking-[0.3em] text-white/50 uppercase\">{card.number}</span>\n                <h3 className=\"mb-2 text-[clamp(22px,2.2vw,32px)] leading-[1.1] font-extrabold tracking-[-0.02em] text-white\">{card.name}</h3>\n                <p className=\"text-sm font-medium tracking-[0.05em] text-white/70 md:text-[15px]\">{card.role}</p>\n                <div className=\"mt-4 h-[3px] w-10 rounded-full\" style={{ backgroundImage: gradient }} />\n                {card.quote ? <p className=\"mt-4 text-xs leading-normal text-white/50 italic md:text-[13px]\">&ldquo;{card.quote}&rdquo;</p> : null}\n              </div>\n            </article>\n          </li>\n        ))}\n      </ul>\n    </div>\n  );\n}\n"}],"categories":["scroll","scrolltrigger","pin","3d","cards","team"],"docs":"import { Section3d } from \"@/components/dimaac/section-3d\". Give its parent a size: it fills it. Docs, playground and props: https://ui.dimaac.com/components/section-3d","meta":{"tier":"free","preview":"https://ui.dimaac.com/components/section-3d","credits":[{"by":"studiomiklus","url":"https://www.lummi.ai/photo/striking-portrait-jodiu","what":"Portrait (Marcus Chen)","source":"Lummi","licence":"Lummi License"},{"by":"Edita","url":"https://www.lummi.ai/photo/striking-portrait-kfent","what":"Portrait (Elena Voss)","source":"Lummi","licence":"Lummi License"},{"by":"Shaun Duval","url":"https://www.lummi.ai/photo/portrait-with-neon-sunglasses-gindg","what":"Portrait (Priya Nair)","source":"Lummi","licence":"Lummi License"},{"by":"SHIHO","url":"https://www.lummi.ai/photo/vibrant-woman-portrait-htofy","what":"Portrait (Sofia Reyes)","source":"Lummi","licence":"Lummi License"},{"by":"Amino","url":"https://www.lummi.ai/photo/vivid-portrait-on-pink-oxbow","what":"Portrait (Ava Mitchell)","source":"Lummi","licence":"Lummi License"},{"by":"Steph Meade","url":"https://www.lummi.ai/photo/expressive-minimal-portrait-jrfyc","what":"Portrait (Kai Tanaka)","source":"Lummi","licence":"Lummi License"},{"by":"Lloyd Creates","url":"https://www.lummi.ai/photo/poised-portrait-ooi9b","what":"Portrait (Liam Foster)","source":"Lummi","licence":"Lummi License"},{"by":"Amino","url":"https://www.lummi.ai/photo/stylish-portrait-8inth","what":"Portrait (Nina Patel)","source":"Lummi","licence":"Lummi License"}]}}