/* global React, ReactDOM, TweaksPanel, useTweaks, TweakSection, TweakRadio, TweakColor */ const { useRef, useEffect, useMemo, useState } = React; // --- Data -------------------------------------------------------------------- const ROLES = [ { title: "Creative Director", url: "https://forms.fillout.com/t/6EmWVwzNf7us" }, { title: "Editor", url: "https://forms.fillout.com/t/tAK6mUPqEmus" }, { title: "Comedy Writer", url: "https://forms.fillout.com/t/51WDLRKiSrus" }, { title: "Producer", url: "https://forms.fillout.com/t/ceyPxYsdAQus" }, { title: "Videographer", url: "https://forms.fillout.com/t/2pBkNy8s8ius" }]; const PHOTO_POOL = [ "assets/photo2.jpg", "assets/photo3.jpg", "assets/photo4.jpg", "assets/photo6.jpg", "assets/photo7.jpg", "assets/photo8.jpg", "assets/photo9.jpg", "assets/photo10.jpg", "assets/photo11.jpg", "assets/photo12.jpg", "assets/photo13.jpg", "assets/photo14.jpg", "assets/photo16.jpg", "assets/photo17.jpg", "assets/photo18.jpg", "assets/photo19.jpg", "assets/photo20.jpg", "assets/photo21.jpg", "assets/photo22.jpg", "assets/photo23.jpg", "assets/photo24.jpg", "assets/photo25.jpg", "assets/photo26.jpg", "assets/photo27.jpg", "assets/photo28.jpg", "assets/photo29.jpg", "assets/photo30.jpg", "assets/photo31.jpg", "assets/photo32.jpg", "assets/photo33.jpg", "assets/photo34.jpg", "assets/photo35.jpg", "assets/photo36.jpg", "assets/photo37.jpg", "assets/photo38.jpg", "assets/photo39.jpg", "assets/photo40.jpg", "assets/photo41.jpg", "assets/photo42.jpg", "assets/photo43.jpg", "assets/photo44.jpg", "assets/photo45.jpg", "assets/photo46.jpg", "assets/photo47.jpg", "assets/photo48.jpg", "assets/photo49.jpg", "assets/photo50.jpg", "assets/photo51.jpg", "assets/photo52.jpg", "assets/photo54.jpg", "assets/photo55.jpg"]; // Photos per row in the hero band (then doubled for seamless loop). const BAND_LEN = 22; // --- Photo pool validation ------------------------------------------------- // Probes every URL in PHOTO_POOL with an Image() and keeps only the ones // that actually load. Anything missing/broken is silently dropped so it // never renders as a gray placeholder. Returns null until verification is // done so callers can render an empty backdrop instead of broken cards. function useVerifiedPhotos() { const [verified, setVerified] = React.useState(null); React.useEffect(() => { let cancelled = false; const results = PHOTO_POOL.map((src) => new Promise((resolve) => { const img = new Image(); img.onload = () => resolve(src); img.onerror = () => resolve(null); img.src = src; }) ); Promise.all(results).then((arr) => { if (cancelled) return; setVerified(arr.filter(Boolean)); }); return () => { cancelled = true; }; }, []); return verified; } // Shuffle that avoids back-to-back duplicates. When the pool is at least // as big as `total`, we sample WITHOUT repeats (Fisher–Yates on a copy of // the pool, then slice); otherwise we cycle pool[i % len] like before so // shorter pools still fill the requested length. function shuffleNoAdjacent(pool, total) { for (let attempt = 0; attempt < 20; attempt++) { let arr; if (pool.length >= total) { const shuffled = pool.slice(); for (let i = shuffled.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; } arr = shuffled.slice(0, total); } else { arr = []; for (let i = 0; i < total; i++) arr.push(pool[i % pool.length]); 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]]; } } let ok = true; for (let i = 1; i < arr.length; i++) { if (arr[i] !== arr[i - 1]) continue; let swapped = false; for (let k = i + 1; k < arr.length; k++) { const prev = arr[i - 1],next = arr[i + 1]; const kPrev = arr[k - 1],kNext = arr[k + 1]; if (arr[k] !== prev && arr[k] !== next && arr[i] !== kPrev && arr[i] !== kNext) { [arr[i], arr[k]] = [arr[k], arr[i]]; swapped = true; break; } } if (!swapped) {ok = false;break;} } if (ok) return arr; } const fallback = []; for (let i = 0; i < total; i++) fallback.push(pool[i % pool.length]); return fallback; } // --- Polaroid (standalone, used in offset photos + footer) ------------------ function Polaroid({ src, caption, rotate = 0, w = 200, className = "", style = {}, ...rest }) { return (
{caption &&
{caption}
}
); } // --- Photo Band (full-bleed horizontal scrollers) --------------------------- function BandRow({ photos, variant }) { // Double so the keyframe -50% translate loops seamlessly. const doubled = [...photos, ...photos]; return (
{doubled.map((src, i) => { // Pseudo-random tilt in -3..+3 deg, deterministic per index. const r = i * 41 % 7 - 3; return (
); })}
); } // Bottom band — sits directly below the hero, the single layer of // polaroids scrolling by before the page breaks into the next sections. function PhotoBandBottom() { const row = useMemo(() => shuffleNoAdjacent(PHOTO_POOL, BAND_LEN), []); return ( ); } // --- Photo Table (top-down pile of polaroids dropping like snow) ---------- // Looking straight down at a table so covered in face-up polaroids the // table is NEVER visible. On load a full mosaic drops in; then every ~1.2s // one new UNIQUE polaroid is dropped onto a systematically-chosen cell, // covering whatever was there. The covered (now-invisible) tile underneath // is removed a beat later — so the pile constantly refreshes, never grows // taller, never shows blank space, and never shows a duplicate. // Columns by width; ROWS derived from the viewport aspect so the (portrait) // tiles tile evenly instead of stacking deep vertically — this is what keeps // mobile from burying photos under each other. function gridDims() { const w = window.innerWidth, h = window.innerHeight || 800; const cols = w < 560 ? 3 : w < 900 ? 4 : w < 1300 ? 6 : 7; let rows = Math.round(0.78 * cols * (h / w)); rows = Math.max(3, Math.min(8, rows)); return { cols, rows }; } // Position + tilt for a tile centered in its grid cell (small jitter only, // so neighbours always overlap and no gap can open up). function tilePose(cellId, cols, rows) { const c = cellId % cols, r = Math.floor(cellId / cols); const cellW = 100 / cols, cellH = 100 / rows; const x = (c + 0.5) * cellW + (Math.random() - 0.5) * cellW * 0.1; const y = (r + 0.5) * cellH + (Math.random() - 0.5) * cellH * 0.1; const rot = (Math.random() * 2 - 1) * 9; return { x, y, rot }; } // Pose for a refreshed cell: near the cell centre (so it always covers the // cell — no gaps) but with a clearly different tilt + a small shift from the // photo it replaces, so it never reads as the exact same placement. function tilePoseFresh(cellId, cols, rows, prev) { const c = cellId % cols, r = Math.floor(cellId / cols); const cellW = 100 / cols, cellH = 100 / rows; const baseX = (c + 0.5) * cellW, baseY = (r + 0.5) * cellH; let rot = (Math.random() * 2 - 1) * 9; if (prev) { let tries = 0; while (Math.abs(rot - prev.rot) < 8 && tries++ < 10) rot = (Math.random() * 2 - 1) * 9; if (Math.abs(rot - prev.rot) < 8) rot = prev.rot + (prev.rot >= 0 ? -10 : 10); } const x = baseX + (Math.random() - 0.5) * cellW * 0.14; const y = baseY + (Math.random() - 0.5) * cellH * 0.14; return { x, y, rot }; } // Measure the hero headline (and logo) so new polaroids can avoid landing // on top of the big white text. Returns a normalized rect or null. function measureAvoid() { const el = document.querySelector(".hero-title"); if (!el) return null; const r = el.getBoundingClientRect(); const W = window.innerWidth, H = window.innerHeight; if (!r.width || !r.height) return null; // Pad a touch so drops keep clear of the glyph edges too. const padX = W * 0.035, padY = H * 0.02; return { x0: (r.left - padX) / W, x1: (r.right + padX) / W, y0: (r.top - padY) / H, y1: (r.bottom + padY) / H }; } // Which cells sit under the hero headline — these are kept out of the // refresh rotation (their initial photo just stays quietly behind the text). function computeAvoidSet(cols, rows, avoid) { const s = new Set(); if (!avoid) return s; for (let id = 0; id < cols * rows; id++) { const nx = (id % cols + 0.5) / cols; const ny = (Math.floor(id / cols) + 0.5) / rows; if (nx >= avoid.x0 && nx <= avoid.x1 && ny >= avoid.y0 && ny <= avoid.y1) s.add(id); } return s; } // One self-contained tile. It stays INVISIBLE until its image has actually // loaded, then runs the drop-in animation — so a half-loaded photo can never // flash its (black) box. `leaving` fades it out gracefully on replacement. function Tile({ t }) { const [loaded, setLoaded] = useState(false); const onReady = () => setLoaded(true); return (
); } function PhotoTable() { const verifiedPool = useVerifiedPhotos(); const [tiles, setTiles] = useState([]); const [inView, setInView] = useState(true); const tableRef = useRef(null); const keyRef = useRef(1); const zRef = useRef(1); const dimsRef = useRef(gridDims()); const avoidRef = useRef(new Set()); const timersRef = useRef([]); // Initial full mosaic (and rebuild on resize). useEffect(() => { if (!verifiedPool || verifiedPool.length === 0) return; const build = () => { const { cols, rows } = gridDims(); dimsRef.current = { cols, rows }; const n = cols * rows; avoidRef.current = computeAvoidSet(cols, rows, measureAvoid()); // shuffled photo deck (unique per cell) for the starting mosaic const deck = verifiedPool.slice(); for (let i = deck.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [deck[i], deck[j]] = [deck[j], deck[i]]; } // Reveal order = organized chaos. Start with a fully-visible edge photo, // then WEAVE the centre (under-text) cells in gradually among the outer // ones — so the centre never floods first and no obvious pattern shows. const avoid = avoidRef.current; const outer = [], center = []; for (let cid = 0; cid < n; cid++) (avoid.has(cid) ? center : outer).push(cid); const shuf = (a) => { for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; } }; shuf(outer); shuf(center); // First photo: a left/right edge cell so it's clearly visible, not buried. const edgeIdx = outer.findIndex((cid) => { const c = cid % cols; return c === 0 || c === cols - 1; }); if (edgeIdx > 0) { const [e] = outer.splice(edgeIdx, 1); outer.unshift(e); } // Weave centre cells into the outer run, evenly spaced, never at the start. const orderArr = []; const gap = center.length ? outer.length / (center.length + 1) : Infinity; let ci = 0, nextAt = Math.max(2, Math.round(gap)); for (let i = 0; i < outer.length; i++) { orderArr.push(outer[i]); if (ci < center.length && i + 1 >= nextAt) { orderArr.push(center[ci++]); nextAt += gap; } } while (ci < center.length) orderArr.push(center[ci++]); const rank = {}; orderArr.forEach((cid, k) => { rank[cid] = k; }); const step = Math.max(28, Math.min(60, Math.round(1500 / n))); const next = []; for (let cellId = 0; cellId < n; cellId++) { next.push({ key: keyRef.current++, cell: cellId, photo: deck[cellId % deck.length], // z follows spiral order so each photo lands on TOP of the prior. z: rank[cellId] + 1, delay: rank[cellId] * step, ...tilePose(cellId, cols, rows) }); } zRef.current = n + 1; setTiles(next); }; build(); let to; // Only rebuild when the WIDTH changes. Mobile browsers fire `resize` // on every scroll as the URL bar shows/hides (height changes), which // was re-dumping the whole pile mid-scroll. Ignoring height keeps the // initial reveal a one-time event. let lastW = window.innerWidth; const onResize = () => { if (window.innerWidth === lastW) return; lastW = window.innerWidth; clearTimeout(to); to = setTimeout(build, 250); }; window.addEventListener("resize", onResize); return () => { window.removeEventListener("resize", onResize); clearTimeout(to); timersRef.current.forEach(clearTimeout); timersRef.current = []; }; }, [verifiedPool]); // Pause the whole pile (the 2.5s drop cadence + its timers/animations) // whenever page 1 is scrolled out of view, so it costs nothing while the // user reads the rest of the page. Resumes when it scrolls back in. useEffect(() => { const el = tableRef.current; if (!el || typeof IntersectionObserver === "undefined") return; const obs = new IntersectionObserver( ([entry]) => setInView(entry.isIntersecting), { rootMargin: "120px" }); obs.observe(el); return () => obs.disconnect(); }, []); // Every 2.5s refresh one cell: a fresh photo drops in (covering the cell) // and the cell's existing photo CROSS-FADES out. One stable photo per cell // means neighbours overlap only lightly (never a stable >60% double), and // a fade — not a pop — hides the outgoing one. const ready = !!verifiedPool && tiles.length > 0; useEffect(() => { if (!ready || !inView) return; const id = setInterval(() => { const { cols, rows } = dimsRef.current; const n = cols * rows; setTiles((prev) => { // Current (non-leaving) top photo + its z per cell. z = recency, so // a low z means that spot has been sitting untouched the longest. const topByCell = {}; for (const t of prev) { if (t.leaving) continue; if (!topByCell[t.cell] || t.z > topByCell[t.cell].z) topByCell[t.cell] = t; } // Candidates = every cell except the ones under the headline. Sort by // staleness (oldest visible photo first) and pick from the stalest // ~30% — so we always refresh a long-untouched spot, never re-cover a // freshly-placed one, and cycle through the whole pile before // repeating. The random pick within the stale pool keeps it organic. let cand = []; for (let cid = 0; cid < n; cid++) if (!avoidRef.current.has(cid)) cand.push(cid); if (!cand.length) cand = Array.from({ length: n }, (_, i) => i); cand.sort((a, b) => (topByCell[a] ? topByCell[a].z : -1) - (topByCell[b] ? topByCell[b].z : -1)); const poolSize = Math.min(cand.length, Math.max(3, Math.round(cand.length * 0.3))); const cellId = cand[Math.floor(Math.random() * poolSize)]; const visible = new Set(Object.values(topByCell).map((t) => t.photo)); const avail = verifiedPool.filter((p) => !visible.has(p)); const photo = (avail.length ? avail : verifiedPool)[ Math.floor(Math.random() * (avail.length ? avail.length : verifiedPool.length))]; const tile = { key: keyRef.current++, cell: cellId, photo, z: zRef.current++, delay: 0, ...tilePoseFresh(cellId, cols, rows, topByCell[cellId]) }; // Cross-fade out the photo that was in this (stalest) cell. const leavingKeys = prev.filter((t) => t.cell === cellId && !t.leaving).map((t) => t.key); const arr = prev.map((t) => leavingKeys.includes(t.key) ? { ...t, leaving: true } : t); arr.push(tile); if (leavingKeys.length) { const tm = setTimeout(() => { setTiles((cur) => cur.filter((t) => !leavingKeys.includes(t.key))); timersRef.current = timersRef.current.filter((x) => x !== tm); }, 700); timersRef.current.push(tm); } return arr; }); }, 2500); return () => clearInterval(id); }, [ready, inView]); const factor = (100 / dimsRef.current.cols * 1.3).toFixed(2); return ( ); } // --- Sections --------------------------------------------------------------- function Hero({ scrollToRoles }) { return (

Join the internet's
#1 comedy brand

); } function WhoAreWe() { return (

Who are we looking for?

); } // --- CountUp (animated number that ticks from 0 → value on scroll in) ---- function CountUp({ value, duration = 2200, format }) { const [count, setCount] = useState(0); const [started, setStarted] = useState(false); const ref = useRef(null); // Kick off the animation the first time the element scrolls into view. useEffect(() => { const el = ref.current; if (!el || started) return; const check = () => { const r = el.getBoundingClientRect(); if (r.top < window.innerHeight * 0.9 && r.bottom > 0) { setStarted(true); } }; check(); window.addEventListener("scroll", check, { passive: true }); window.addEventListener("resize", check, { passive: true }); return () => { window.removeEventListener("scroll", check); window.removeEventListener("resize", check); }; }, [started]); // Tween from 0 → value over `duration` ms, ease-out cubic. useEffect(() => { if (!started) return; const startT = performance.now(); let raf; const tick = (t) => { const elapsed = t - startT; const p = Math.min(elapsed / duration, 1); const eased = 1 - Math.pow(1 - p, 3); setCount(value * eased); if (p < 1) raf = requestAnimationFrame(tick); }; raf = requestAnimationFrame(tick); return () => cancelAnimationFrame(raf); }, [started, value, duration]); const display = format ? format(count) : Math.floor(count).toLocaleString(); return {display}; } // --- About Us (stats + paragraph + Apply Now, with offset polaroids) ---- function AboutUs({ scrollToRoles }) { return (
{/* Upper-left polaroid — tilted, group/behind-the-scenes shot. */}

About Us

v.toFixed(1) + "M"} />
Subscribers
Math.round(v) + "M+"} />
Views

Family Friendly was created with the unwavering mission of becoming the biggest comedy brand on the planet.

To accomplish this, we've centered our lives around one trait: Obsession. Obsessed with perfecting every detail. Obsessed with making people laugh. Obsessed with being the best.

If this sounds like you, hit apply now and let's talk.

); } function RoleRow({ role }) { return ( {role.title} Apply Now ); } function Roles({ rolesRef }) { return (

Roles

Apply now.

{ROLES.map((r) => )}
); } function Footer() { // The footer polaroid swings open to reveal a hidden CTA. Interaction // model differs by device: // • Desktop (hover-capable): hover triggers the swing, leaving the // element returns it. NO click toggling. // • Touch (mobile): click toggles revealed state — first tap opens, // second tap closes. const [revealed, setRevealed] = useState(false); const [isTouch, setIsTouch] = useState(false); useEffect(() => { // (hover: none) OR (pointer: coarse) → treat as touch. const mq = window.matchMedia("(hover: none), (pointer: coarse)"); const update = () => setIsTouch(mq.matches); update(); mq.addEventListener?.("change", update); return () => mq.removeEventListener?.("change", update); }, []); // On desktop, CSS handles the swing via .foot-polaroid-wrap:hover. The // `revealed` state only ever toggles for touch, so the JS class stays // off on desktop and doesn't fight the hover transition. const wrapClass = "foot-polaroid-wrap" + ( isTouch && revealed ? " foot-polaroid-wrap--revealed" : ""); const polaroidClass = "foot-polaroid" + ( isTouch && revealed ? " foot-polaroid--revealed" : ""); return ( ); } // --- Tweaks ----------------------------------------------------------------- const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{ "accent": "#ff6a1a" } /*EDITMODE-END*/; function App() { const [t, setTweak] = useTweaks(TWEAK_DEFAULTS); const rolesRef = useRef(null); useEffect(() => { document.documentElement.style.setProperty("--c-accent", t.accent); }, [t]); const scrollToRoles = () => { const title = document.querySelector(".roles-title"); if (title) { const top = title.getBoundingClientRect().top + window.scrollY - 32; window.scrollTo({ top, behavior: "smooth" }); } else if (rolesRef.current) { window.scrollTo({ top: rolesRef.current.offsetTop - 24, behavior: "smooth" }); } }; return (
{/* Smooth transition between page 1 (black hero) and page 2 (orange body) — short gradient strip that fades black → dark orange → page orange so the two pages blend. */}