// nav-hero.jsx — Top nav, Hero (layouts), Marquee ticker, Statement const HERO_IMG = 'images/lamia-hero.jpg'; const HERO_IMG_B = 'images/lamia-b.png'; const HERO_IMG_C = 'images/lamia-a.png'; const HERO_IMG_D = 'images/hero-ceremony.jpg'; const MARQUEE_ITEMS = (window.CMS_DATA && window.CMS_DATA.marquee) || [ 'UGC Creator', 'Shot on iPhone', 'High-end visuals', 'Creative Edits', 'Organic Content', 'Strategic Storytelling', 'Social-First', 'Fast Turnarounds', 'BTS & Event Coverage', 'Dubai → Worldwide', ]; // Arrow, FontDropdown, ServicesDropdown come from shared.jsx (loaded before this). // ── SubNav: scroll-spy journey bar ─────────────────── const SUB_NAV_SECTIONS = [ { id: 'statement', sel: '.statement', label: 'The Intent' }, { id: 'brands', sel: '.brands-section', label: 'Brands' }, { id: 'services', sel: '#services', label: 'Services' }, { id: 'work', sel: '#work', label: 'Work' }, { id: 'about', sel: '#about', label: 'About' }, { id: 'inquire', sel: '#inquire', label: 'Enquire' }, ]; function SubNav() { const [visible, setVisible] = React.useState(false); const [activeIdx, setActiveIdx] = React.useState(-1); const navRef = React.useRef(null); React.useEffect(() => { const TRIGGER = 0.38; // section is "active" when its top is above 38% vh const update = () => { const vh = window.innerHeight; // Show sub-nav once hero bottom is above the main nav const hero = document.querySelector('.vf-hero, .hero-stack, .hero-split, .hero-bleed, .hero-centered'); if (hero) { const heroBottom = hero.getBoundingClientRect().bottom; setVisible(heroBottom < 68); } // Find current section let cur = -1; SUB_NAV_SECTIONS.forEach((s, i) => { const el = document.querySelector(s.sel); if (!el) return; const { top } = el.getBoundingClientRect(); if (top <= vh * TRIGGER) cur = i; }); setActiveIdx(cur); }; update(); // Poll every 120ms — reliable across all iframe/browser contexts const iv = setInterval(update, 120); window.addEventListener('scroll', update, { passive: true }); return () => { clearInterval(iv); window.removeEventListener('scroll', update); }; }, []); // Auto-scroll the active dot into view inside the sub-nav React.useEffect(() => { if (!navRef.current || activeIdx < 0) return; const dot = navRef.current.querySelectorAll('.sub-nav-stop')[activeIdx]; if (dot) dot.scrollIntoView({ block: 'nearest', inline: 'center', behavior: 'smooth' }); }, [activeIdx]); return ( ); } // NAV_FONTS, FontDropdown, SERVICE_ITEMS, ServicesDropdown come from shared.jsx / services-data.js. function Nav({ onToggleTheme, themeKey, onToggleDark, dark, showControls, fontPair, onChangeFont }) { const [menuOpen, setMenuOpen] = React.useState(false); const close = () => setMenuOpen(false); // Nav stays visible at all times — no scroll-hide. return ( <> {/* Mobile drawer — MVP: no dropdown sub-links, no The Method */}
Services About My Work Enquire
); } function HeroSplit() { return (
Dubai based · Serving worldwide

High-end visuals,
made for the scroll.

A creative studio supporting brands with on-brand UGC, event coverage and evergreen content that converts.

Start a project See the work
Est.
2022 — Dubai
Capacity
3 retainers / mo
Currently
Booking Q3
Lamia, founder & creative director
); } function HeroBleed() { return (
Dubai based · Serving worldwide

High-end visuals,
made for the scroll.

A creative studio supporting brands with on-brand UGC, event coverage and evergreen content that converts.

Start a project
); } function HeroCentered() { return (
Dubai based · Serving worldwide

High-end visuals,
made for the scroll.

A creative studio supporting brands with on-brand UGC, event coverage and evergreen content that converts.

Start a project See the work
); } // ── Viewfinder Hero (replaces the original card-parallax HeroStack) ── // Cards use vf- prefixed CSS classes to avoid conflicts with site styles. // Lens centring fix: ONE clip-path on a non-rotated hero-level overlay // (not per-card), so circle coords are always in straight hero space. const VF_CARDS = [ { id: 'brand', label: 'Product & Brand', focal: '85mm', aperture: 'f/2.8', poster: 'images/card-brand.png', video: 'videos/brand.mp4', }, { id: 'ugc', label: 'UGC & Creator', focal: '50mm', aperture: 'f/1.8', poster: 'images/card-ugc.png', video: 'videos/UGC.mp4', }, { id: 'weddings', label: 'Weddings & Events', focal: '35mm', aperture: 'f/2.0', poster: 'images/card-events.jpg', video: 'videos/events.mp4', }, ]; const VF_LENS_R = 94; const VF_SPRING = 0.10; const VF_TOUCH_DY = -72; function VfReticle({ size = 76, focused }) { const c = size / 2; const tick = Math.round(size * 0.148); const pad = Math.round(size * 0.175); // Use the site's --accent CSS variable so the reticle matches the active theme const sage = 'var(--accent)'; const sageDim = 'color-mix(in oklab, var(--accent) 35%, transparent)'; const TL = `M${pad},${pad+tick} L${pad},${pad} L${pad+tick},${pad}`; const TR = `M${size-pad-tick},${pad} L${size-pad},${pad} L${size-pad},${pad+tick}`; const BR = `M${size-pad},${size-pad-tick} L${size-pad},${size-pad} L${size-pad-tick},${size-pad}`; const BL = `M${pad+tick},${size-pad} L${pad},${size-pad} L${pad},${size-pad-tick}`; return ( {focused && } {focused && } ); } function HeroStack() { const heroRef = React.useRef(null); const overlayRef = React.useRef(null); const cardRefs = React.useRef([]); const videoRefs = React.useRef([]); const ringRef = React.useRef(null); const reticleRef = React.useRef(null); const readoutRef = React.useRef(null); const focalRef = React.useRef(null); const cur = React.useRef({ x: -500, y: -500 }); const target = React.useRef({ x: -500, y: -500 }); const lensWordRef = React.useRef(null); // wrapping "Lens" in the title const idlePos = React.useRef(null); // default lens position (centre of "Lens" word) const rafId = React.useRef(null); const active = React.useRef(-1); const inside = React.useRef(false); // true only while mouse is inside hero // ── Staggered animation states ────────────────────────── const [titleIn, setTitleIn] = React.useState(false); const [subIn, setSubIn] = React.useState(false); const [cardIn, setCardIn] = React.useState([false, false, false]); const [fanned, setFanned] = React.useState(false); const [hasTouch, setHasTouch] = React.useState(false); const [rmReduced] = React.useState( () => window.matchMedia('(prefers-reduced-motion: reduce)').matches ); const [reticleFocused, setReticleFocused] = React.useState(false); const [tapCard, setTapCard] = React.useState(-1); React.useEffect(() => { const mq = window.matchMedia('(hover:none),(pointer:coarse)'); setHasTouch(mq.matches); mq.addEventListener('change', e => setHasTouch(e.matches)); }, []); // Staggered entrance: title → subtext → cards one-by-one → fan → idle lens React.useEffect(() => { if (rmReduced) { // Snap everything visible immediately setTitleIn(true); setSubIn(true); setCardIn([true, true, true]); setFanned(true); return; } const ids = [ setTimeout(() => setTitleIn(true), 80), setTimeout(() => setSubIn(true), 500), // Center card first (highest z), then left, then right setTimeout(() => setCardIn(p => [p[0], true, p[2]]), 920), setTimeout(() => setCardIn(p => [true, p[1], p[2]]), 1120), setTimeout(() => setCardIn(p => [p[0], p[1], true]), 1320), setTimeout(() => setFanned(true), 1680), // Spawn lens over "Lens" word, then sweep left → right → settle setTimeout(() => { if (!lensWordRef.current || !heroRef.current) return; const wr = lensWordRef.current.getBoundingClientRect(); const cx = wr.left + wr.width / 2; const cy = wr.top + wr.height / 2; // Record home position idlePos.current = { x: cx, y: cy }; // Place lens at centre, enable rAF spring so sweep targets animate cur.current = { x: cx, y: cy }; target.current = { x: cx, y: cy }; inside.current = true; moveLens(cx, cy); // Fade in (slow — CSS transition handles the easing) ringRef.current?.classList.add('visible'); reticleRef.current?.classList.add('visible'); readoutRef.current?.classList.add('visible'); // Sweep: left → right → home // Spring (VF_SPRING=0.10) gives a smooth trailing feel between each beat setTimeout(() => { target.current = { x: cx - 72, y: cy }; }, 320); setTimeout(() => { target.current = { x: cx + 72, y: cy }; }, 700); setTimeout(() => { target.current = { x: cx, y: cy }; }, 1080); }, 200), ]; return () => ids.forEach(clearTimeout); }, [rmReduced]); React.useEffect(() => { const vids = videoRefs.current.filter(Boolean); if (!vids.length) return; const obs = new IntersectionObserver( entries => entries.forEach(e => { if (!e.isIntersecting) e.target.pause(); else e.target.play().catch(() => {}); }), { threshold: 0.1 } ); vids.forEach(v => obs.observe(v)); return () => obs.disconnect(); }); const moveLens = React.useCallback((clientX, clientY) => { const hero = heroRef.current; if (!hero) return; const hr = hero.getBoundingClientRect(); const lx = clientX - hr.left; const ly = clientY - hr.top; if (overlayRef.current) overlayRef.current.style.clipPath = `circle(${VF_LENS_R}px at ${lx}px ${ly}px)`; [ringRef, reticleRef, readoutRef].forEach(r => { if (!r.current) return; r.current.style.left = lx + 'px'; r.current.style.top = ly + 'px'; }); let hit = -1; cardRefs.current.forEach((card, i) => { if (!card) return; const r = card.getBoundingClientRect(); if (clientX >= r.left && clientX <= r.right && clientY >= r.top && clientY <= r.bottom) hit = i; }); if (hit !== active.current) { if (active.current >= 0) { cardRefs.current[active.current]?.classList.remove('vf-focused'); videoRefs.current[active.current]?.classList.remove('vf-sharp'); // Revert overlay card to its CSS-defined fanned transform const prev = videoRefs.current[active.current]?.parentElement; if (prev) { prev.style.transform = ''; prev.style.visibility = ''; } } active.current = hit; if (hit >= 0) { cardRefs.current[hit]?.classList.add('vf-focused'); videoRefs.current[hit]?.classList.add('vf-sharp'); if (focalRef.current) focalRef.current.textContent = `${VF_CARDS[hit].focal} ${VF_CARDS[hit].aperture}`; // Mirror the CSS hover lift onto the overlay card so its bounds match // the real card's lifted position — prevents the top of the lens from // falling outside the overlay card and showing the dark background. const HOVER_T = [ 'rotate(-11deg) translateX(-6%) translateY(-14px) scale(1.05)', 'translateX(-50%) translateY(-14px) scale(1.05)', 'rotate(11deg) translateX(6%) translateY(-14px) scale(1.05)', ]; const next = videoRefs.current[hit]?.parentElement; if (next) next.style.transform = HOVER_T[hit]; } else { if (focalRef.current) focalRef.current.textContent = ''; } setReticleFocused(hit >= 0); // Show only the focused overlay card — hides others so they can't bleed videoRefs.current.forEach((v, i) => { const card = v?.parentElement; if (!card) return; card.style.visibility = (hit < 0 || i === hit) ? '' : 'hidden'; }); } }, []); const tick = React.useCallback(() => { if (inside.current) { const p = cur.current, t = target.current; const dx = t.x - p.x, dy = t.y - p.y; if (Math.abs(dx) > .12 || Math.abs(dy) > .12) { p.x += dx * VF_SPRING; p.y += dy * VF_SPRING; moveLens(p.x, p.y); } } rafId.current = requestAnimationFrame(tick); }, [moveLens]); React.useEffect(() => { if (hasTouch || rmReduced) return; rafId.current = requestAnimationFrame(tick); return () => cancelAnimationFrame(rafId.current); }, [hasTouch, rmReduced, tick]); const parkLens = () => { // Return lens to the idle position (over "Lens" word) and clear card focus heroRef.current?.classList.remove('vf-lens-on'); cardRefs.current.forEach(c => c?.classList.remove('vf-focused')); videoRefs.current.forEach(v => v?.classList.remove('vf-sharp')); if (focalRef.current) focalRef.current.textContent = ''; active.current = -1; setReticleFocused(false); if (overlayRef.current) overlayRef.current.style.clipPath = 'circle(0px at 50% 50%)'; if (idlePos.current) { // Spring back to "Lens" word position target.current = { x: idlePos.current.x, y: idlePos.current.y }; inside.current = true; // keep rAF running so spring animates } }; const onPointerMove = React.useCallback((e) => { if (hasTouch || e.pointerType === 'touch') return; // Disable the spring while the mouse is driving — direct updates are instant // and the spring's lag would cause exactly the "follows after many frames" jitter. inside.current = false; target.current = { x: e.clientX, y: e.clientY }; cur.current = { x: e.clientX, y: e.clientY }; moveLens(e.clientX, e.clientY); heroRef.current?.classList.add('vf-lens-on'); ringRef.current?.classList.add('visible'); reticleRef.current?.classList.add('visible'); readoutRef.current?.classList.add('visible'); }, [hasTouch, moveLens]); const onPointerLeave = React.useCallback((e) => { if (hasTouch || e.pointerType === 'touch') return; // Re-enable spring so it animates back to the idle "Lens" word position inside.current = true; parkLens(); }, [hasTouch]); const dragActive = React.useRef(false); const touchOrigin = React.useRef({ x: 0, y: 0 }); const onTouchStart = React.useCallback((e) => { const t = e.touches[0]; touchOrigin.current = { x: t.clientX, y: t.clientY }; dragActive.current = false; let hit = -1; cardRefs.current.forEach((card, i) => { if (!card) return; const r = card.getBoundingClientRect(); if (t.clientX >= r.left && t.clientX <= r.right && t.clientY >= r.top && t.clientY <= r.bottom) hit = i; }); setTapCard(hit); }, []); const onTouchMove = React.useCallback((e) => { const t = e.touches[0]; if (Math.abs(t.clientX - touchOrigin.current.x) > 8 || Math.abs(t.clientY - touchOrigin.current.y) > 8) dragActive.current = true; if (!dragActive.current || !heroRef.current) return; const tx = t.clientX; const ty = t.clientY + VF_TOUCH_DY; const hr = heroRef.current.getBoundingClientRect(); if (overlayRef.current) overlayRef.current.style.clipPath = `circle(${VF_LENS_R}px at ${tx - hr.left}px ${ty - hr.top}px)`; [ringRef, reticleRef].forEach(r => { if (!r.current) return; r.current.style.left = (tx - hr.left) + 'px'; r.current.style.top = (ty - hr.top) + 'px'; }); ringRef.current?.classList.add('visible'); reticleRef.current?.classList.add('visible'); }, []); const onTouchEnd = React.useCallback(() => { if (!dragActive.current) return; if (overlayRef.current) overlayRef.current.style.clipPath = 'circle(0px at 50% 50%)'; ringRef.current?.classList.remove('visible'); reticleRef.current?.classList.remove('visible'); dragActive.current = false; }, []); React.useEffect(() => { if (!hasTouch) return; videoRefs.current.forEach((v, i) => { if (!v) return; if (tapCard === i) v.play().catch(() => {}); else v.pause(); }); }, [hasTouch, tapCard]); return (
); } function Hero({ layout }) { if (layout === 'bleed') return ; if (layout === 'centered') return ; if (layout === 'split') return ; return ; } // Statement — purpose manifesto, includes ticker pinned to bottom function Statement() { const items = [...MARQUEE_ITEMS, ...MARQUEE_ITEMS, ...MARQUEE_ITEMS]; return (
(The intent)

{(window.CMS_DATA && window.CMS_DATA.statement && window.CMS_DATA.statement.title) || 'Supporting brands with high-end visuals and creative edits that feel authentic on social.'}

Services
{(window.CMS_DATA && window.CMS_DATA.statement && window.CMS_DATA.statement.services) || 'UGC / Events'}
Known for
{(window.CMS_DATA && window.CMS_DATA.statement && window.CMS_DATA.statement.knownFor) || 'Creative Edits'}
Content shot on
{(window.CMS_DATA && window.CMS_DATA.statement && window.CMS_DATA.statement.shotOn) || 'iPhone 16 Pro'}
{/* Ticker pinned to bottom of this viewport */}
{items.map((t, i) => {t})}
); } Object.assign(window, { Nav, SubNav, Hero, Statement });