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 · heavythreeanimationmotiongsap
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