Studio Pieces & Glyph Artv0.4.0New
Pane Stack Portfolio
Project panes standing in a receding diagonal row, with filters and a detail view.
Installation
CLI
npx shadcn@latest add https://hantui-theta.vercel.app/r/pane-stack-portfolio.jsonManual
Install the dependencies
npm install clsx lucide-react motion radix-ui tailwind-mergeAdd the shared helpers
Components use
cn()fromlib/utils.tsand the motion tokens below. See Installation if you have not set them up yet.lib/motion.tsimport type { Transition, Variants } from "motion/react"; /** Durations in seconds. */ export const duration = { instant: 0.1, quick: 0.18, base: 0.28, slow: 0.45, } as const; /** Cubic-bezier curves. */ export const ease = { /** Entering, UI response. */ out: [0.22, 1, 0.36, 1], /** Moving on screen. */ inOut: [0.65, 0, 0.35, 1], /** Sheets and the mobile menu. */ drawer: [0.32, 0.72, 0, 1], } as const; /** Everyday UI: presses, icon swaps, popovers and modals. Exits run at `exitFactor` of the enter. */ export const ui = { press: { scale: 0.96, duration: 0.12 }, icon: { type: "spring", duration: 0.3, bounce: 0 }, popover: { duration: 0.18, ease: ease.out }, modal: { duration: 0.24, ease: ease.out }, exitFactor: 0.6, } as const; /** Once-per-visit motion on the landing page: hero letters, the reel and the afterimage. */ export const kinetic = { heroLetter: { duration: 0.6, ease: ease.out }, /** Delay per letter, eased so the wave slows toward the end. */ heroStep: 0.035, reelSwap: { type: "spring", duration: 0.5, bounce: 0.1 }, /** Two afterimages: lag in seconds and opacity of each. */ ghost: { lag: [0.04, 0.09], opacity: [0.14, 0.06] }, } as const; /** Spring presets. */ export const spring = { snappy: { type: "spring", stiffness: 500, damping: 30 }, smooth: { type: "spring", stiffness: 260, damping: 26 }, bouncy: { type: "spring", stiffness: 300, damping: 15 }, } as const satisfies Record<string, Transition>; /** Stagger between siblings, in seconds (0.03–0.06). */ export const stagger = { tight: 0.03, base: 0.045, loose: 0.06, } as const; /** Blur + fade + y-offset. Falls back to a plain fade under reduced motion. */ export function enterVariants(reduceMotion?: boolean | null, y = 8): Variants { if (reduceMotion) { return { hidden: { opacity: 0 }, visible: { opacity: 1, transition: { duration: duration.base } }, exit: { opacity: 0, transition: { duration: duration.quick } }, }; } return { hidden: { opacity: 0, y, filter: "blur(4px)" }, visible: { opacity: 1, y: 0, filter: "blur(0px)", transition: { duration: duration.base, ease: ease.out }, }, exit: { opacity: 0, y: -y / 2, filter: "blur(4px)", transition: { duration: duration.quick, ease: ease.out }, }, }; } /** Scale 0.96 → 1 for popovers, menus and dialogs. */ export function overlayVariants(reduceMotion?: boolean | null): Variants { if (reduceMotion) { return { hidden: { opacity: 0 }, visible: { opacity: 1, transition: { duration: duration.quick } }, exit: { opacity: 0, transition: { duration: duration.instant } }, }; } return { hidden: { opacity: 0, scale: 0.96, filter: "blur(2px)" }, visible: { opacity: 1, scale: 1, filter: "blur(0px)", transition: { duration: duration.quick, ease: ease.out }, }, exit: { opacity: 0, scale: 0.96, filter: "blur(2px)", transition: { duration: duration.instant + 0.02, ease: ease.out }, }, }; } /** Parent variants that stagger their children in. */ export function staggerVariants(gap: number = stagger.base, delayChildren = 0): Variants { return { hidden: {}, visible: { transition: { staggerChildren: gap, delayChildren } }, exit: { transition: { staggerChildren: gap / 2, staggerDirection: -1 } }, }; }Copy the source
components/ui/pane-stack-portfolio/pane-stack-portfolio.tsx"use client"; import { useEffect, useId, useRef, useState, useSyncExternalStore, type ComponentProps, type CSSProperties, type KeyboardEvent, type PointerEvent, type ReactNode } from "react"; import { animate, AnimatePresence, LayoutGroup, motion, useMotionValue, useMotionValueEvent, useReducedMotion, useTransform, type MotionValue } from "motion/react"; import { Dialog } from "radix-ui"; import { ArrowLeft, ArrowRight, ArrowUpRight, Moon, Pause, Play, Sun, X } from "lucide-react"; import { cn } from "@/lib/utils"; import { duration, ease } from "@/lib/motion"; export interface PaneMedia { type: "image" | "video"; /** Image or video URL. A video without a playable src shows its poster with a simulated progress line. */ src: string; /** Still shown for videos before they play (and in the stack). */ poster?: string; /** Running time shown on video panes, e.g. "0:42". */ duration?: string; } export interface PaneProject { id: string; title: string; category: string; client?: string; year?: number | string; tools?: string[]; description?: string; media: PaneMedia; } export interface PaneStackOwner { name: string; role: string; /** IANA time zone for the local time in the header. @default "Asia/Jakarta" */ timeZone?: string; /** Short booking status next to the live dot. @default "Booking from June" */ status?: string; } export interface PaneStackPortfolioProps extends Omit<ComponentProps<"section">, "onSelect"> { /** Projects in stack order. @default twelve sample projects with generated art */ projects?: PaneProject[]; /** Filter chips, in order. "All" is always first. @default the categories found in `projects` */ categories?: string[]; /** Replaces the built-in header (owner pill, links, booking status, local time and Dark toggle). */ header?: ReactNode; /** Owner shown in the built-in header. */ owner?: PaneStackOwner; /** Called when a project opens in the detail view. */ onSelect?: (project: PaneProject) => void; /** Where "Start a project" goes. @default "#contact" */ ctaHref?: string; } /* ------------------------------------------------------------------ sample */ const ART: [string, string, string, string][] = [ ["#fbe3d6", "#f2a28a", "#e5644e", "#b9362f"], ["#d9efec", "#7cc7bd", "#2f8f87", "#16514d"], ["#ece4d8", "#c9b79e", "#9b8467", "#5e4c38"], ["#e4e1f7", "#9f94e0", "#5b4cc4", "#2b2370"], ["#0b1a3a", "#2563eb", "#38bdf8", "#a5f3fc"], ["#071a1d", "#14b8a6", "#5eead4", "#ccfbf1"], ["#f3ead9", "#e0cba6", "#c9774a", "#2f4a5c"], ["#140a26", "#7c3aed", "#ec4899", "#f9a8d4"], ["#eef1f4", "#cfd8e3", "#a9b8c9", "#5d6b7c"], ["#1d2b4f", "#3c5a8f", "#8fa9d6", "#f2c38b"], ["#f6d2b1", "#e48b52", "#b9502a", "#6e2a17"], ["#efe3d0", "#f9d9a6", "#d2b48c", "#7a6a55"], ]; /** A soft generated still: a gradient sweep with a lit sphere and its shadow. */ function sampleArt(i: number) { const [a, b, c, d] = ART[i % ART.length]; const x = 380 + ((i * 97) % 240); const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 750"><defs><linearGradient id="g" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${a}"/><stop offset="1" stop-color="${b}"/></linearGradient><radialGradient id="s" cx=".36" cy=".3" r=".8"><stop offset="0" stop-color="#fff"/><stop offset=".45" stop-color="${c}"/><stop offset="1" stop-color="${d}"/></radialGradient><filter id="f"><feGaussianBlur stdDeviation="24"/></filter></defs><rect width="1200" height="750" fill="url(#g)"/><ellipse cx="${x}" cy="610" rx="240" ry="34" fill="${d}" opacity=".35" filter="url(#f)"/><circle cx="${x}" cy="390" r="${150 + (i % 4) * 18}" fill="url(#s)"/></svg>`; return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`; } const SAMPLE: PaneProject[] = [ ["Coral Bloom", "3D", "Lumivo", 2026, "A soft-body coral that opens like a flower."], ["Glass Garden", "3D", "Tessaly", 2025, "Blown-glass plants growing in a light box."], ["Soft Monolith", "3D", "Halvane", 2026, "A rounded slab in brushed clay."], ["Orbit Room", "3D", "Orbiq", 2025, "Rings and spheres balanced in a sunlit room."], ["Tidal Type", "Motion", "Driftwell", 2026, "Letterforms that roll in and out like surf."], ["Signal Drift", "Motion", "Pylonix", 2025, "Data lines drifting through fog."], ["Paper Engines", "Motion", "Mosaiq", 2026, "Folded paper machines that click along."], ["Night Loop", "Motion", "Nimbra", 2025, "A looping drive through neon rain."], ["Salt Flats", "Stills", "Corvane", 2025, "Pale horizons at first light."], ["Quiet Harbor", "Stills", "Fernwick", 2026, "Moored boats and still water at blue hour."], ["Red Desert Study", "Stills", "Quorvex", 2024, "Wind-cut dunes in long shadow."], ["Window Light", "Stills", "Brightloom", 2026, "Afternoon light moving across a wall."], ].map(([title, category, client, year, description], i) => ({ id: String(title).toLowerCase().replace(/\s+/g, "-"), title: String(title), category: String(category), client: String(client), year: Number(year), description: String(description), tools: category === "Stills" ? ["Medium format"] : category === "Motion" ? ["Animation", "Compositing"] : ["Modeling", "Path tracing"], media: category === "Motion" ? { type: "video" as const, src: "", poster: sampleArt(i), duration: `0:${String(28 + ((i * 13) % 40)).padStart(2, "0")}` } : { type: "image" as const, src: sampleArt(i) }, })); /* ------------------------------------------------------------------ helpers */ const SPRING = { type: "spring", stiffness: 210, damping: 30 } as const; const clamp = (v: number, min: number, max: number) => Math.min(max, Math.max(min, v)); const pad = (n: number) => String(n).padStart(2, "0"); const still = (m: PaneMedia) => (m.type === "video" ? (m.poster ?? m.src) : m.src); const seconds = (text?: string) => { const [m, s] = (text ?? "0:30").split(":").map(Number); return (m || 0) * 60 + (s || 0) || 30; }; const subscribeMinute = (cb: () => void) => { const id = window.setInterval(cb, 15000); return () => window.clearInterval(id); }; function LocalTime({ timeZone }: { timeZone: string }) { const text = useSyncExternalStore( subscribeMinute, () => new Intl.DateTimeFormat("en-US", { hour: "2-digit", minute: "2-digit", hour12: false, timeZone }).format(new Date()), () => "--:--", ); return <span className="tabular-nums">{text}</span>; } /* ------------------------------------------------------------------ pane */ interface PaneProps { project: PaneProject; index: number; total: number; position: MotionValue<number>; compact: boolean; isFront: boolean; hideMedia: boolean; reduce: boolean; onActivate: () => void; layoutKey: string; } function Pane({ project, index, total, position, compact, isFront, hideMedia, reduce, onActivate, layoutKey }: PaneProps) { // The pane's slot animates when filtering re-indexes the stack. const slot = useMotionValue(index); useEffect(() => { const controls = animate(slot, index, reduce ? { duration: 0 } : SPRING); return () => controls.stop(); }, [slot, index, reduce]); const k = useTransform(() => slot.get() - position.get()); const x = useTransform(k, (v) => (compact ? "0%" : v >= 0 ? `${Math.min(v, 6) * 23}%` : `${v * 70}%`)); const y = useTransform(k, (v) => (compact ? (v >= 0 ? `${Math.min(v, 5) * 9}%` : `${v * 110}%`) : v >= 0 ? `${-Math.min(v, 6) * 11}%` : `${-v * 16}%`)); const scale = useTransform(k, (v) => (v >= 0 ? 1 - Math.min(v, 6) * (compact ? 0.07 : 0.11) : 1 + v * 0.04)); const rotateY = useTransform(k, (v) => (compact || reduce ? 0 : -16 + Math.min(Math.max(v, 0), 3) * 2)); const opacity = useTransform(k, (v) => (v >= 0 ? clamp(1 - Math.max(0, v - (compact ? 3 : 4.2)) * 0.8, 0, 1) : clamp(1 + v * 1.4, 0, 1))); const zIndex = useTransform(k, (v) => 100 - Math.round(v * 10)); const pointerEvents = useTransform(opacity, (o) => (o < 0.1 ? "none" : "auto")); const shade = useTransform(k, (v) => clamp(Math.abs(v) * 0.12, 0, 0.45)); return ( <motion.div style={{ x, y, scale, rotateY, opacity, zIndex, pointerEvents, transformOrigin: compact ? "50% 0%" : "0% 100%" }} className={cn("absolute aspect-[16/10]", compact ? "inset-x-[5%] top-[14%]" : "top-[24%] left-[6%] w-[46%]")} > {/* Entering and leaving (filters) animate in here, so the outer z-index keeps ordering the stack. */} <motion.div className="relative size-full" initial={{ opacity: 0, y: 40 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: 60, transition: { duration: duration.base, ease: ease.out } }} transition={{ duration: reduce ? 0 : duration.slow, ease: ease.out }} > <div aria-hidden="true" className="absolute inset-x-[10%] -bottom-5 h-8 rounded-[50%] bg-black/35 blur-xl" /> <button type="button" tabIndex={isFront ? 0 : -1} aria-label={isFront ? `Open ${project.title}, ${index + 1} of ${total}` : `Show ${project.title}`} onClick={onActivate} className="focus-ring relative block size-full cursor-pointer overflow-hidden rounded-[18px] bg-black/10 text-left shadow-[0_1px_2px_rgb(0_0_0/0.1),0_24px_48px_-20px_rgb(0_0_0/0.45)]" > {hideMedia ? null : ( <motion.div layoutId={`${layoutKey}-media-${project.id}`} transition={reduce ? { duration: 0 } : SPRING} className="absolute inset-0 overflow-hidden rounded-[18px]"> {/* eslint-disable-next-line @next/next/no-img-element -- plain img keeps the component framework-agnostic */} <img src={still(project.media)} alt="" draggable={false} className="size-full object-cover" /> </motion.div> )} <motion.span aria-hidden="true" className="absolute inset-0 bg-black" style={{ opacity: shade }} /> {project.media.type === "video" && project.media.duration ? ( <span className="absolute top-3 right-3 inline-flex items-center gap-1 rounded-full bg-black/55 px-2 py-0.5 font-mono text-[11px] text-white backdrop-blur"> <Play className="size-3 fill-current" aria-hidden="true" /> {project.media.duration} </span> ) : null} </button> </motion.div> </motion.div> ); } /* ------------------------------------------------------------------ main */ /** * A portfolio of large project panes standing in a diagonal row that recedes into depth: the active * pane at the front left, the rest stepping back and up to the right, smaller and turned on Y. Wheel, * drag, arrow keys and a scrubber move through the stack on a spring; clicking a back pane brings it * forward. Filter chips re-stack the panes, an Index view lays every project out in a grid, and the * front pane morphs into a detail view with a player, meta table and previous/next. DOM and CSS 3D * driven by Motion values; narrow containers get a vertical swipe deck. */ export function PaneStackPortfolio({ projects = SAMPLE, categories, header, owner = { name: "Alibar", role: "Motion & 3D designer" }, onSelect, ctaHref = "#contact", className, ...props }: PaneStackPortfolioProps) { const reduceMotion = useReducedMotion() ?? false; const layoutKey = useId(); const liveId = useId(); const [dark, setDark] = useState(false); const [view, setView] = useState<"overview" | "index">("overview"); const [filter, setFilter] = useState("All"); const [front, setFront] = useState(0); const [detail, setDetail] = useState<string | null>(null); const [compact, setCompact] = useState(false); const rootRef = useRef<HTMLElement>(null); const stageRef = useRef<HTMLDivElement>(null); const position = useMotionValue(0); const drag = useRef({ active: false, moved: false, start: 0, startPos: 0, last: 0, lastT: 0, velocity: 0, id: -1 }); const wheel = useRef({ acc: 0, t: 0 }); const chips = ["All", ...(categories ?? [...new Set(projects.map((p) => p.category))])]; const visible = filter === "All" ? projects : projects.filter((p) => p.category === filter); const total = visible.length; const current = visible[Math.min(front, total - 1)]; const detailProject = projects.find((p) => p.id === detail) ?? null; const detailIndex = detailProject ? visible.findIndex((p) => p.id === detailProject.id) : -1; const goTo = (i: number) => { const target = clamp(Math.round(i), 0, Math.max(0, total - 1)); setFront(target); animate(position, target, reduceMotion ? { duration: 0 } : SPRING); // Keyboard users follow the new front pane. requestAnimationFrame(() => { const stage = stageRef.current; if (stage?.contains(document.activeElement)) stage.querySelector<HTMLButtonElement>('button[tabindex="0"]')?.focus({ preventScroll: true }); }); }; // Keep `front` in step with the stack while it is dragged. useMotionValueEvent(position, "change", (v) => { const i = clamp(Math.round(v), 0, Math.max(0, total - 1)); if (drag.current.active && i !== front) setFront(i); }); // Container width decides between the diagonal stack and the vertical deck. useEffect(() => { const root = rootRef.current; if (!root) return; const ro = new ResizeObserver(([entry]) => setCompact((entry?.contentRect.width ?? 1000) < 560)); ro.observe(root); return () => ro.disconnect(); }, []); // Wheel moves one pane per notch-worth of scroll, and lets the page scroll at either end. useEffect(() => { const stage = stageRef.current; if (!stage || view !== "overview") return; const onWheel = (event: WheelEvent) => { const delta = Math.abs(event.deltaX) > Math.abs(event.deltaY) ? event.deltaX : event.deltaY; const target = Math.round(position.get()) + Math.sign(delta); if (target < 0 || target > total - 1) return; event.preventDefault(); const w = wheel.current; if (event.timeStamp - w.t > 220) w.acc = 0; w.t = event.timeStamp; w.acc += delta; if (Math.abs(w.acc) >= 40) { w.acc = 0; const i = clamp(Math.round(position.get()) + Math.sign(delta), 0, total - 1); setFront(i); animate(position, i, reduceMotion ? { duration: 0 } : SPRING); } }; stage.addEventListener("wheel", onWheel, { passive: false }); return () => stage.removeEventListener("wheel", onWheel); }, [view, total, position, reduceMotion]); const changeFilter = (next: string) => { setFilter(next); setFront(0); animate(position, 0, reduceMotion ? { duration: 0 } : SPRING); }; const open = (project: PaneProject) => { setDetail(project.id); onSelect?.(project); }; const step = () => (stageRef.current?.clientWidth ?? 800) * (compact ? 0.5 : 0.22); const onDown = (event: PointerEvent<HTMLDivElement>) => { if (event.button !== 0) return; Object.assign(drag.current, { active: false, moved: false, start: compact ? event.clientY : event.clientX, startPos: position.get(), last: compact ? event.clientY : event.clientX, lastT: event.timeStamp, velocity: 0, id: event.pointerId }); drag.current.active = true; }; const onMove = (event: PointerEvent<HTMLDivElement>) => { const d = drag.current; if (!d.active) return; const at = compact ? event.clientY : event.clientX; if (!d.moved && Math.abs(at - d.start) > 6) { d.moved = true; // Capture only once it is a drag, so clicks on panes still land. event.currentTarget.setPointerCapture(event.pointerId); } if (!d.moved) return; const dt = Math.max(1, event.timeStamp - d.lastT); d.velocity = (at - d.last) / dt; d.last = at; d.lastT = event.timeStamp; position.set(clamp(d.startPos - (at - d.start) / step(), -0.4, total - 0.6)); }; const onUp = () => { const d = drag.current; if (!d.active) return; d.active = false; if (d.moved) goTo(position.get() - (d.velocity * 220) / step()); }; const onKey = (event: KeyboardEvent<HTMLDivElement>) => { const keys: Record<string, number> = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 }; if (event.key in keys) { event.preventDefault(); goTo(front + keys[event.key]); } else if (event.key === "Home") goTo(0); else if (event.key === "End") goTo(total - 1); }; const theme = dark ? { "--ps-bg": "#0f0f11", "--ps-fg": "#f4f4f5", "--ps-muted": "#a1a1aa", "--ps-surface": "#1b1b1f", "--ps-line": "rgb(255 255 255 / 0.12)" } : { "--ps-bg": "#f5f4f0", "--ps-fg": "#111113", "--ps-muted": "#5f5f66", "--ps-surface": "#ffffff", "--ps-line": "rgb(0 0 0 / 0.1)" }; return ( <section ref={rootRef} aria-label={`${owner.name}: selected work`} className={cn("@container relative isolate w-full overflow-clip rounded-[28px] bg-[var(--ps-bg)] text-[var(--ps-fg)] transition-colors duration-500", className)} style={theme as CSSProperties} {...props} > {/* Header slot. */} <div className="relative z-[200] flex flex-wrap items-center gap-x-4 gap-y-2 px-5 pt-5 text-[13px] @3xl:px-7 @3xl:pt-6"> {header ?? ( <> <span className="inline-flex items-center gap-2 rounded-full bg-[var(--ps-surface)] py-1 pr-3 pl-1 shadow-xs ring-1 ring-[var(--ps-line)]"> <span className="flex size-6 items-center justify-center rounded-full bg-[var(--ps-fg)] text-[11px] font-semibold text-[var(--ps-bg)]">{owner.name[0]}</span> <span className="font-medium">{owner.name}</span> <span className="text-[var(--ps-muted)]">{owner.role}</span> </span> <nav aria-label="Site" className="flex items-center gap-4"> <a href="#studio" className="focus-ring rounded underline-offset-4 hover:underline"> Studio </a> <a href="#contact" className="focus-ring rounded underline-offset-4 hover:underline"> Contact </a> </nav> <span className="ml-auto inline-flex items-center gap-2 text-[var(--ps-muted)]"> <span className="relative flex size-2" aria-hidden="true"> <span className="absolute inset-0 animate-ping rounded-full bg-emerald-500/60 motion-reduce:hidden" /> <span className="relative size-2 rounded-full bg-emerald-500" /> </span> {owner.status ?? "Booking from June"} </span> <span className="hidden font-mono text-[12px] text-[var(--ps-muted)] @md:inline"> {(owner.timeZone ?? "Asia/Jakarta").split("/").pop()?.replace(/_/g, " ")} <LocalTime timeZone={owner.timeZone ?? "Asia/Jakarta"} /> </span> <button type="button" aria-pressed={dark} onClick={() => setDark((d) => !d)} className="focus-ring inline-flex h-8 cursor-pointer items-center gap-1.5 rounded-full bg-[var(--ps-surface)] px-3 ring-1 ring-[var(--ps-line)]" > {dark ? <Sun className="size-3.5" aria-hidden="true" /> : <Moon className="size-3.5" aria-hidden="true" />} Dark </button> </> )} </div> <LayoutGroup id={layoutKey}> <div className={cn("relative", compact ? "h-[600px]" : "h-[max(520px,56.25cqw)]")}> {/* Filter chips. */} <div role="group" aria-label="Filter projects" className={cn("absolute z-[150] flex gap-1.5", compact || view === "index" ? "inset-x-5 top-3 overflow-x-auto @3xl:inset-x-7" : "top-6 right-7 flex-col items-end")} > {chips.map((chip) => { const count = chip === "All" ? projects.length : projects.filter((p) => p.category === chip).length; return ( <button key={chip} type="button" aria-pressed={filter === chip} onClick={() => changeFilter(chip)} className={cn( "focus-ring inline-flex h-8 shrink-0 cursor-pointer items-center gap-2 rounded-full px-3 text-[13px] ring-1 transition-colors", filter === chip ? "bg-[var(--ps-fg)] text-[var(--ps-bg)] ring-transparent" : "bg-[var(--ps-surface)] ring-[var(--ps-line)] hover:bg-[var(--ps-bg)]", )} > {chip} <span className="font-mono text-[11px] opacity-60 tabular-nums">{count}</span> </button> ); })} </div> <AnimatePresence mode="popLayout" initial={false}> {view === "overview" ? ( <motion.div key="overview" ref={stageRef} role="region" aria-roledescription="carousel" aria-label="Projects" tabIndex={-1} onKeyDown={onKey} onPointerDown={onDown} onPointerMove={onMove} onPointerUp={onUp} onPointerCancel={onUp} onDragStart={(e) => e.preventDefault()} initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0, transition: { duration: duration.quick } }} className={cn("absolute inset-0 touch-pan-y overflow-clip outline-none [perspective:1600px] select-none", compact && "touch-pan-x")} > <AnimatePresence> {visible.map((project, i) => ( <Pane key={project.id} project={project} index={i} total={total} position={position} compact={compact} isFront={i === front} hideMedia={detail === project.id} reduce={reduceMotion} layoutKey={layoutKey} onActivate={() => { if (drag.current.moved) return; if (i === front) open(project); else goTo(i); }} /> ))} </AnimatePresence> {/* Caption of the front pane. */} <div className={cn("pointer-events-none absolute z-[150]", compact ? "inset-x-5 bottom-20" : "bottom-[11%] left-[6%]")}> <AnimatePresence mode="popLayout" initial={false}> {current ? ( <motion.p key={current.id} initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -6 }} transition={{ duration: duration.base, ease: ease.out }} className="flex items-baseline gap-3" > <span className="font-mono text-[13px] text-[var(--ps-muted)] tabular-nums">{pad(front + 1)}</span> <span className="text-[clamp(1.25rem,3cqi,2rem)] font-semibold tracking-tight">{current.title}</span> <span className="text-[13px] text-[var(--ps-muted)]">{current.category}</span> </motion.p> ) : null} </AnimatePresence> </div> <p id={liveId} className="sr-only" aria-live="polite"> {current ? `${front + 1} of ${total}: ${current.title}, ${current.category}` : ""} </p> </motion.div> ) : ( <motion.ul key="index" aria-label="All projects" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0, transition: { duration: duration.quick } }} className={cn("absolute inset-0 grid content-start gap-4 overflow-y-auto px-5 pt-16 pb-24 @3xl:grid-cols-4 @3xl:px-7", "grid-cols-2")} > {visible.map((project, i) => ( <li key={project.id}> <button type="button" onClick={() => open(project)} className="focus-ring group block w-full cursor-pointer rounded-[14px] text-left"> <span className="relative block aspect-[16/10] overflow-hidden rounded-[14px] bg-black/10"> {detail === project.id ? null : ( <motion.span layoutId={`${layoutKey}-media-${project.id}`} transition={reduceMotion ? { duration: 0 } : SPRING} className="absolute inset-0 block overflow-hidden rounded-[14px]"> {/* eslint-disable-next-line @next/next/no-img-element -- plain img keeps the component framework-agnostic */} <img src={still(project.media)} alt="" draggable={false} className="size-full object-cover transition-transform duration-500 group-hover:scale-[1.06] motion-reduce:transition-none" /> </motion.span> )} <span className="absolute inset-x-0 bottom-0 translate-y-full bg-gradient-to-t from-black/70 to-transparent p-3 text-[12px] text-white transition-transform duration-300 group-hover:translate-y-0 group-focus-visible:translate-y-0"> {project.description} </span> </span> <span className="mt-2 flex items-baseline gap-2 text-[13px]"> <span className="font-mono text-[var(--ps-muted)] tabular-nums">{pad(i + 1)}</span> <span className="font-medium">{project.title}</span> <span className="ml-auto text-[var(--ps-muted)]">{project.category}</span> </span> </button> </li> ))} </motion.ul> )} </AnimatePresence> {/* Bottom bar: view toggle and scrubber. */} <div className="absolute inset-x-5 bottom-5 z-[150] flex items-center gap-4 @3xl:inset-x-7"> <div role="group" aria-label="View" className="inline-flex rounded-full bg-[var(--ps-surface)] p-1 ring-1 ring-[var(--ps-line)]"> {(["overview", "index"] as const).map((v) => ( <button key={v} type="button" aria-pressed={view === v} onClick={() => setView(v)} className={cn("focus-ring h-7 cursor-pointer rounded-full px-3 text-[12.5px] capitalize transition-colors", view === v ? "bg-[var(--ps-fg)] text-[var(--ps-bg)]" : "text-[var(--ps-muted)] hover:text-[var(--ps-fg)]")} > {v} </button> ))} </div> {view === "overview" && total > 1 ? <Scrubber position={position} total={total} front={front} title={current?.title ?? ""} onChange={goTo} /> : null} </div> </div> <Detail project={detailProject} index={detailIndex} total={total} layoutKey={layoutKey} reduce={reduceMotion} dark={dark} theme={theme} ctaHref={ctaHref} onClose={() => setDetail(null)} onStep={(dir) => { const next = visible[(detailIndex + dir + total) % total]; if (!next) return; setDetail(next.id); goTo((detailIndex + dir + total) % total); onSelect?.(next); }} returnFocus={() => stageRef.current?.querySelector<HTMLButtonElement>('button[tabindex="0"]')?.focus({ preventScroll: true })} /> </LayoutGroup> </section> ); } /* ------------------------------------------------------------------ scrubber */ function Scrubber({ position, total, front, title, onChange }: { position: MotionValue<number>; total: number; front: number; title: string; onChange: (i: number) => void }) { const trackRef = useRef<HTMLDivElement>(null); const left = useTransform(position, (p) => `${clamp(p / Math.max(1, total - 1), 0, 1) * 100}%`); const pick = (clientX: number) => { const rect = trackRef.current?.getBoundingClientRect(); if (!rect) return; onChange(((clientX - rect.left) / rect.width) * (total - 1)); }; return ( <div className="flex min-w-0 flex-1 items-center gap-3 font-mono text-[12px] tabular-nums"> <span>{pad(front + 1)}</span> <div ref={trackRef} role="slider" tabIndex={0} aria-label="Project" aria-valuemin={1} aria-valuemax={total} aria-valuenow={front + 1} aria-valuetext={`${front + 1} of ${total}: ${title}`} onKeyDown={(e) => { const map: Record<string, number> = { ArrowRight: 1, ArrowUp: 1, ArrowLeft: -1, ArrowDown: -1 }; if (e.key in map) { e.preventDefault(); onChange(front + map[e.key]); } }} onPointerDown={(e) => { e.currentTarget.setPointerCapture(e.pointerId); pick(e.clientX); }} onPointerMove={(e) => { if (e.buttons === 1) pick(e.clientX); }} className="focus-ring relative h-6 flex-1 cursor-pointer touch-none rounded" > <span aria-hidden="true" className="absolute inset-x-0 top-1/2 h-px bg-[var(--ps-line)]" /> {Array.from({ length: total }, (_, i) => ( <span key={i} aria-hidden="true" className="absolute top-1/2 h-2 w-px -translate-y-1/2 bg-[var(--ps-muted)] opacity-50" style={{ left: `${(i / Math.max(1, total - 1)) * 100}%` }} /> ))} <motion.span aria-hidden="true" className="absolute top-1/2 h-3 w-6 -translate-x-1/2 -translate-y-1/2 rounded-full bg-[var(--ps-fg)]" style={{ left }} /> </div> <span className="text-[var(--ps-muted)]">{pad(total)}</span> </div> ); } /* ------------------------------------------------------------------ detail */ interface DetailProps { project: PaneProject | null; index: number; total: number; layoutKey: string; reduce: boolean; dark: boolean; theme: Record<string, string>; ctaHref: string; onClose: () => void; onStep: (dir: 1 | -1) => void; returnFocus: () => void; } function Detail({ project, index, total, layoutKey, reduce, theme, ctaHref, onClose, onStep, returnFocus }: DetailProps) { return ( <Dialog.Root open={project !== null} onOpenChange={(next) => !next && onClose()}> <AnimatePresence> {project ? ( <Dialog.Portal forceMount> <Dialog.Overlay asChild forceMount> <motion.div className="fixed inset-0 z-50 bg-black/45 backdrop-blur-[2px]" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: duration.base }} /> </Dialog.Overlay> <div className="pointer-events-none fixed inset-0 z-50 flex items-end justify-center sm:items-center sm:p-6" style={theme as CSSProperties}> <Dialog.Content asChild forceMount aria-describedby={undefined} onCloseAutoFocus={(event) => { event.preventDefault(); requestAnimationFrame(returnFocus); }} > <motion.div initial={reduce ? { opacity: 0 } : { opacity: 0, y: 24 }} animate={{ opacity: 1, y: 0 }} exit={reduce ? { opacity: 0 } : { opacity: 0, y: 24 }} transition={{ duration: duration.base, ease: ease.out }} className="pointer-events-auto relative grid max-h-[100dvh] w-full max-w-6xl gap-6 overflow-y-auto rounded-t-[28px] bg-[var(--ps-bg)] p-4 text-[var(--ps-fg)] shadow-2xl outline-none sm:max-h-[calc(100dvh-3rem)] sm:rounded-[28px] sm:p-6 md:grid-cols-[1.6fr_1fr]" > <Player project={project} layoutKey={layoutKey} reduce={reduce} /> <div className="flex flex-col gap-5 pt-1 pr-1 md:pt-10"> <p className="font-mono text-[12px] text-[var(--ps-muted)] tabular-nums"> {pad(index + 1)} / {pad(total)} · {project.category} </p> <Dialog.Title className="text-[clamp(2rem,4vw,3.25rem)] leading-[1.02] font-semibold tracking-[-0.03em]">{project.title}</Dialog.Title> {project.description ? <p className="text-[15px] leading-7 text-[var(--ps-muted)]">{project.description}</p> : null} <dl className="grid grid-cols-[auto_1fr] gap-x-6 gap-y-2 border-y border-[var(--ps-line)] py-4 text-[13.5px]"> {project.client ? ( <> <dt className="text-[var(--ps-muted)]">Client</dt> <dd>{project.client}</dd> </> ) : null} {project.year ? ( <> <dt className="text-[var(--ps-muted)]">Year</dt> <dd className="tabular-nums">{project.year}</dd> </> ) : null} {project.tools?.length ? ( <> <dt className="text-[var(--ps-muted)]">Tools</dt> <dd>{project.tools.join(", ")}</dd> </> ) : null} </dl> <div className="mt-auto flex flex-wrap items-center gap-2"> <button type="button" aria-label="Previous project" onClick={() => onStep(-1)} className="focus-ring inline-flex size-10 cursor-pointer items-center justify-center rounded-full ring-1 ring-[var(--ps-line)] hover:bg-[var(--ps-surface)]"> <ArrowLeft className="size-4" aria-hidden="true" /> </button> <button type="button" aria-label="Next project" onClick={() => onStep(1)} className="focus-ring inline-flex size-10 cursor-pointer items-center justify-center rounded-full ring-1 ring-[var(--ps-line)] hover:bg-[var(--ps-surface)]"> <ArrowRight className="size-4" aria-hidden="true" /> </button> <a href={ctaHref} className="focus-ring ml-auto inline-flex h-10 items-center gap-1.5 rounded-full bg-[var(--ps-fg)] px-4 text-[13.5px] font-medium text-[var(--ps-bg)]"> Start a project <ArrowUpRight className="size-4" aria-hidden="true" /> </a> </div> </div> <Dialog.Close aria-label="Close" className="focus-ring absolute top-4 right-4 inline-flex size-9 cursor-pointer items-center justify-center rounded-full bg-[var(--ps-surface)] ring-1 ring-[var(--ps-line)]"> <X className="size-4" aria-hidden="true" /> </Dialog.Close> </motion.div> </Dialog.Content> </div> </Dialog.Portal> ) : null} </AnimatePresence> </Dialog.Root> ); } function Player({ project, layoutKey, reduce }: { project: PaneProject; layoutKey: string; reduce: boolean }) { const { media } = project; const videoRef = useRef<HTMLVideoElement>(null); const barRef = useRef<HTMLSpanElement>(null); const [playing, setPlaying] = useState(false); const [broken, setBroken] = useState(!media.src); const isVideo = media.type === "video"; const simulated = isVideo && broken; // Without a playable file, play runs a simulated progress line over the poster. useEffect(() => { if (!simulated || !playing) return; let raf = 0; let start = 0; const total = seconds(media.duration) * 1000; const tick = (now: number) => { if (!start) start = now - (Number(barRef.current?.dataset.at) || 0) * total; const at = Math.min(1, (now - start) / total); if (barRef.current) { barRef.current.style.transform = `scaleX(${at})`; barRef.current.dataset.at = String(at); } if (at < 1) raf = requestAnimationFrame(tick); else setPlaying(false); }; raf = requestAnimationFrame(tick); return () => cancelAnimationFrame(raf); }, [simulated, playing, media.duration]); const toggle = () => { if (!isVideo) return; const video = videoRef.current; if (!simulated && video) { if (video.paused) void video.play().catch(() => setBroken(true)); else video.pause(); } setPlaying((p) => !p); }; return ( <motion.div layoutId={`${layoutKey}-media-${project.id}`} transition={reduce ? { duration: 0 } : SPRING} className="relative aspect-[16/10] overflow-hidden rounded-[20px] bg-black"> {isVideo && !broken ? ( <video ref={videoRef} src={media.src} poster={media.poster} playsInline onError={() => setBroken(true)} onTimeUpdate={(e) => { const v = e.currentTarget; if (barRef.current && v.duration) barRef.current.style.transform = `scaleX(${v.currentTime / v.duration})`; }} onEnded={() => setPlaying(false)} className="size-full object-cover" /> ) : ( // eslint-disable-next-line @next/next/no-img-element -- plain img keeps the component framework-agnostic <img src={still(media)} alt={project.title} className={cn("size-full object-cover transition-transform duration-[6000ms] ease-linear motion-reduce:transition-none", simulated && playing && "scale-110")} /> )} {isVideo ? ( <div className="absolute inset-x-0 bottom-0 flex items-center gap-3 bg-gradient-to-t from-black/70 to-transparent p-4 text-white"> <button type="button" onClick={toggle} aria-label={playing ? `Pause ${project.title}` : `Play ${project.title}`} className="focus-ring inline-flex h-10 cursor-pointer items-center gap-2 rounded-full bg-white px-4 text-[13px] font-medium text-black"> {playing ? <Pause className="size-4 fill-current" aria-hidden="true" /> : <Play className="size-4 fill-current" aria-hidden="true" />} {playing ? "Pause" : "Play"} {media.duration ? <span className="font-mono text-[12px] opacity-60">{media.duration}</span> : null} </button> <span className="relative h-0.5 flex-1 overflow-hidden rounded-full bg-white/30" aria-hidden="true"> <span ref={barRef} className="absolute inset-0 origin-left scale-x-0 bg-white" /> </span> </div> ) : null} </motion.div> ); }
Usage
import { PaneStackPortfolio } from "@/components/ui/pane-stack-portfolio/pane-stack-portfolio";
<PaneStackPortfolio
owner={{ name: "Alibar", role: "Motion & 3D designer", timeZone: "Asia/Jakarta" }}
projects={[
{ id: "coral-bloom", title: "Coral Bloom", category: "3D", client: "Lumivo", year: 2026, tools: ["Sculpting"], media: { type: "image", src: "/work/coral.webp" } },
{ id: "tidal-type", title: "Tidal Type", category: "Motion", media: { type: "video", src: "/work/tidal.mp4", poster: "/work/tidal.webp", duration: "0:42" } },
]}
onSelect={(project) => track("open", project.id)}
/>API reference
| Prop | Type | Default | Description |
|---|---|---|---|
projects | PaneProject[] | twelve sample projects with generated art | Projects in stack order. |
categories | string[] | the categories found in `projects` | Filter chips, in order. "All" is always first. |
header | ReactNode | – | Replaces the built-in header (owner pill, links, booking status, local time and Dark toggle). |
owner | PaneStackOwner | – | Owner shown in the built-in header. |
onSelect | (project: PaneProject) => void | – | Called when a project opens in the detail view. |
ctaHref | string | "#contact" | Where "Start a project" goes. |
Also accepts every native <section> attribute, including ref and className.
- All panes read one Motion value, the stack position. Each pane maps its distance from it to x, y, scale, Y rotation, opacity and z-index, so wheel, drag (with momentum), keys, the scrubber and clicks on back panes all just animate that value with a spring. Filtering springs each pane's own slot to its new index while the others fade out.
- The front pane's media, the Index tiles and the detail player share a layoutId, so opening morphs the pane into the player and the Overview/Index switch moves tiles in and out of the stack.
- A video without a playable `src` shows its poster and plays a simulated progress line for its `duration`. Below 560px wide the stack turns into a vertical swipe deck and the detail view opens as a full sheet.
- The built-in header shows the owner, Studio and Contact links, a booking status with a live dot, the local time in `owner.timeZone` (rendered after hydration) and a Dark toggle that switches the component's own colors. Pass `header` to replace it.
Accessibility
- The stack is a carousel region: only the front pane is in the tab order (Enter opens it), arrow keys and Home/End move through the stack, and a polite live region announces the new front project. The scrubber is a slider with arrow keys.
- Filter chips and the Overview/Index toggle are pressed-state buttons. The detail view is a modal dialog titled by the project, with Escape and a Close button; focus returns to the front pane.
- Under reduced motion every move, re-stack and morph is instant, the panes stop turning on Y and the live dot stops pulsing.