shadimage
spaceui
blocktemplate-immersive-lenssource: ok · hit

Immersive Lens Portfolio

Full-page immersive photography template with Three.js WebGL distortion, inertial dragging, and GSAP lightbox.

npx shadcn add "https://www.spaceui.one/r/template-immersive-lens.json"
free

Where it goes

gallery

Visual style

3d

Interaction

drag

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · heavythree
animationmotiongsap
webglthree

Registry dependencies

https://www.spaceui.one/r/lib-three-types.jsonhttps://www.spaceui.one/r/lib-next-image-url.jsonhttps://www.spaceui.one/r/block-lens-carousel.jsonhttps://www.spaceui.one/r/lib-utils.json

Files13 · source cached 10/8/2026, 11:21:35 PM

src/registry/templates/immersive-lens/index.tsx
'use client'

export const instant = false

import { ClientPage } from './components/client-page'

export default ClientPage
src/registry/templates/immersive-lens/app/page.tsx
import type { Metadata } from 'next'
import { ClientPage } from '../components/client-page'

export const metadata: Metadata = {
  title: 'Immersive Lens Portfolio',
  description: 'Full-page immersive photography template with Three.js WebGL distortion and GSAP lightbox.',
}

export default function Page() {
  return <ClientPage />
}
src/registry/templates/immersive-lens/app/layout.tsx
import type { Metadata } from 'next'

export const metadata: Metadata = {
  title: 'Immersive Lens Portfolio',
  description: 'Full-page immersive photography template with Three.js WebGL distortion and GSAP lightbox.',
}

export default function Layout({ children }: { children: React.ReactNode }) {
  return (
    <div className="fixed inset-0 overflow-hidden bg-white text-black antialiased selection:bg-black selection:text-white">
      {children}
    </div>
  )
}
src/registry/templates/immersive-lens/app/loading.tsx
export default function Loading() {
  return (
    <div className="fixed inset-0 z-50 flex items-center justify-center bg-white text-neutral-900">
      <div className="flex flex-col items-center gap-3">
        <div className="text-xl font-medium tracking-tight">Immersive Lens</div>
        <div className="text-xs uppercase tracking-[0.25em] text-neutral-400">Loading WebGL Scene</div>
      </div>
    </div>
  )
}
src/registry/templates/immersive-lens/components/client-page.tsx
'use client'

import React, { useEffect, useRef, useState } from 'react'
import { AnimatePresence, motion } from 'motion/react'
import { categories, ease, galleryImages } from '../lib/data'
import { CustomCursor } from './custom-cursor'
import { GlassDock } from './glass-dock'
import { HorizontalGallery } from './horizontal-gallery'
import { LightboxModal } from './lightbox-modal'
import { VerticalGallery } from '@/components/blocks/lens-carousel/vertical-gallery'
import { LensWebGLLayer } from '@/components/blocks/lens-carousel/lens-webgl-layer'
import { useCustomCursor } from '../hooks/use-custom-cursor'
import { useHorizontalDrag } from '../hooks/use-horizontal-drag'
import { useLightbox } from '../hooks/use-lightbox'
import { cn } from '@/lib/utils'

export function ClientPage() {
  const [loading, setLoading] = useState(true)
  const [mode, setMode] = useState<'vertical' | 'horizontal'>('vertical')
  const [layoutAnimating, setLayoutAnimating] = useState(false)
  const [headerHidden, setHeaderHidden] = useState(false)
  const [categoryIndex, setCategoryIndex] = useState(0)
  const [hoveredIndex, setHoveredIndex] = useState<number | null>(null)

  const verticalRef = useRef<HTMLDivElement>(null)
  const lastScrollTop = useRef(0)

  const { cursorActive, setCursorActive, cursorLabel, setCursorLabel, cursorX, cursorY } = useCustomCursor()

  const { horizontalRef, horizontalButtonRefs, horizontalTarget, onHorizontalWheel, beginDrag, endDrag, cancelDrag } =
    useHorizontalDrag({
      active: mode === 'horizontal',
      onOpenImage: (idx) => openImage(idx),
    })

  const {
    lightboxIndex,
    fixedPhase,
    fixedDirection,
    fixedNavIdle,
    fixedBackdropRef,
    fixedFrameRef,
    imageButtonRefs,
    openImage,
    closeImage,
    moveImage,
    scheduleFixedIdle,
  } = useLightbox({
    mode,
    verticalRef,
    horizontalRef,
    horizontalButtonRefs,
    horizontalTarget,
    onCursorStateChange: (active, label) => {
      setCursorActive(active)
      setCursorLabel(label)
    },
  })

  const currentCategory = categories[categoryIndex] ?? categories[0]
  const isFixed = lightboxIndex !== null
  const navPreviewIndex = hoveredIndex ?? lightboxIndex
  const navImage = navPreviewIndex === null ? currentCategory.thumb : galleryImages[navPreviewIndex]
  const navSubLabel = isFixed ? 'Gallery' : hoveredIndex === null ? 'Category' : 'Details'
  const navTitle = isFixed
    ? `${(lightboxIndex ?? 0) + 1} — ${galleryImages.length}`
    : hoveredIndex === null
      ? 'Details'
      : 'Gallery'

  useEffect(() => {
    const timer = window.setTimeout(() => setLoading(false), 1050)
    return () => window.clearTimeout(timer)
  }, [])

  const toggleMode = () => {
    if (layoutAnimating) return
    setLayoutAnimating(true)
    window.setTimeout(() => {
      setMode((current) => (current === 'vertical' ? 'horizontal' : 'vertical'))
      window.setTimeout(() => setLayoutAnimating(false), 80)
    }, 600)
  }

  const onVerticalScroll = () => {
    const element = verticalRef.current
    if (!element) return
    const current = element.scrollTop
    setHeaderHidden(current > 40 && current > lastScrollTop.current)
    lastScrollTop.current = current
  }

  const handleImageHover = (active: boolean, index: number) => {
    setCursorActive(active)
    setCursorLabel('Zoom')
    if (fixedPhase !== 'closed') return
    if (active) {
      setHoveredIndex(index)
    } else {
      window.setTimeout(() => {
        setHoveredIndex((current) => (current === index ? null : current))
      }, 160)
    }
  }

  return (
    <div
      className={cn('fixed inset-0 z-60 isolate overflow-hidden', {
        'cursor-none': cursorActive || lightboxIndex !== null,
      })}
    >
      <LensWebGLLayer
        active={mode === 'vertical'}
        hiddenIndex={lightboxIndex}
        images={galleryImages}
        imageRefs={imageButtonRefs}
        scrollRef={verticalRef}
      />

      <div className="relative size-full overflow-hidden">
        {mode === 'vertical' ? (
          <VerticalGallery
            scrollRef={verticalRef}
            images={galleryImages}
            registerRef={(node, idx) => {
              imageButtonRefs.current[idx] = node
            }}
            lightboxIndex={lightboxIndex}
            onScroll={onVerticalScroll}
            onOpenImage={openImage}
            onHover={handleImageHover}
          />
        ) : (
          <HorizontalGallery
            horizontalRef={horizontalRef}
            images={galleryImages}
            registerRef={(node, idx) => {
              horizontalButtonRefs.current[idx] = node
            }}
            lightboxIndex={lightboxIndex}
            onWheel={onHorizontalWheel}
            onPointerDown={beginDrag}
            onPointerUp={endDrag}
            onPointerCancel={cancelDrag}
            onOpenImage={openImage}
            onHover={handleImageHover}
          />
        )}
      </div>

      <LightboxModal
        lightboxIndex={lightboxIndex}
        fixedDirection={fixedDirection}
        fixedBackdropRef={fixedBackdropRef}
        fixedFrameRef={fixedFrameRef}
        images={galleryImages}
        onClose={closeImage}
        onMove={moveImage}
        onHoverControl={(active, label) => {
          setCursorActive(active)
          if (label) setCursorLabel(label)
          scheduleFixedIdle()
        }}
      />

      <CustomCursor cursorX={cursorX} cursorY={cursorY} cursorActive={cursorActive} cursorLabel={cursorLabel} />

      <GlassDock
        fixedNavIdle={fixedNavIdle}
        isFixed={isFixed}
        lightboxIndex={lightboxIndex}
        hoveredIndex={hoveredIndex}
        mode={mode}
        totalImages={galleryImages.length}
        fixedDirection={fixedDirection}
        navImage={navImage}
        navSubLabel={navSubLabel}
        navTitle={navTitle}
        onClose={closeImage}
        onOpenImage={openImage}
        onToggleMode={toggleMode}
        onMoveImage={moveImage}
      />

      <AnimatePresence>
        {loading && (
          <motion.div
            initial={{ opacity: 1 }}
            exit={{ opacity: 0 }}
            transition={{ duration: 0.65, ease }}
            className="pointer-events-none fixed inset-0 z-50 flex items-center justify-center bg-white text-neutral-900"
          >
            <div className="flex flex-col items-center gap-3">
              <div className="text-xl font-medium tracking-tight">Immersive Lens</div>
              <div className="text-xs uppercase tracking-[0.25em] text-neutral-400">Loading WebGL Scene</div>
            </div>
          </motion.div>
        )}
      </AnimatePresence>
    </div>
  )
}
src/registry/templates/immersive-lens/components/horizontal-gallery.tsx
'use client'

import React, {
  type PointerEvent as ReactPointerEvent,
  type RefObject,
  type WheelEvent as ReactWheelEvent,
} from 'react'
import { motion } from 'motion/react'
import { ease } from '../lib/data'

export interface HorizontalGalleryProps {
  horizontalRef: RefObject<HTMLDivElement | null>
  images: readonly string[]
  lightboxIndex: number | null
  onWheel: (event: ReactWheelEvent<HTMLDivElement>) => void
  onPointerDown: (event: ReactPointerEvent<HTMLDivElement>) => void
  onPointerUp: (event: ReactPointerEvent<HTMLDivElement>) => void
  onPointerCancel: (event: ReactPointerEvent<HTMLDivElement>) => void
  registerRef: (node: HTMLButtonElement | null, index: number) => void
  onOpenImage: (index: number) => void
  onHover: (active: boolean, index: number) => void
}

export function HorizontalGallery({
  horizontalRef,
  images,
  lightboxIndex,
  onWheel,
  onPointerDown,
  onPointerUp,
  onPointerCancel,
  registerRef,
  onOpenImage,
  onHover,
}: HorizontalGalleryProps) {
  return (
    <div
      ref={horizontalRef}
      onWheel={onWheel}
      onPointerDown={onPointerDown}
      onPointerUp={onPointerUp}
      onPointerCancel={onPointerCancel}
      className="relative z-10 flex h-full w-full touch-pan-y select-none items-center overflow-hidden"
    >
      <div className="flex shrink-0 items-center pl-[clamp(24px,5vw,72px)] pr-[clamp(24px,5vw,72px)]">
        {images.map((src, index) => {
          return (
            <motion.button
              key={src}
              ref={(node) => registerRef(node, index)}
              type="button"
              onClick={() => onOpenImage(index)}
              onMouseEnter={() => onHover(true, index)}
              onMouseLeave={() => onHover(false, index)}
              initial={{ opacity: 0, x: 40 }}
              animate={{ opacity: lightboxIndex === index ? 0 : 1, x: 0 }}
              transition={{ duration: 0.8, ease }}
              className="group relative mr-[clamp(24px,4vw,60px)] block shrink-0 cursor-zoom-in bg-transparent"
              data-horizontal-lens-index={index}
              style={{
                width: 'clamp(280px, 38vw, 560px)',
                aspectRatio: '16 / 10',
              }}
            >
              <span className="sr-only">{`Lens ${String(index + 1).padStart(2, '0')}`}</span>
              <span className="pointer-events-none absolute inset-0 bg-white/0 transition-colors duration-500 group-hover:bg-white/[.025]" />
            </motion.button>
          )
        })}
      </div>
    </div>
  )
}
src/registry/templates/immersive-lens/components/glass-dock.tsx
'use client'

import React from 'react'
import NextImage from 'next/image'
import { AnimatePresence, motion } from 'motion/react'
import { ease, glass } from '../lib/data'

export function GlassSheen() {
  return (
    <>
      <span className="pointer-events-none absolute inset-0 bg-gradient-to-b from-white/10 via-white/[.025] to-black/5" />
      <span className="pointer-events-none absolute inset-0 [corner-shape:inherit] rounded-[inherit] shadow-[inset_0_1px_rgba(255,255,255,.36),inset_0_-1px_rgba(0,0,0,.05)]" />
    </>
  )
}

export interface GlassDockProps {
  fixedNavIdle: boolean
  isFixed: boolean
  lightboxIndex: number | null
  hoveredIndex: number | null
  mode: 'vertical' | 'horizontal'
  totalImages: number
  fixedDirection: number
  navImage: string
  navSubLabel: string
  navTitle: string
  onClose: () => void
  onOpenImage: (index: number) => void
  onToggleMode: () => void
  onMoveImage: (direction: number) => void
}

export function GlassDock({
  fixedNavIdle,
  isFixed,
  lightboxIndex,
  hoveredIndex,
  mode,
  totalImages,
  fixedDirection,
  navImage,
  navSubLabel,
  navTitle,
  onClose,
  onOpenImage,
  onToggleMode,
  onMoveImage,
}: GlassDockProps) {
  return (
    <nav
      className={`fixed bottom-[10px] left-1/2 z-[100] flex -translate-x-1/2 items-center gap-[5px] text-white transition-[transform,opacity] duration-500 ease-[cubic-bezier(.25,.46,.45,.94)] md:bottom-[30px] ${
        fixedNavIdle && isFixed
          ? 'pointer-events-none translate-y-[calc(100%+40px)] opacity-0'
          : 'translate-y-0 opacity-100'
      }`}
    >
      <button
        type="button"
        aria-label="Back"
        onClick={() => {
          if (lightboxIndex !== null) onClose()
          else if (typeof window !== 'undefined' && window.history.length > 1) window.history.back()
          else if (typeof window !== 'undefined') window.location.href = '/'
        }}
        className={`group relative h-[50px] overflow-hidden rounded-full transition-[width,margin,opacity,transform,border-radius] duration-500 ease-[cubic-bezier(.25,.46,.45,.94)] hover:rounded-[8px] md:h-[70px] ${
          isFixed
            ? '-mr-[5px] w-0 scale-50 opacity-0 pointer-events-none'
            : 'w-[50px] scale-100 opacity-100 md:w-[70px]'
        } ${glass}`}
      >
        <GlassSheen />
        <svg
          className="absolute left-1/2 top-1/2 size-5 -translate-x-1/2 -translate-y-1/2 md:size-6"
          viewBox="0 0 24 24"
          aria-hidden="true"
        >
          <path d="M4,12h16v5M10.5,19l-7-7M3.5,12l7-7" fill="none" stroke="currentColor" strokeWidth="2" />
        </svg>
      </button>

      <div
        className={`relative flex h-[50px] items-center gap-[10px] overflow-hidden rounded-[8px] p-[10px] transition-[width] duration-500 ease-[cubic-bezier(.25,.46,.45,.94)] md:h-[70px] ${
          isFixed ? 'w-[220px] md:w-[260px]' : 'w-[152px] md:w-[180px]'
        } ${glass}`}
      >
        {hoveredIndex !== null && !isFixed && (
          <button
            type="button"
            aria-label={`Open image ${hoveredIndex + 1}`}
            onClick={() => onOpenImage(hoveredIndex)}
            className="absolute inset-0 z-20"
          />
        )}
        <GlassSheen />
        <span className="relative size-[30px] flex-none overflow-hidden rounded-full bg-black/20 shadow-inner md:size-[50px]">
          <AnimatePresence initial={false}>
            <motion.div
              key={navImage}
              initial={{ opacity: 0 }}
              animate={{ opacity: 1 }}
              exit={{ opacity: 0 }}
              transition={{ duration: 0.35, ease: 'easeOut' }}
              className="absolute inset-0"
            >
              <NextImage src={navImage} alt="" fill sizes="70px" className="object-cover" />
            </motion.div>
          </AnimatePresence>
        </span>
        <span className="relative h-[2.4em] min-w-0 flex-1 overflow-hidden text-center uppercase leading-none">
          <AnimatePresence initial={false} mode="popLayout">
            <motion.span
              key={isFixed ? 'gallery-fixed' : `${navSubLabel}-${navTitle}`}
              initial={{ y: '120%', opacity: 0 }}
              animate={{ y: '0%', opacity: 1 }}
              exit={{ y: '-120%', opacity: 0 }}
              transition={{ duration: 0.35, ease: 'easeOut' }}
              className="absolute inset-0 flex flex-col items-center justify-center"
            >
              <span className="text-[10px] normal-case text-white/60">{navSubLabel}</span>
              <span className="mt-[2px] max-w-full truncate text-[14px] font-semibold md:text-[16px]">
                {isFixed ? (
                  <span className="flex items-center justify-center">
                    <span className="relative inline-block h-[1.25em] min-w-[2ch] overflow-hidden text-right">
                      <AnimatePresence initial={false} custom={fixedDirection} mode="popLayout">
                        <motion.span
                          key={lightboxIndex}
                          custom={fixedDirection}
                          initial={{
                            y: fixedDirection > 0 ? '100%' : '-100%',
                            opacity: 0,
                          }}
                          animate={{ y: '0%', opacity: 1 }}
                          exit={{
                            y: fixedDirection > 0 ? '-100%' : '100%',
                            opacity: 0,
                          }}
                          transition={{ duration: 0.35, ease: 'easeOut' }}
                          className="block"
                        >
                          {(lightboxIndex ?? 0) + 1}
                        </motion.span>
                      </AnimatePresence>
                    </span>
                    <span className="mx-[5px] h-px w-[15px] bg-white" />
                    <span>{totalImages}</span>
                  </span>
                ) : (
                  navTitle
                )}
              </span>
            </motion.span>
          </AnimatePresence>
        </span>
        <AnimatePresence>
          {isFixed && (
            <motion.span
              initial={{ width: 0, opacity: 0 }}
              animate={{ width: 'auto', opacity: 1 }}
              exit={{ width: 0, opacity: 0 }}
              transition={{ duration: 0.5, ease }}
              className="relative z-30 flex flex-none items-center gap-[5px] overflow-hidden"
            >
              <button
                type="button"
                aria-label="Previous image"
                onClick={() => onMoveImage(-1)}
                className="flex h-5 w-7 items-center justify-center rounded-full bg-black/20 shadow-inner md:h-6 md:w-9"
              >
                <svg viewBox="0 0 16 16" className="size-4">
                  <path d="M8,6v1M8,6.5l-3,3M11,9.5l-3-3" fill="none" stroke="currentColor" strokeWidth="1.5" />
                </svg>
              </button>
              <button
                type="button"
                aria-label="Next image"
                onClick={() => onMoveImage(1)}
                className="flex h-5 w-7 items-center justify-center rounded-full bg-black/20 shadow-inner md:h-6 md:w-9"
              >
                <svg viewBox="0 0 16 16" className="size-4">
                  <path d="M8,10.5v-1M5,7l3,3M8,10l3-3" fill="none" stroke="currentColor" strokeWidth="1.5" />
                </svg>
              </button>
            </motion.span>
          )}
        </AnimatePresence>
      </div>

      <button
        type="button"
        aria-label={isFixed ? 'Close gallery' : hoveredIndex !== null ? `Open image ${hoveredIndex + 1}` : 'Layout'}
        onClick={() => {
          if (isFixed) onClose()
          else if (hoveredIndex !== null) onOpenImage(hoveredIndex)
          else onToggleMode()
        }}
        className={`group relative size-[50px] overflow-hidden rounded-full transition-[border-radius] duration-[350ms] hover:rounded-[8px] md:size-[70px] ${glass}`}
      >
        <GlassSheen />
        <AnimatePresence initial={false} mode="wait">
          <motion.svg
            key={isFixed ? 'close' : hoveredIndex !== null ? 'plus' : mode}
            initial={{ opacity: 0, scale: 0.65, rotate: -45 }}
            animate={{ opacity: 1, scale: 1, rotate: 0 }}
            exit={{ opacity: 0, scale: 0.65, rotate: 45 }}
            transition={{ duration: 0.35, ease }}
            className="absolute left-1/2 top-1/2 size-5 -translate-x-1/2 -translate-y-1/2 md:size-6"
            viewBox="0 0 24 24"
            aria-hidden="true"
          >
            <path
              d={
                isFixed
                  ? 'M5,5L19,19M19,5L5,19'
                  : hoveredIndex !== null
                    ? 'M12,4V20M4,12H20'
                    : mode === 'vertical'
                      ? 'M18,2v3H6v-3M12,15H6V9h12v6h-6M6,22v-3h12v3'
                      : 'M2,6h3v12H2M15,12V6H9v12h6v-6M22,18h-3V6h3'
              }
              fill="none"
              stroke="currentColor"
              strokeWidth="2"
            />
          </motion.svg>
        </AnimatePresence>
      </button>
    </nav>
  )
}
src/registry/templates/immersive-lens/components/lightbox-modal.tsx
'use client'

import React, { type RefObject } from 'react'
import NextImage from 'next/image'
import { AnimatePresence, motion } from 'motion/react'

export interface LightboxModalProps {
  lightboxIndex: number | null
  fixedDirection: number
  images: readonly string[]
  fixedBackdropRef: RefObject<HTMLDivElement | null>
  fixedFrameRef: RefObject<HTMLDivElement | null>
  onClose: () => void
  onMove: (direction: number) => void
  onHoverControl: (active: boolean, label?: string) => void
}

export function LightboxModal({
  lightboxIndex,
  fixedDirection,
  images,
  fixedBackdropRef,
  fixedFrameRef,
  onClose,
  onMove,
  onHoverControl,
}: LightboxModalProps) {
  return (
    <AnimatePresence>
      {lightboxIndex !== null && (
        <div className="fixed inset-0 z-[80] overflow-hidden">
          <div ref={fixedBackdropRef} className="pointer-events-none absolute inset-0 bg-black opacity-0" />
          <div ref={fixedFrameRef} className="pointer-events-none fixed z-10 overflow-hidden bg-black">
            <AnimatePresence initial={false} mode="sync" custom={fixedDirection}>
              <motion.div
                key={images[lightboxIndex]}
                custom={fixedDirection}
                initial={{ opacity: 0 }}
                animate={{ opacity: 1 }}
                exit={{ opacity: 0 }}
                transition={{ duration: 0.36, ease: 'easeOut' }}
                className="absolute inset-0"
              >
                <NextImage src={images[lightboxIndex]} alt="" fill sizes="100vw" className="object-cover" priority />
              </motion.div>
            </AnimatePresence>
          </div>
          <button
            type="button"
            aria-label="Close image"
            onClick={onClose}
            onMouseEnter={() => onHoverControl(true, 'Close')}
            onMouseLeave={() => onHoverControl(false)}
            className="absolute inset-0 z-[11] cursor-zoom-out"
          />
          <button
            type="button"
            aria-label="Previous image"
            onClick={(event) => {
              event.stopPropagation()
              onMove(-1)
            }}
            onMouseEnter={() => onHoverControl(true, 'Prev')}
            onMouseLeave={() => onHoverControl(false, 'Close')}
            className="absolute inset-y-0 left-0 z-20 w-[180px] max-w-[18vw] cursor-w-resize"
          />
          <button
            type="button"
            aria-label="Next image"
            onClick={(event) => {
              event.stopPropagation()
              onMove(1)
            }}
            onMouseEnter={() => onHoverControl(true, 'Next')}
            onMouseLeave={() => onHoverControl(false, 'Close')}
            className="absolute inset-y-0 right-0 z-20 w-[180px] max-w-[18vw] cursor-e-resize"
          />
        </div>
      )}
    </AnimatePresence>
  )
}
src/registry/templates/immersive-lens/components/custom-cursor.tsx
'use client'

import React from 'react'
import { motion, type MotionValue } from 'motion/react'
import { ease, glass } from '../lib/data'

export interface CustomCursorProps {
  cursorX: MotionValue<number>
  cursorY: MotionValue<number>
  cursorActive: boolean
  cursorLabel: string
}

export function CustomCursor({ cursorX, cursorY, cursorActive, cursorLabel }: CustomCursorProps) {
  return (
    <motion.div
      aria-hidden="true"
      style={{
        x: cursorX,
        y: cursorY,
        translateX: '-50%',
        translateY: 'calc(-100% - 15px)',
      }}
      animate={{
        opacity: cursorActive ? 1 : 0,
        clipPath: cursorActive ? 'inset(0 0 0 0 round 8px)' : 'inset(0 50% 0 50% round 8px)',
      }}
      transition={{ duration: cursorActive ? 0.45 : 0.25, ease }}
      className={`pointer-events-none fixed left-0 top-0 z-[110] inline-flex min-w-[60px] items-center justify-center overflow-hidden rounded-[8px] px-[15px] text-[14px] font-semibold uppercase leading-[34px] text-white ${glass}`}
    >
      <span className="truncate">{cursorLabel}</span>
    </motion.div>
  )
}
src/registry/templates/immersive-lens/hooks/use-custom-cursor.ts
'use client'

import { useEffect, useState } from 'react'
import { useMotionValue, useSpring } from 'motion/react'

export function useCustomCursor() {
  const [cursorActive, setCursorActive] = useState(false)
  const [cursorLabel, setCursorLabel] = useState('Zoom')

  const mouseX = useMotionValue(0)
  const mouseY = useMotionValue(0)
  const cursorX = useSpring(mouseX, { stiffness: 600, damping: 48, mass: 0.22 })
  const cursorY = useSpring(mouseY, { stiffness: 600, damping: 48, mass: 0.22 })

  useEffect(() => {
    const onMove = (event: PointerEvent) => {
      mouseX.set(event.clientX)
      mouseY.set(event.clientY)
    }

    window.addEventListener('pointermove', onMove)
    return () => window.removeEventListener('pointermove', onMove)
  }, [mouseX, mouseY])

  return {
    cursorActive,
    setCursorActive,
    cursorLabel,
    setCursorLabel,
    cursorX,
    cursorY,
    mouseX,
    mouseY,
  }
}
src/registry/templates/immersive-lens/hooks/use-horizontal-drag.ts
'use client'

import { useEffect, useRef, type PointerEvent as ReactPointerEvent, type WheelEvent as ReactWheelEvent } from 'react'

export interface UseHorizontalDragOptions {
  active: boolean
  onOpenImage: (index: number) => void
}

export function useHorizontalDrag({ active, onOpenImage }: UseHorizontalDragOptions) {
  const horizontalRef = useRef<HTMLDivElement>(null)
  const horizontalButtonRefs = useRef<(HTMLButtonElement | null)[]>([])
  const horizontalTarget = useRef(0)
  const horizontalVelocity = useRef(0)
  const horizontalRaf = useRef<number | null>(null)

  const dragState = useRef({
    active: false,
    startX: 0,
    scrollLeft: 0,
    distance: 0,
    lastX: 0,
    lastTime: 0,
    pressedIndex: null as number | null,
  })

  useEffect(() => {
    if (!active) return
    const element = horizontalRef.current
    if (!element) return
    horizontalTarget.current = element.scrollLeft

    const update = () => {
      if (!dragState.current.active) {
        horizontalTarget.current += horizontalVelocity.current
        horizontalVelocity.current *= 0.92
      }
      const max = Math.max(element.scrollWidth - element.clientWidth, 0)
      horizontalTarget.current = Math.max(0, Math.min(horizontalTarget.current, max))
      element.scrollLeft += (horizontalTarget.current - element.scrollLeft) * 0.105
      horizontalRaf.current = window.requestAnimationFrame(update)
    }

    horizontalRaf.current = window.requestAnimationFrame(update)
    return () => {
      if (horizontalRaf.current !== null) {
        window.cancelAnimationFrame(horizontalRaf.current)
      }
      horizontalRaf.current = null
    }
  }, [active])

  useEffect(() => {
    const onMove = (event: PointerEvent) => {
      if (dragState.current.active && horizontalRef.current) {
        const now = performance.now()
        const elapsed = Math.max(now - dragState.current.lastTime, 8)
        const delta = dragState.current.lastX - event.clientX
        dragState.current.distance = Math.abs(event.clientX - dragState.current.startX)
        horizontalTarget.current = dragState.current.scrollLeft - (event.clientX - dragState.current.startX) * 1.05
        horizontalVelocity.current = (delta / elapsed) * 16
        dragState.current.lastX = event.clientX
        dragState.current.lastTime = now
      }
    }

    window.addEventListener('pointermove', onMove)
    return () => window.removeEventListener('pointermove', onMove)
  }, [])

  const onHorizontalWheel = (event: ReactWheelEvent<HTMLDivElement>) => {
    horizontalTarget.current += (event.deltaY + event.deltaX) * 1.08
    horizontalVelocity.current +=
      Math.sign(event.deltaY + event.deltaX) * Math.min(Math.abs(event.deltaY + event.deltaX) * 0.02, 0.68) * 18.5
  }

  const beginDrag = (event: ReactPointerEvent<HTMLDivElement>) => {
    const target =
      event.target instanceof Element ? event.target.closest<HTMLElement>('[data-horizontal-lens-index]') : null
    const pressedIndex = Number(target?.dataset.horizontalLensIndex)

    dragState.current = {
      active: true,
      startX: event.clientX,
      scrollLeft: event.currentTarget.scrollLeft,
      distance: 0,
      lastX: event.clientX,
      lastTime: performance.now(),
      pressedIndex: Number.isInteger(pressedIndex) ? pressedIndex : null,
    }
    horizontalTarget.current = event.currentTarget.scrollLeft
    horizontalVelocity.current = 0
    event.currentTarget.setPointerCapture(event.pointerId)
  }

  const endDrag = (event: ReactPointerEvent<HTMLDivElement>) => {
    const { distance, pressedIndex } = dragState.current
    dragState.current.active = false
    horizontalVelocity.current = Math.max(-68, Math.min(horizontalVelocity.current * 1.85, 68))
    if (event.currentTarget.hasPointerCapture(event.pointerId)) {
      event.currentTarget.releasePointerCapture(event.pointerId)
    }
    if (distance < 6 && pressedIndex !== null) onOpenImage(pressedIndex)
  }

  const cancelDrag = (event: ReactPointerEvent<HTMLDivElement>) => {
    dragState.current.active = false
    dragState.current.pressedIndex = null
    if (event.currentTarget.hasPointerCapture(event.pointerId)) {
      event.currentTarget.releasePointerCapture(event.pointerId)
    }
  }

  return {
    horizontalRef,
    horizontalButtonRefs,
    horizontalTarget,
    onHorizontalWheel,
    beginDrag,
    endDrag,
    cancelDrag,
  }
}
src/registry/templates/immersive-lens/hooks/use-lightbox.ts
'use client'

import { useCallback, useEffect, useRef, useState } from 'react'
import gsap from 'gsap'
import { galleryImages } from '../lib/data'
import { galleryImageSizes } from '@/components/blocks/lens-carousel/lens-webgl-layer'

function setFixedBackdropMask(element: HTMLDivElement, progress: number, direction: 1 | -1) {
  const clamped = Math.max(0, Math.min(progress, 1))
  const arcProgress = Math.sin(clamped * Math.PI)
  const aspectScale = Math.max(0.35, Math.min(window.innerWidth / Math.max(window.innerHeight, 1), 1))
  const edgePoints = Array.from({ length: 21 }, (_, index) => {
    const x = index / 20
    const arc = Math.sin(x * Math.PI) * 0.1 * aspectScale * arcProgress
    const edge = Math.max(0, Math.min(clamped + arc, 1))
    return `${x * 100}% ${(1 - edge) * 100}%`
  })
  const polygon =
    direction > 0
      ? `polygon(${edgePoints.join(',')},100% 100%,0% 100%)`
      : `polygon(0% 0%,100% 0%,${edgePoints.reverse().join(',')})`

  element.style.clipPath = polygon
  ;(element.style as any).webkitClipPath = polygon
}

export interface UseLightboxOptions {
  mode: 'vertical' | 'horizontal'
  verticalRef: React.RefObject<HTMLDivElement | null>
  horizontalRef: React.RefObject<HTMLDivElement | null>
  horizontalButtonRefs: React.RefObject<(HTMLButtonElement | null)[]>
  horizontalTarget: React.RefObject<number>
  onCursorStateChange?: (active: boolean, label: string) => void
}

export function useLightbox({
  mode,
  verticalRef,
  horizontalRef,
  horizontalButtonRefs,
  horizontalTarget,
  onCursorStateChange,
}: UseLightboxOptions) {
  const [lightboxIndex, setLightboxIndex] = useState<number | null>(null)
  const [fixedPhase, setFixedPhase] = useState<'closed' | 'opening' | 'open' | 'closing'>('closed')
  const [fixedDirection, setFixedDirection] = useState(1)
  const [fixedNavIdle, setFixedNavIdle] = useState(false)

  const fixedBackdropRef = useRef<HTMLDivElement>(null)
  const fixedFrameRef = useRef<HTMLDivElement>(null)
  const imageButtonRefs = useRef<(HTMLButtonElement | null)[]>([])
  const fixedSourceRect = useRef<DOMRect | null>(null)
  const fixedScrollPosition = useRef(0)
  const fixedIdleTimer = useRef<any>(null)

  const scheduleFixedIdle = useCallback(() => {
    if (fixedIdleTimer.current) window.clearTimeout(fixedIdleTimer.current)
    setFixedNavIdle(false)
    fixedIdleTimer.current = window.setTimeout(() => {
      setFixedNavIdle(true)
      onCursorStateChange?.(false, 'Zoom')
    }, 2000)
  }, [onCursorStateChange])

  const getFixedTargetRect = useCallback((index: number) => {
    const sourceSize = galleryImageSizes[index] ?? [2560, 1707]
    const ratio = sourceSize[0] / sourceSize[1]
    const horizontalPadding = window.innerWidth <= 640 ? 20 : 60
    const verticalPadding = window.innerWidth <= 640 ? 20 : 60
    const maxWidth = Math.min(1080, window.innerWidth - horizontalPadding * 2)
    const maxHeight = window.innerHeight - verticalPadding * 2
    const width = Math.min(maxWidth, maxHeight * ratio)
    const height = width / ratio

    return {
      left: (window.innerWidth - width) / 2,
      top: (window.innerHeight - height) / 2,
      width,
      height,
    }
  }, [])

  const openImage = useCallback(
    (index: number) => {
      if (fixedPhase !== 'closed') return
      const sourceButton = mode === 'vertical' ? imageButtonRefs.current[index] : horizontalButtonRefs.current?.[index]
      const source = sourceButton
      fixedScrollPosition.current =
        mode === 'vertical' ? (verticalRef.current?.scrollTop ?? 0) : (horizontalRef.current?.scrollLeft ?? 0)
      fixedSourceRect.current = source?.getBoundingClientRect() ?? null

      setLightboxIndex(index)
      setFixedDirection(1)
      setFixedPhase('opening')
      onCursorStateChange?.(false, 'Close')
    },
    [fixedPhase, horizontalButtonRefs, horizontalRef, mode, onCursorStateChange, verticalRef],
  )

  const closeImage = useCallback(() => {
    if (lightboxIndex === null || fixedPhase === 'closing' || fixedPhase === 'closed') return

    setFixedPhase('closing')
    setFixedNavIdle(false)
    if (fixedIdleTimer.current) window.clearTimeout(fixedIdleTimer.current)

    const sourceButton =
      mode === 'vertical' ? imageButtonRefs.current[lightboxIndex] : horizontalButtonRefs.current?.[lightboxIndex]
    const source = sourceButton
    const scrollContainer = mode === 'vertical' ? verticalRef.current : horizontalRef.current

    if (source && scrollContainer) {
      if (mode === 'vertical') {
        scrollContainer.scrollTop = fixedScrollPosition.current
      } else {
        scrollContainer.scrollLeft = fixedScrollPosition.current
        if (horizontalTarget) horizontalTarget.current = fixedScrollPosition.current
      }
    }

    window.requestAnimationFrame(() => {
      const target = source?.getBoundingClientRect() ?? fixedSourceRect.current
      const frame = fixedFrameRef.current
      const backdrop = fixedBackdropRef.current
      if (!frame || !backdrop || !target) {
        setLightboxIndex(null)
        setFixedPhase('closed')
        onCursorStateChange?.(false, 'Zoom')
        return
      }
      const backdropProgress = { value: 0 }
      setFixedBackdropMask(backdrop, 0, -1)

      gsap
        .timeline({
          defaults: { overwrite: true },
          onComplete: () => {
            setLightboxIndex(null)
            setFixedPhase('closed')
            onCursorStateChange?.(false, 'Zoom')
          },
        })
        .to(
          frame,
          {
            left: target.left,
            top: target.top,
            width: target.width,
            height: target.height,
            duration: 1,
            ease: 'power3.inOut',
          },
          0,
        )
        .to(
          backdropProgress,
          {
            value: 1,
            duration: 1.05,
            ease: 'power3.inOut',
            onUpdate: () => setFixedBackdropMask(backdrop, backdropProgress.value, -1),
          },
          0,
        )
    })
  }, [
    fixedPhase,
    horizontalButtonRefs,
    horizontalRef,
    horizontalTarget,
    lightboxIndex,
    mode,
    onCursorStateChange,
    verticalRef,
  ])

  const moveImage = useCallback(
    (direction: number) => {
      if (fixedPhase !== 'open') return
      setFixedDirection(direction)
      setLightboxIndex((current) =>
        current === null ? current : (current + direction + galleryImages.length) % galleryImages.length,
      )
      scheduleFixedIdle()
    },
    [fixedPhase, scheduleFixedIdle],
  )

  // GSAP opening animation
  useEffect(() => {
    if (lightboxIndex === null || fixedPhase !== 'opening' || !fixedFrameRef.current || !fixedBackdropRef.current)
      return

    const source = fixedSourceRect.current ?? imageButtonRefs.current[lightboxIndex]?.getBoundingClientRect()
    const target = getFixedTargetRect(lightboxIndex)
    if (!source) return
    const backdropProgress = { value: 0 }

    gsap.killTweensOf([fixedBackdropRef.current, fixedFrameRef.current])
    gsap.set(fixedBackdropRef.current, { opacity: 1 })
    setFixedBackdropMask(fixedBackdropRef.current, 0, 1)
    gsap.set(fixedFrameRef.current, {
      left: source.left,
      top: source.top,
      width: source.width,
      height: source.height,
    })

    gsap
      .timeline({
        defaults: { overwrite: true },
        onComplete: () => {
          setFixedPhase('open')
          scheduleFixedIdle()
        },
      })
      .to(
        backdropProgress,
        {
          value: 1,
          duration: 1.05,
          ease: 'power3.inOut',
          onUpdate: () => {
            if (fixedBackdropRef.current) {
              setFixedBackdropMask(fixedBackdropRef.current, backdropProgress.value, 1)
            }
          },
        },
        0,
      )
      .to(
        fixedFrameRef.current,
        {
          ...target,
          duration: 1,
          ease: 'power3.inOut',
        },
        0,
      )
  }, [fixedPhase, getFixedTargetRect, lightboxIndex, scheduleFixedIdle])

  // Keyboard navigation and pointermove idle reset
  useEffect(() => {
    const onMove = () => {
      if (lightboxIndex !== null) scheduleFixedIdle()
    }
    const onKey = (event: KeyboardEvent) => {
      if (event.key === 'Escape') {
        if (lightboxIndex !== null) closeImage()
      }
      if (lightboxIndex !== null && event.key === 'ArrowLeft') moveImage(-1)
      if (lightboxIndex !== null && event.key === 'ArrowRight') moveImage(1)
    }

    window.addEventListener('pointermove', onMove)
    window.addEventListener('keydown', onKey)
    return () => {
      window.removeEventListener('pointermove', onMove)
      window.removeEventListener('keydown', onKey)
      if (fixedIdleTimer.current) window.clearTimeout(fixedIdleTimer.current)
    }
  }, [closeImage, lightboxIndex, moveImage, scheduleFixedIdle])

  return {
    lightboxIndex,
    setLightboxIndex,
    fixedPhase,
    fixedDirection,
    fixedNavIdle,
    fixedBackdropRef,
    fixedFrameRef,
    imageButtonRefs,
    openImage,
    closeImage,
    moveImage,
    scheduleFixedIdle,
  }
}
src/registry/templates/immersive-lens/lib/data.ts
export interface CategoryItem {
  label: string
  image: string
  thumb: string
  colors: readonly string[]
}

export interface GalleryImage {
  url: string
  title: string
  category: string
  location?: string
  camera?: string
}

export type LayoutMode = 'vertical' | 'horizontal'
export type LightboxPhase = 'closed' | 'opening' | 'open' | 'closing'

export const ease = [0.104, 0.204, 0.492, 1] as const

export const glass =
  'bg-black/75 backdrop-blur-[4px] shadow-[inset_0_0_0_1px_rgba(255,255,255,.08),inset_1.8px_3px_0_-2px_rgba(255,255,255,.45),inset_-2px_-2px_0_-2px_rgba(255,255,255,.4),inset_-3px_-8px_1px_-6px_rgba(255,255,255,.3),inset_-.3px_-1px_4px_rgba(0,0,0,.09),0_1px_5px_rgba(0,0,0,.08),0_6px_16px_rgba(0,0,0,.12),0_6px_12px_rgba(0,0,0,.12)]'

export const categories: readonly CategoryItem[] = [
  {
    label: 'Lens',
    image: 'https://cdn.spaceui.one/atom/samples/image-1.png',
    thumb: 'https://cdn.spaceui.one/atom/samples/image-1.png',
    colors: ['#F3ECF3', '#D9C6DB', '#D694AD', '#AD7092'],
  },
  {
    label: 'Fashion',
    image: 'https://cdn.spaceui.one/atom/samples/image-2.png',
    thumb: 'https://cdn.spaceui.one/atom/samples/image-2.png',
    colors: ['#DBD1D0', '#282824', '#E9C797', '#D0A975'],
  },
  {
    label: 'Journey',
    image: 'https://cdn.spaceui.one/atom/samples/image-3.png',
    thumb: 'https://cdn.spaceui.one/atom/samples/image-3.png',
    colors: ['#D7F3FD', '#B4D0E8', '#8DB5D9', '#6B99C5'],
  },
] as const

export const galleryImages = [
  'https://cdn.spaceui.one/atom/samples/image-1.png',
  'https://cdn.spaceui.one/atom/samples/image-2.png',
  'https://cdn.spaceui.one/atom/samples/image-3.png',
  'https://cdn.spaceui.one/atom/samples/image-4.png',
  'https://cdn.spaceui.one/atom/samples/image-5.png',
  'https://cdn.spaceui.one/atom/samples/image-6.png',
  'https://cdn.spaceui.one/atom/samples/image-7.png',
  'https://cdn.spaceui.one/atom/samples/image-8.png',
  'https://cdn.spaceui.one/atom/samples/image-9.png',
  'https://cdn.spaceui.one/atom/samples/image-10.png',
  'https://cdn.spaceui.one/atom/samples/image-11.png',
  'https://cdn.spaceui.one/atom/samples/image-13.png',
] as const

More from spaceui

Browse all components in this library →