// 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 (
{SUB_NAV_SECTIONS.map((s, i) => {
const state = i === activeIdx ? 'active' : i < activeIdx ? 'past' : 'future';
return (
{i < SUB_NAV_SECTIONS.length - 1 && (
)}
);
})}
);
}
// 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 (
<>
{/* LL monogram → expands to Lamia Lens on hover */}
LL
Lamia Lens
{/* MVP: plain link, no dropdown */}
Services
About
My Work
{/* The Method hidden for MVP launch — will return as standalone CREATE page */}
{showControls && (
)}
Enquire
setMenuOpen(o => !o)}
aria-label="Toggle menu">
{/* Mobile drawer — MVP: no dropdown sub-links, no The Method */}
>
);
}
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.
);
}
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.
);
}
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.
);
}
// ── 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 (
{/* Heading — children animate in individually */}
{/* "Lens" is wrapped so we can measure its position for the idle lens */}
Lamia Lens
{(window.CMS_DATA && window.CMS_DATA.hero && window.CMS_DATA.hero.tagline) || 'High-end, organic content for brands'}
{/* Poster card stack — stacked then fanned */}
{/* Large italic service names scrolling behind the cards */}
{['UGC', 'Event Coverage', 'Content Creation', 'Editing Coaching', 'Video Editing', 'Creative Direction',
'UGC', 'Event Coverage', 'Content Creation', 'Editing Coaching', 'Video Editing', 'Creative Direction']
.map((t, i) => {t} )}
{VF_CARDS.map((card, i) => (
cardRefs.current[i] = el}
className={`vf-card${cardIn[i] ? ' vf-card-in' : ''}${tapCard === i ? ' vf-tapped' : ''}`}
data-i={i}
onClick={() => hasTouch && setTapCard(t => t === i ? -1 : i)}
>
{card.label}
{card.focal} {card.aperture}
))}
{/* Video overlay — mirrors copy + card layout for correct clip-path centring */}
{/* Spacer mirrors vf-copy height (opacity:0 via vf-spacer) */}
Lamia Lens
A creative studio for modern brands and creators
{/* Mirror the card stack — fanned class applied in sync with real cards */}
{VF_CARDS.map((card, i) => (
videoRefs.current[i] = el}
className="vf-overlay-video"
src={card.video}
poster={card.poster}
muted loop playsInline preload="metadata"
autoPlay
/>
))}
{/* Barrel ring + reticle + readout */}
{!rmReduced && (
<>
REC
·
>
)}
);
}
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 });