// Direction 3: Dark Room // Black background, cinematic, images glow. Tasteful 3D: cards tilt slightly // on mouse-move (parallax card effect), soft emissive glow behind featured // images, subtle grain overlay for that projection-room feel. const DR_BG = '#0a0a0a'; const DR_BG2 = '#141414'; const DR_INK = '#f0ebe2'; // warm cream text const DR_DIM = '#8a8578'; const DR_LINE = 'rgba(240,235,226,0.12)'; const DR_ACCENT = '#d4c59a'; // warm brass // Append the build-stamped ?v=HASH to image URLs so content changes propagate // to returning visitors without depending on cache-control heuristics. const srcV = (src) => (typeof window !== 'undefined' && window.__IMG_VER ? `${src}?v=${window.__IMG_VER}` : src); // Responsive tier helper — three tiers so layout scales smoothly across // phone, iPad, and desktop. Breakpoints chosen to match common devices: // mobile: < 720 (phones, small phones in landscape) // tablet: 720..1099 (iPad portrait 810/834, iPad landscape 1024/1180) // desktop: >= 1100 (laptops and up) function useTier() { const get = () => { const w = typeof window !== 'undefined' ? window.innerWidth : 1440; if (w < 720) return 'mobile'; if (w < 1100) return 'tablet'; return 'desktop'; }; const [tier, setTier] = React.useState(get); React.useEffect(() => { const on = () => setTier(get()); window.addEventListener('resize', on); window.addEventListener('orientationchange', on); return () => { window.removeEventListener('resize', on); window.removeEventListener('orientationchange', on); }; }, []); return { tier, isMob: tier === 'mobile', isTab: tier === 'tablet', isDesk: tier === 'desktop', // isNarrow = phone OR tablet — useful where the change is "not desktop" isNarrow: tier !== 'desktop', }; } // Back-compat: some call sites still just need the phone/narrow distinction function useIsMobile() { return useTier().isMob; } function DR_Root() { // `open` is null or { photo, list, index }. Keeping list + index alongside // the selected photo lets the lightbox page through the current view // (gallery filter order, hero, etc.) with arrow keys or a swipe. const [open, setOpen] = React.useState(null); const [scroll, setScroll] = React.useState(0); const [filter, setFilter] = React.useState('All'); const [enquiry, setEnquiry] = React.useState(null); // null | { mode: 'prints'|'contact', photoTitle?: string } const rootRef = React.useRef(null); const openPhoto = React.useCallback((photo, list, startIndex) => { const l = (list && list.length) ? list : [photo]; const i = Number.isInteger(startIndex) ? startIndex : l.indexOf(photo); setOpen({ photo, list: l, index: i >= 0 ? i : 0 }); }, []); const gotoOffset = React.useCallback((offset) => { setOpen((prev) => { if (!prev) return prev; const len = prev.list.length; if (len <= 1) return prev; const next = (prev.index + offset + len) % len; return { ...prev, photo: prev.list[next], index: next }; }); }, []); React.useEffect(() => { const el = rootRef.current; if (!el) return; const onScroll = () => setScroll(el.scrollTop); el.addEventListener('scroll', onScroll, { passive: true }); return () => el.removeEventListener('scroll', onScroll); }, []); return (
{/* Faint film grain overlay */}
")`, }} /> setEnquiry({ mode: 'prints' })} onContact={() => setEnquiry({ mode: 'contact' })} /> setEnquiry({ mode: 'prints' })} onContact={() => setEnquiry({ mode: 'contact' })} /> {open && ( 1} onClose={() => setOpen(null)} onPrev={() => gotoOffset(-1)} onNext={() => gotoOffset(1)} /> )} {enquiry && setEnquiry(null)} />}
); } function DR_Nav({ scroll, onPrints, onContact }) { const solid = scroll > 80; const { isMob, isTab, isNarrow } = useTier(); const [open, setOpen] = React.useState(false); const items = [ { label: 'Works', href: '#works' }, { label: 'Series', href: '#series' }, { label: 'About', href: '#about' }, { label: 'Prints', href: '#', onClick: (e) => { e.preventDefault(); onPrints(); setOpen(false); } }, { label: 'Contact', href: '#', onClick: (e) => { e.preventDefault(); onContact(); setOpen(false); } }, ]; // Tablet uses a compact horizontal menu — shorter gap, smaller type, no "Photography" tag const padX = isMob ? 20 : isTab ? 32 : 48; const padY = isMob ? 14 : 18; return ( ); } function DR_Hero({ scroll, onOpen }) { const hero = window.PHOTOS.find(p => p.id === 'colorado') || window.PHOTOS[0]; const parallax = Math.min(scroll * 0.35, 220); const { isMob, isTab } = useTier(); const [tilt, setTilt] = React.useState({ rx: 0, ry: 0 }); const heroRef = React.useRef(null); const onMove = (e) => { if (isMob) return; const el = heroRef.current; if (!el) return; const r = el.getBoundingClientRect(); const x = (e.clientX - r.left) / r.width; const y = (e.clientY - r.top) / r.height; setTilt({ rx: -(y - 0.5) * 2.5, ry: (x - 0.5) * 2.5 }); }; const onLeave = () => { setTilt({ rx: 0, ry: 0 }); }; const padX = isMob ? 20 : isTab ? 32 : 48; const padTop = isMob ? 32 : isTab ? 48 : 60; const padBottom = isMob ? 60 : isTab ? 90 : 120; return (
Selected work · 2019 – 2026
Twenty-nine plates

Waves, weather,
and mostly wild places.

onOpen(hero)} style={{ position: 'relative', perspective: 1400, cursor: 'zoom-in', }}> {/* Glow backdrop */}
{hero.title} {/* Vignette */}
{/* Print-thickness edges: thin inner highlight top, deep inner shadow bottom */}
I — {hero.title} {hero.location} · {hero.year}
); } function DR_Marquee() { const names = window.SERIES.map(s => s.name).join(' · '); const txt = ` ${names} · `; return (
{txt.repeat(4)}
); } function DR_Gallery({ onOpen, filter, setFilter }) { const series = ['All', ...window.SERIES.map(s => s.name)]; // Shuffle once per page load (Fisher-Yates). Stable across re-renders via // useMemo so filter-chip clicks don't re-shuffle mid-session. const shuffled = React.useMemo(() => { const arr = [...window.PHOTOS]; for (let i = arr.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [arr[i], arr[j]] = [arr[j], arr[i]]; } return arr; }, []); const photos = filter === 'All' ? shuffled : shuffled.filter(p => p.series === filter); const { isMob, isTab } = useTier(); const padX = isMob ? 20 : isTab ? 32 : 48; const padY = isMob ? 60 : isTab ? 80 : 100; // Masonry everywhere: 2 cols on phone, 3 on iPad, 3 on desktop. const cols = isMob ? 2 : 3; const gap = isMob ? 12 : isTab ? 20 : 32; return (

Works

{series.map(s => ( ))}
{(() => { // Wrap the open callback so the lightbox gets the currently visible // (filtered + shuffled) list for arrow-key / swipe navigation. const handleOpen = (photo) => onOpen(photo, photos, photos.indexOf(photo)); const columns = Array.from({ length: cols }, () => []); const heights = new Array(cols).fill(0); photos.forEach((p, i) => { const h = p.aspect === 'portrait' ? 1.5 : 1; let shortest = 0; for (let c = 1; c < cols; c++) { if (heights[c] < heights[shortest]) shortest = c; } columns[shortest].push({ photo: p, index: i }); heights[shortest] += h; }); return columns.map((col, ci) => (
{col.map(({ photo, index }) => ( ))}
)); })()}
); } function DR_Card({ photo, index, onOpen }) { const { isMob, isTab } = useTier(); const bottomGap = isMob ? 16 : isTab ? 24 : 40; const [hover, setHover] = React.useState(false); const [tilt, setTilt] = React.useState({ rx: 0, ry: 0, mx: 50, my: 50 }); const [revealed, setRevealed] = React.useState(false); const ref = React.useRef(null); // Scroll-triggered settle: as the card enters the viewport, rise 16px with // a gentle ease. Reads as "the print being placed." React.useEffect(() => { const el = ref.current; if (!el) return; const io = new IntersectionObserver((entries) => { entries.forEach(en => { if (en.isIntersecting) { setRevealed(true); io.disconnect(); } }); }, { threshold: 0.15, rootMargin: '0px 0px -40px 0px' }); io.observe(el); return () => io.disconnect(); }, []); const onMove = (e) => { const el = ref.current; if (!el) return; const r = el.getBoundingClientRect(); const x = (e.clientX - r.left) / r.width; const y = (e.clientY - r.top) / r.height; setTilt({ rx: -(y - 0.5) * 6, ry: (x - 0.5) * 6, mx: x * 100, my: y * 100 }); }; const onLeave = () => { setHover(false); setTilt({ rx: 0, ry: 0, mx: 50, my: 50 }); }; return (
setHover(true)} onMouseMove={onMove} onMouseLeave={onLeave} onClick={() => onOpen(photo)} style={{ breakInside: 'avoid', margin: `0 0 ${bottomGap}px`, cursor: 'zoom-in', perspective: 1200, opacity: revealed ? 1 : 0, transform: revealed ? 'translateY(0)' : 'translateY(16px)', transition: `opacity .9s cubic-bezier(.2,.7,.3,1) ${Math.min(index * 60, 360)}ms, transform .9s cubic-bezier(.2,.7,.3,1) ${Math.min(index * 60, 360)}ms`, }}>
{/* Glow backdrop */}
{photo.title} {/* Specular highlight following cursor */}
{/* Print-thickness edge */}
{photo.title}
{photo.location}
{isMob ? String(index + 1).padStart(2, '0') : `${String(index + 1).padStart(2, '0')} / ${photo.year}`}
); } function DR_Series({ onFilter }) { const { isMob, isTab } = useTier(); const padX = isMob ? 20 : isTab ? 32 : 48; const padY = isMob ? 60 : isTab ? 80 : 100; const seriesCols = isMob ? 2 : isTab ? 3 : 4; const handleClick = (e, name) => { e.preventDefault(); onFilter(name); // Find the scrollable root and the Works section, scroll manually. requestAnimationFrame(() => { const works = document.getElementById('works'); if (!works) return; let root = works.parentElement; while (root && getComputedStyle(root).overflowY !== 'auto') root = root.parentElement; if (root) { const top = works.getBoundingClientRect().top - root.getBoundingClientRect().top + root.scrollTop; root.scrollTo({ top, behavior: 'smooth' }); } }); }; return (

Series

{window.SERIES.map(s => { const cover = window.PHOTOS.find(p => s.coverId ? p.id === s.coverId : p.series === s.name); return ( handleClick(e, s.name)} style={{ textDecoration: 'none', color: 'inherit', display: 'block', position: 'relative', overflow: 'hidden', aspectRatio: '3/4', cursor: 'pointer' }} className="dr-series-tile"> {s.name}
{s.count} works · {s.years}
{s.name}
); })}
); } function DR_About() { const { isMob, isTab, isNarrow } = useTier(); const padX = isMob ? 20 : isTab ? 32 : 48; const padY = isMob ? 72 : isTab ? 96 : 120; return (
About
p.id === 'lone-surfer') || window.PHOTOS[0]).src)} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover', filter: 'saturate(0.85)' }} />

Karl is a travel and landscape photographer based on Oahu's North Shore, by way of Media, PA.

Recent work spans Hawaiʻi, Colorado, French Polynesia, Scotland, and the quarter-mile between his front door and the water. Prints are hand-made on archival paper on request.

{[ { n: '20+', l: 'countries' }, { n: '29', l: 'final plates' }, { n: '4', l: 'series' }, ].map(s => (
{s.n}
{s.l}
))}
); } function DR_Footer({ onPrints, onContact }) { const { isMob, isTab } = useTier(); const padX = isMob ? 20 : isTab ? 32 : 48; return ( ); } function DR_Lightbox({ photo, onClose, onPrev, onNext, canNavigate }) { const { isMob, isNarrow } = useTier(); React.useEffect(() => { const onKey = (e) => { if (e.key === 'Escape') onClose(); else if (canNavigate && e.key === 'ArrowLeft') onPrev(); else if (canNavigate && e.key === 'ArrowRight') onNext(); }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [onClose, onPrev, onNext, canNavigate]); const [mounted, setMounted] = React.useState(false); React.useEffect(() => { requestAnimationFrame(() => setMounted(true)); }, []); const navBtn = (side) => ({ position: 'fixed', top: '50%', [side]: 20, transform: 'translateY(-50%)', width: 48, height: 48, borderRadius: '50%', background: 'rgba(10,10,10,0.6)', backdropFilter: 'blur(8px)', border: `1px solid ${DR_LINE}`, cursor: 'pointer', color: DR_INK, fontSize: 28, lineHeight: 1, fontFamily: 'inherit', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 0, zIndex: 2, transition: 'background .2s, border-color .2s', }); // Zoom + gesture state. Pinch = zoom (1x–4x), 1-finger drag while zoomed = pan, // 1-finger drag at 1x = swipe to prev/next. const [zoom, setZoom] = React.useState({ scale: 1, x: 0, y: 0 }); const [isGesturing, setIsGesturing] = React.useState(false); React.useEffect(() => { setZoom({ scale: 1, x: 0, y: 0 }); }, [photo?.src]); const gesture = React.useRef(null); const touchStart = React.useRef(null); const onTouchStart = (e) => { if (e.touches.length === 2) { const [a, b] = e.touches; gesture.current = { type: 'pinch', startDist: Math.hypot(b.clientX - a.clientX, b.clientY - a.clientY), startScale: zoom.scale, }; touchStart.current = null; setIsGesturing(true); } else if (e.touches.length === 1) { const t = e.touches[0]; if (zoom.scale > 1) { gesture.current = { type: 'pan', startX: t.clientX, startY: t.clientY, origX: zoom.x, origY: zoom.y, }; setIsGesturing(true); } else { touchStart.current = { x: t.clientX, y: t.clientY, time: Date.now() }; gesture.current = null; } } }; const onTouchMove = (e) => { const g = gesture.current; if (!g) return; if (g.type === 'pinch' && e.touches.length === 2) { const [a, b] = e.touches; const dist = Math.hypot(b.clientX - a.clientX, b.clientY - a.clientY); const scale = Math.max(1, Math.min(4, g.startScale * (dist / g.startDist))); setZoom(z => ({ ...z, scale })); } else if (g.type === 'pan' && e.touches.length === 1) { const t = e.touches[0]; setZoom(z => ({ ...z, x: g.origX + (t.clientX - g.startX), y: g.origY + (t.clientY - g.startY), })); } }; const onTouchEnd = (e) => { const g = gesture.current; if (g?.type === 'pinch' && zoom.scale <= 1.05) { setZoom({ scale: 1, x: 0, y: 0 }); } if (!g && touchStart.current && canNavigate) { const t = e.changedTouches[0]; const dx = t.clientX - touchStart.current.x; const dy = t.clientY - touchStart.current.y; const dt = Date.now() - touchStart.current.time; if (dt <= 700 && Math.abs(dx) >= 50 && Math.abs(dy) <= Math.abs(dx) * 0.8) { if (dx < 0) onNext(); else onPrev(); } } touchStart.current = null; if (e.touches.length === 0) { gesture.current = null; setIsGesturing(false); } }; return (
{canNavigate && !isMob && ( <> )}
e.stopPropagation()} onTouchStart={onTouchStart} onTouchMove={onTouchMove} onTouchEnd={onTouchEnd} style={{ display: 'grid', gridTemplateColumns: isNarrow ? '1fr' : '1fr 300px', gap: isMob ? 24 : isNarrow ? 32 : 48, maxWidth: 1400, width: '100%', perspective: 1800, cursor: 'default', }}>
{photo.title} 1 ? 'none' : 'pan-y', willChange: 'transform', }} />
); } window.DR_Root = DR_Root; // ───────────────────────────────────────────────────────────── // Print enquiry modal // ───────────────────────────────────────────────────────────── // Submits to Formspree. To wire: create a form at formspree.io, then replace // FORMSPREE_ENDPOINT below. Until then it falls back to mailto on submit. const FORMSPREE_ENDPOINT = 'https://formspree.io/f/mqewwbdg'; function DR_EnquiryModal({ mode = 'prints', prefill, onClose }) { const isPrints = mode === 'prints'; const isMob = useIsMobile(); const [mounted, setMounted] = React.useState(false); const [state, setState] = React.useState('idle'); // idle | sending | sent | error const [form, setForm] = React.useState({ name: '', email: '', photo: prefill || '', size: '8.5 × 11"', message: '', }); React.useEffect(() => { requestAnimationFrame(() => setMounted(true)); }, []); React.useEffect(() => { const onKey = (e) => { if (e.key === 'Escape' && state !== 'sending') onClose(); }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [onClose, state]); const update = (k) => (e) => setForm({ ...form, [k]: e.target.value }); const mailtoFallback = () => { const subjectBase = isPrints ? (form.photo ? `Print enquiry: ${form.photo}` : 'Print enquiry') : 'Hello from your site'; const lines = [ `Name: ${form.name}`, `Email: ${form.email}`, ]; if (isPrints) { if (form.photo) lines.push(`Photograph: ${form.photo}`); lines.push(`Size: ${form.size}`); } lines.push('', form.message); window.location.href = `mailto:krauch025@gmail.com?subject=${encodeURIComponent(subjectBase)}&body=${encodeURIComponent(lines.join('\n'))}`; }; const onSubmit = async (e) => { e.preventDefault(); if (!form.name || !form.email) return; const subject = isPrints ? (form.photo ? `Print enquiry: ${form.photo}` : 'Print enquiry') : 'Hello from karlrauch.com'; const payload = { mode, ...form, _subject: subject, _replyto: form.email }; if (!isPrints) { delete payload.photo; delete payload.size; } if (!FORMSPREE_ENDPOINT) { mailtoFallback(); setState('sent'); return; } setState('sending'); try { const res = await fetch(FORMSPREE_ENDPOINT, { method: 'POST', headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); if (res.ok) setState('sent'); else setState('error'); } catch (err) { setState('error'); } }; const inputStyle = { width: '100%', padding: '12px 14px', background: 'rgba(255,255,255,0.04)', border: `1px solid ${DR_LINE}`, borderRadius: 2, color: DR_INK, fontFamily: 'inherit', fontSize: 16, letterSpacing: '0.01em', transition: 'border-color .2s, background .2s', boxSizing: 'border-box', }; const labelStyle = { display: 'block', fontSize: 10, letterSpacing: '0.18em', textTransform: 'uppercase', color: DR_DIM, marginBottom: 8, }; return (
state !== 'sending' && onClose()} style={{ position: 'fixed', inset: 0, zIndex: 400, background: mounted ? 'rgba(6,6,6,0.94)' : 'rgba(6,6,6,0)', backdropFilter: 'blur(16px)', WebkitBackdropFilter: 'blur(16px)', display: 'flex', alignItems: isMob ? 'flex-start' : 'center', justifyContent: 'center', padding: isMob ? 0 : 24, transition: 'background .3s', overflowY: 'auto', }}>
e.stopPropagation()} style={{ width: '100%', maxWidth: 560, background: DR_BG2, border: isMob ? 'none' : `1px solid ${DR_LINE}`, padding: isMob ? '56px 20px 32px' : '48px 48px 40px', position: 'relative', minHeight: isMob ? '100vh' : 'auto', opacity: mounted ? 1 : 0, transform: mounted ? 'translateY(0)' : 'translateY(16px)', transition: 'opacity .4s, transform .5s cubic-bezier(.2,.7,.3,1)', boxShadow: isMob ? 'none' : '0 40px 80px -20px rgba(0,0,0,0.7)', }}> {state === 'sent' ? (
Thank you.

{isPrints ? 'Karl will be in touch within a few days. Prints are hand-made, so timelines vary a little with season.' : 'Karl will get back to you within a few days.'}

) : ( <> {!isPrints && (
Karl Rauch
Say hello
)} {isPrints && (
Enquire
)}

{isPrints ? 'About a print.' : 'Get in touch.'}

{isPrints ? "Each print is hand-made on archival paper on request. Tell me what you're drawn to and I'll reply within a few days." : "Questions, hellos, commissions, or just something nice to say — all welcome."}

{isPrints && ( <>
)}