diff --git a/components/content/ProjectCarousel.tsx b/components/content/ProjectCarousel.tsx
new file mode 100644
index 0000000..b1cb185
--- /dev/null
+++ b/components/content/ProjectCarousel.tsx
@@ -0,0 +1,128 @@
+'use client';
+
+import { useEffect, useState } from "react";
+import { IconChevronLeft, IconChevronRight, IconZoomInFilled } from "@tabler/icons-react";
+import Image from "next/image";
+import ImageViewer from "@/components/ui/ImageViewer";
+import type { Screenshot } from "@/constants/projects";
+
+interface ProjectCarouselProps {
+ screenshots: Screenshot[];
+}
+
+/** Milliseconds each slide stays up before auto-advancing. */
+const AUTOPLAY_MS = 5000;
+
+export default function ProjectCarousel({ screenshots }: ProjectCarouselProps) {
+ const [index, setIndex] = useState(0);
+ const [viewerOpen, setViewerOpen] = useState(false);
+ const [paused, setPaused] = useState(false);
+ const [isHovered, setIsHovered] = useState(false);
+
+ const len = screenshots.length;
+ const go = (i: number) => setIndex(((i % len) + len) % len);
+
+ useEffect(() => {
+ if (len < 2 || paused || viewerOpen) return;
+ const t = setInterval(() => setIndex((i) => (i + 1) % len), AUTOPLAY_MS);
+ return () => clearInterval(t);
+ }, [len, paused, viewerOpen]);
+
+ if (len === 0) return null;
+
+ const active = screenshots[index];
+
+ const handleButtonClick = (e: React.MouseEvent, i: number) => {
+ e.stopPropagation();
+ go(i);
+ setPaused(true);
+ setTimeout(() => setPaused(false), AUTOPLAY_MS);
+ };
+
+ return (
+
+ { setPaused(true); setIsHovered(true); }}
+ onMouseLeave={() => { setPaused(false); setIsHovered(false); }}
+ onFocusCapture={() => setPaused(true)}
+ onBlurCapture={() => setPaused(false)}
+ >
+
setViewerOpen(true)}
+ >
+ {screenshots.map((shot, i) => (
+
+ ))}
+
+
+ {isHovered && }
+
+
+
+
+
+
+ {active.caption ?? active.alt}
+
+
+
+ {len > 1 && (
+
+
+ {screenshots.map((shot, i) => (
+
+ )}
+
+ {viewerOpen && (
+ setViewerOpen(false)}
+ />
+ )}
+
+ );
+}
diff --git a/components/content/ProjectImage.tsx b/components/content/ProjectImage.tsx
new file mode 100644
index 0000000..eb39014
--- /dev/null
+++ b/components/content/ProjectImage.tsx
@@ -0,0 +1,48 @@
+'use client';
+
+import { useState } from "react";
+import Image from "next/image";
+import { IconZoomInFilled } from "@tabler/icons-react";
+
+interface ProjectImageProps {
+ src: string;
+ alt: string;
+ caption?: string;
+ onClick?: () => void;
+}
+
+export default function ProjectImage({ src, alt, caption, onClick }: ProjectImageProps) {
+ const [isHovered, setIsHovered] = useState(false);
+
+ const handleMouseEnter = () => {
+ setIsHovered(true);
+ };
+
+ const handleMouseLeave = () => {
+ setIsHovered(false);
+ };
+
+ return (
+
+
+
+
+
+ {isHovered && }
+
+
+ );
+}
diff --git a/components/ui/ImageViewer.tsx b/components/ui/ImageViewer.tsx
new file mode 100644
index 0000000..5cb11e4
--- /dev/null
+++ b/components/ui/ImageViewer.tsx
@@ -0,0 +1,191 @@
+'use client';
+
+import { useEffect, useRef, useState } from 'react';
+import { createPortal } from 'react-dom';
+import { IconX, IconChevronLeft, IconChevronRight } from '@tabler/icons-react';
+import { usePanZoom } from '@/hooks/usePanZoom';
+import type { Screenshot } from '@/constants/projects';
+
+type ImageViewerProps = {
+ items: Screenshot[];
+ index: number;
+ onIndexChange: (i: number) => void;
+ onClose: () => void;
+};
+
+/** Caption to display — falls back to the required `alt` text. */
+const captionOf = (s: Screenshot) => s.caption ?? s.alt;
+
+/**
+ * Full-bleed fullscreen image viewer with pan / pinch / wheel zoom, keyboard
+ * navigation, and a thumbnail rail (vertical on desktop, horizontal at the
+ * bottom on mobile). Borrowed from imagelab-gpu's FullscreenImage, trimmed to
+ * the parts this portfolio needs — no slideshow.
+ */
+export default function ImageViewer({ items, index, onIndexChange, onClose }: ImageViewerProps) {
+ // Natural size is tagged with the index it was measured for, so it reads as
+ // null until the *current* image's onLoad fires — no reset effect needed.
+ const [sized, setSized] = useState<{ i: number; w: number; h: number } | null>(null);
+ const stageRef = useRef(null);
+ const activeThumbRef = useRef(null);
+
+ const len = items.length;
+ const item = items[index];
+ const prev = () => onIndexChange((index - 1 + len) % len);
+ const next = () => onIndexChange((index + 1) % len);
+
+ const { zoom, offset, reframing, handlers } = usePanZoom(stageRef, {
+ resetKey: index,
+ onSwipe: (dir) => onIndexChange((index + dir + len) % len),
+ onTap: onClose,
+ });
+
+ // Lock body scroll while the viewer is open.
+ useEffect(() => {
+ const original = document.body.style.overflow;
+ document.body.style.overflow = 'hidden';
+ return () => { document.body.style.overflow = original; };
+ }, []);
+
+ // Keyboard navigation: arrows to move, Esc to close.
+ useEffect(() => {
+ const onKey = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') onClose();
+ else if (e.key === 'ArrowLeft') onIndexChange((index - 1 + len) % len);
+ else if (e.key === 'ArrowRight') onIndexChange((index + 1) % len);
+ };
+ document.addEventListener('keydown', onKey);
+ return () => document.removeEventListener('keydown', onKey);
+ }, [index, len, onClose, onIndexChange]);
+
+ // Keep the active thumbnail scrolled into view.
+ useEffect(() => {
+ activeThumbRef.current?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
+ }, [index]);
+
+ if (!item) return null;
+
+ const url = item.src;
+
+ return createPortal(
+
+ {/* Ambient backdrop sampled from the current image via heavy blur. */}
+
+ {/* eslint-disable-next-line @next/next/no-img-element */}
+

+
+
+
+ {/* Stage — owns the pan/zoom gestures. */}
+
+
+ {/* eslint-disable-next-line @next/next/no-img-element */}
+

setSized({ i: index, w: e.currentTarget.naturalWidth, h: e.currentTarget.naturalHeight })}
+ className="max-h-full max-w-full select-none rounded-md shadow-[0_20px_70px_rgba(0,0,0,0.7)]"
+ style={{
+ transform: `translate(${offset.x}px, ${offset.y}px) scale(${zoom})`,
+ transition: reframing ? 'transform 250ms cubic-bezier(0.22, 1, 0.36, 1)' : undefined,
+ }}
+ />
+
+
+ {/* Prev / next */}
+ {len > 1 && (
+ <>
+
{ e.stopPropagation(); prev(); }}
+ className="absolute left-4 top-1/2 z-10 flex h-12 w-12 -translate-y-1/2 items-center justify-center rounded-full bg-black/40 text-white/80 backdrop-blur-md transition-colors hover:bg-black/60 hover:text-white"
+ >
+
+
+
{ e.stopPropagation(); next(); }}
+ className="absolute right-4 top-1/2 z-10 flex h-12 w-12 -translate-y-1/2 items-center justify-center rounded-full bg-black/40 text-white/80 backdrop-blur-md transition-colors hover:bg-black/60 hover:text-white"
+ >
+
+
+ >
+ )}
+
+ {/* Close */}
+
{ e.stopPropagation(); onClose(); }}
+ className="absolute right-4 top-4 z-10 flex h-11 w-11 items-center justify-center rounded-full bg-black/40 text-white/80 backdrop-blur-md transition-colors hover:bg-black/60 hover:text-white"
+ >
+
+
+
+ {/* Page counter + dimensions */}
+
+ {index + 1} / {len}
+
+
+ {/* Caption */}
+
+
+ {captionOf(item)}
+
+
+
+
+ {/* Thumbnail rail — horizontal at the bottom on mobile, vertical on the right on desktop. */}
+ {len > 1 && (
+
+ {items.map((shot, i) => {
+ const isActive = i === index;
+ return (
+
onIndexChange(i)}
+ className={`relative shrink-0 overflow-hidden rounded-md border transition-all md:w-full ${
+ isActive
+ ? 'border-white/80 opacity-100 ring-2 ring-white/60'
+ : 'border-white/10 opacity-60 hover:opacity-100'
+ }`}
+ >
+ {/* eslint-disable-next-line @next/next/no-img-element */}
+
+
+ );
+ })}
+
+ )}
+
,
+ document.body,
+ );
+}
diff --git a/components/ui/Modal.tsx b/components/ui/Modal.tsx
new file mode 100644
index 0000000..032818a
--- /dev/null
+++ b/components/ui/Modal.tsx
@@ -0,0 +1,71 @@
+'use client';
+
+import { IconXFilled } from "@tabler/icons-react";
+import { useEffect, useState } from "react";
+import { createPortal } from 'react-dom';
+
+type ModalProps = {
+ children?: React.ReactNode;
+ headline?: string;
+ open?: boolean;
+ setOpen?: (open: boolean) => void;
+};
+
+export default function Modal({ children, headline, open, setOpen }: ModalProps) {
+ const [mounted, setMounted] = useState(false);
+
+ useEffect(() => {
+ setMounted(true);
+ }, []);
+
+ useEffect(() => {
+ if (!open) return;
+ const original = document.body.style.overflow;
+ document.body.style.overflow = 'hidden';
+ return () => {
+ document.body.style.overflow = original;
+ };
+ }, [open]);
+
+ useEffect(() => {
+ if (!open) return;
+ const onKey = (e: KeyboardEvent) => e.key === 'Escape' && handleClose();
+ document.addEventListener('keydown', onKey);
+ return () => document.removeEventListener('keydown', onKey);
+ }, [open]);
+
+ const handleClose = () => {
+ if (setOpen) setOpen(false);
+ };
+
+ if (!mounted || !open) return null;
+
+ return createPortal(
+
+
e.stopPropagation()}
+ className="fixed inset-0 m-2 sm:m-24 sm:mx-64 xs:mx-64 flex flex-col rounded-md border border-neutral-700 bg-neutral-900/50 p-2"
+ >
+
+
+ {children}
+
+
+
,
+ document.body
+ );
+}
diff --git a/hooks/usePanZoom.ts b/hooks/usePanZoom.ts
new file mode 100644
index 0000000..e1bc5f2
--- /dev/null
+++ b/hooks/usePanZoom.ts
@@ -0,0 +1,213 @@
+import { useEffect, useRef, useState, type RefObject } from 'react';
+import { THROW_MIN_DISTANCE, createVelocityTracker, runInertia } from '@/lib/momentum';
+
+const ZOOM_MIN = 1;
+const ZOOM_MAX = 8;
+const SWIPE_THRESHOLD = 80; // px of horizontal flick (at zoom ~1) to navigate
+const CLICK_BUFFER = 6; // px of travel under which a press counts as a tap
+
+type Options = {
+ resetKey: unknown;
+ onSwipe?: (dir: -1 | 1) => void;
+ onTap?: () => void;
+};
+
+export type PanZoom = {
+ zoom: number;
+ offset: { x: number; y: number };
+ reframing: boolean;
+ handlers: {
+ onWheel: (e: React.WheelEvent) => void;
+ onPointerDown: (e: React.PointerEvent) => void;
+ onPointerMove: (e: React.PointerEvent) => void;
+ onPointerUp: (e: React.PointerEvent) => void;
+ onClick: (e: React.MouseEvent) => void;
+ };
+};
+
+/**
+ * Pan + pinch-zoom + wheel-zoom + release momentum for a CSS-transformed
+ * element. Pure gesture mechanics built on `lib/momentum` — the consumer
+ * supplies `onSwipe` for un-zoomed horizontal flicks and `onTap` for plain
+ * presses, and applies the returned `zoom`/`offset` as a transform.
+ */
+export function usePanZoom(
+ containerRef: RefObject,
+ { resetKey, onSwipe, onTap }: Options,
+): PanZoom {
+ const [zoom, setZoom] = useState(1);
+ const [offset, setOffset] = useState({ x: 0, y: 0 });
+ const [reframing, setReframing] = useState(false);
+ const reframeTimerRef = useRef(null);
+
+ const reframe = () => {
+ setZoom(ZOOM_MIN);
+ setOffset({ x: 0, y: 0 });
+ setReframing(true);
+ if (reframeTimerRef.current != null) window.clearTimeout(reframeTimerRef.current);
+ reframeTimerRef.current = window.setTimeout(() => setReframing(false), 260);
+ };
+
+ const pointersRef = useRef(new Map());
+ const downPosRef = useRef<{ x: number; y: number } | null>(null);
+ const panStartRef = useRef<{ ox: number; oy: number } | null>(null);
+ const pinchStartRef = useRef<{ d: number; z: number; ox: number; oy: number; cx: number; cy: number } | null>(null);
+ const velocity = useRef(createVelocityTracker());
+ const cancelInertiaRef = useRef<(() => void) | null>(null);
+ const zoomRef = useRef(zoom);
+ const offsetRef = useRef(offset);
+ useEffect(() => { zoomRef.current = zoom; }, [zoom]);
+ useEffect(() => { offsetRef.current = offset; }, [offset]);
+
+ const stopInertia = () => {
+ cancelInertiaRef.current?.();
+ cancelInertiaRef.current = null;
+ };
+
+ const [prevKey, setPrevKey] = useState(resetKey);
+ if (prevKey !== resetKey) {
+ setPrevKey(resetKey);
+ setZoom(ZOOM_MIN);
+ setOffset({ x: 0, y: 0 });
+ setReframing(false);
+ }
+
+ useEffect(() => {
+ return () => {
+ stopInertia();
+ if (reframeTimerRef.current != null) window.clearTimeout(reframeTimerRef.current);
+ };
+ }, [resetKey]);
+ useEffect(() => () => {
+ stopInertia();
+ if (reframeTimerRef.current != null) window.clearTimeout(reframeTimerRef.current);
+ }, []);
+
+ const dist = (a: { x: number; y: number }, b: { x: number; y: number }) =>
+ Math.hypot(a.x - b.x, a.y - b.y);
+
+ const onWheel = (e: React.WheelEvent) => {
+ e.preventDefault();
+ stopInertia();
+ const r = containerRef.current?.getBoundingClientRect();
+ if (!r) return;
+ const mx = e.clientX - r.left - r.width / 2;
+ const my = e.clientY - r.top - r.height / 2;
+ const factor = Math.exp(-e.deltaY * 0.001);
+ const z = zoomRef.current;
+ const newZoom = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, z * factor));
+ // Hit the floor → reframe to the original framing.
+ if (newZoom <= ZOOM_MIN + 0.001 && z > ZOOM_MIN + 0.001) {
+ reframe();
+ return;
+ }
+ const k = newZoom / z - 1;
+ setOffset(o => ({ x: o.x - mx * k, y: o.y - my * k }));
+ setZoom(newZoom);
+ };
+
+ const onPointerDown = (e: React.PointerEvent) => {
+ if ((e.target as HTMLElement).closest('button')) return;
+ stopInertia();
+ e.currentTarget.setPointerCapture(e.pointerId);
+ const map = pointersRef.current;
+ map.set(e.pointerId, { x: e.clientX, y: e.clientY, sx: e.clientX, sy: e.clientY });
+
+ if (map.size === 1) {
+ panStartRef.current = { ox: offsetRef.current.x, oy: offsetRef.current.y };
+ pinchStartRef.current = null;
+ downPosRef.current = { x: e.clientX, y: e.clientY };
+ velocity.current.reset(e.clientX, e.clientY);
+ } else if (map.size === 2) {
+ downPosRef.current = null;
+ const [a, b] = [...map.values()];
+ const r = containerRef.current!.getBoundingClientRect();
+ pinchStartRef.current = {
+ d: dist(a, b),
+ z: zoomRef.current,
+ ox: offsetRef.current.x,
+ oy: offsetRef.current.y,
+ cx: (a.x + b.x) / 2 - r.left - r.width / 2,
+ cy: (a.y + b.y) / 2 - r.top - r.height / 2,
+ };
+ panStartRef.current = null;
+ }
+ };
+
+ const onPointerMove = (e: React.PointerEvent) => {
+ const map = pointersRef.current;
+ const pt = map.get(e.pointerId);
+ if (!pt) return;
+ pt.x = e.clientX;
+ pt.y = e.clientY;
+
+ if (map.size === 1 && panStartRef.current) {
+ const only = [...map.values()][0];
+ setOffset({
+ x: panStartRef.current.ox + (only.x - only.sx),
+ y: panStartRef.current.oy + (only.y - only.sy),
+ });
+ velocity.current.sample(e.clientX, e.clientY);
+ } else if (map.size === 2 && pinchStartRef.current) {
+ const [a, b] = [...map.values()];
+ const r = containerRef.current?.getBoundingClientRect();
+ if (!r) return;
+ const start = pinchStartRef.current;
+ const newD = dist(a, b);
+ const newZoom = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, start.z * newD / start.d));
+ const ncx = (a.x + b.x) / 2 - r.left - r.width / 2;
+ const ncy = (a.y + b.y) / 2 - r.top - r.height / 2;
+ const imgPx = (start.cx - start.ox) / start.z;
+ const imgPy = (start.cy - start.oy) / start.z;
+ setZoom(newZoom);
+ setOffset({ x: ncx - newZoom * imgPx, y: ncy - newZoom * imgPy });
+ }
+ };
+
+ const onPointerUp = (e: React.PointerEvent) => {
+ const map = pointersRef.current;
+ const pt = map.get(e.pointerId);
+ if (!pt) return;
+ const dx = pt.x - pt.sx;
+ const dy = pt.y - pt.sy;
+ map.delete(e.pointerId);
+
+ if (map.size === 0) {
+ if (zoomRef.current <= ZOOM_MIN + 0.001 && (offsetRef.current.x !== 0 || offsetRef.current.y !== 0)) {
+ reframe();
+ } else if (panStartRef.current) {
+ const moved = Math.hypot(dx, dy);
+ if (zoomRef.current <= 1.05) {
+ if (moved > SWIPE_THRESHOLD && Math.abs(dx) > Math.abs(dy)) {
+ onSwipe?.(dx > 0 ? -1 : 1);
+ } else {
+ setOffset({ x: 0, y: 0 });
+ }
+ } else if (moved > THROW_MIN_DISTANCE) {
+ stopInertia();
+ cancelInertiaRef.current = runInertia(velocity.current.release(), (sdx, sdy) =>
+ setOffset(o => ({ x: o.x + sdx, y: o.y + sdy })));
+ }
+ }
+ panStartRef.current = null;
+ pinchStartRef.current = null;
+ } else if (map.size === 1) {
+ const remaining = [...map.values()][0];
+ remaining.sx = remaining.x;
+ remaining.sy = remaining.y;
+ panStartRef.current = { ox: offsetRef.current.x, oy: offsetRef.current.y };
+ pinchStartRef.current = null;
+ velocity.current.reset(remaining.x, remaining.y);
+ }
+ };
+
+ const onClick = (e: React.MouseEvent) => {
+ if ((e.target as HTMLElement).closest('button')) return;
+ const down = downPosRef.current;
+ downPosRef.current = null;
+ if (!down) return;
+ if (Math.hypot(e.clientX - down.x, e.clientY - down.y) <= CLICK_BUFFER) onTap?.();
+ };
+
+ return { zoom, offset, reframing, handlers: { onWheel, onPointerDown, onPointerMove, onPointerUp, onClick } };
+}
diff --git a/lib/momentum.ts b/lib/momentum.ts
new file mode 100644
index 0000000..ddccbbc
--- /dev/null
+++ b/lib/momentum.ts
@@ -0,0 +1,70 @@
+export const THROW_MIN_DISTANCE = 14;
+export const THROW_GRACE_MS = 24;
+export const THROW_STALE_MS = 90;
+
+export function freshness(idleMs: number): number {
+ if (idleMs <= THROW_GRACE_MS) return 1;
+ return Math.max(0, 1 - (idleMs - THROW_GRACE_MS) / (THROW_STALE_MS - THROW_GRACE_MS));
+}
+
+export type Vec = { x: number; y: number };
+
+export function createVelocityTracker() {
+ let last: { x: number; y: number; t: number } | null = null;
+ let vx = 0;
+ let vy = 0;
+
+ return {
+ reset(x: number, y: number) {
+ last = { x, y, t: performance.now() };
+ vx = 0;
+ vy = 0;
+ },
+ sample(x: number, y: number) {
+ const now = performance.now();
+ if (last) {
+ const dt = now - last.t;
+ if (dt > 0) {
+ vx = vx * 0.7 + ((x - last.x) / dt) * 0.3;
+ vy = vy * 0.7 + ((y - last.y) / dt) * 0.3;
+ }
+ }
+ last = { x, y, t: now };
+ },
+ release(): Vec {
+ const idle = last ? performance.now() - last.t : Infinity;
+ const f = freshness(idle);
+ return { x: vx * f, y: vy * f };
+ },
+ };
+}
+
+export function runInertia(
+ velocity: Vec,
+ onStep: (dx: number, dy: number) => void,
+ opts: { minSpeed?: number; stopSpeed?: number; decayPerFrame?: number } = {},
+): () => void {
+ const minSpeed = opts.minSpeed ?? 0.04;
+ const stopSpeed = opts.stopSpeed ?? 0.012;
+ const decayPerFrame = opts.decayPerFrame ?? 0.94;
+ let { x: vx, y: vy } = velocity;
+ if (Math.hypot(vx, vy) < minSpeed) return () => { /* nothing started */ };
+
+ let raf = 0;
+ let last = performance.now();
+ const tick = (now: number) => {
+ const dt = Math.min(40, now - last);
+ last = now;
+ onStep(vx * dt, vy * dt);
+ const decay = Math.pow(decayPerFrame, dt / 16);
+ vx *= decay;
+ vy *= decay;
+ if (Math.hypot(vx, vy) > stopSpeed) {
+ raf = requestAnimationFrame(tick);
+ } else {
+ raf = 0;
+ }
+ };
+ raf = requestAnimationFrame(tick);
+ return () => { if (raf) cancelAnimationFrame(raf); };
+}