/* Hero Dashboard Graphic - standalone embed ------------------------------------------------------------------ A 1920x1080, 25s seamless-loop animated CRM dashboard. Transparent background: nothing is painted behind the window, so it sits on any hero section. No player chrome, no timeline, no controls. USE - plain HTML page:
USE - Design Component: The graphic locks a 16:9 box, fills the width of its container and scales the artwork to fit. Props (all optional): cursor {boolean} show the pointer default true float {boolean} slow parallax drift default true zoom {number} artwork scale, percent default 100 autoplay{boolean} default true staticT {number} frame held when the user prefers reduced motion, seconds default 5.2 loop {boolean} default true Fonts: Inter, Inter Tight, JetBrains Mono. Load them once on the host page (this file does not inject a stylesheet): https://fonts.googleapis.com/css2?family=Inter:wght@300..700&family=Inter+Tight:wght@400..700&family=JetBrains+Mono:wght@400..600&display=swap ------------------------------------------------------------------ */ /* ── tiny motion runtime (no engine dependency) ───────────────────────── */ const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v); const Easing = { linear: (t) => t, easeOutCubic: (t) => 1 - Math.pow(1 - t, 3), easeInOutCubic: (t) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2), easeInOutQuart: (t) => (t < 0.5 ? 8 * t * t * t * t : 1 - Math.pow(-2 * t + 2, 4) / 2), easeOutBack: (t) => { const c1 = 1.70158, c3 = c1 + 1; return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2); }, }; /* piecewise map: input breakpoints -> output values, eased within each span */ function interpolate(input, output, ease) { const e = ease || Easing.linear; return (t) => { if (t <= input[0]) return output[0]; const n = input.length - 1; if (t >= input[n]) return output[n]; for (let i = 0; i < n; i++) { if (t >= input[i] && t <= input[i + 1]) { const span = input[i + 1] - input[i]; const p = span <= 0 ? 1 : (t - input[i]) / span; return output[i] + (output[i + 1] - output[i]) * e(p); } } return output[n]; }; } /* one tween as a function of the authored clock */ function animate(o) { const e = o.ease || Easing.linear; const span = o.end - o.start; return (t) => { if (t <= o.start) return o.from; if (t >= o.end) return o.to; return o.from + (o.to - o.from) * e(span <= 0 ? 1 : (t - o.start) / span); }; } /* ── the scene list: names + authored durations, in order ─────────────── */ const SCENES = [ { name: 'Wake', dur: 3.2 }, { name: 'Raise', dur: 4.2 }, { name: 'Trend', dur: 3.6 }, { name: 'Pipeline', dur: 3.6 }, { name: 'Deals', dur: 3.4 }, { name: 'Feed', dur: 3.4 }, { name: 'Close', dur: 1.6 }, { name: 'Refresh', dur: 2.0 }, ]; const CUES = (() => { const c = {}; let at = 0; for (const s of SCENES) { c[s.name] = at; at += s.dur; } return c; })(); const TOTAL = SCENES.reduce((n, s) => n + s.dur, 0); const CompositionCtx = React.createContext({ T: 0, CUES, authoredTotal: TOTAL }); const useComposition = () => React.useContext(CompositionCtx); const C = { deep: '#14352A', accent: '#CBE81C', tint: '#F7FFD2', deepest: '#6E8511', ink: '#1C1D1F', ink2: '#232529', ink3: '#505967', ink4: '#6F7988', cap: '#A4ADBA', w1: '#FFFFFF', w2: '#FAFAFB', w3: '#F3F4F6', w4: '#EDEFF3', w5: '#E4E7EC', w7: '#D3D8DF', w8: '#CAD0D9', blue: '#266DF0', green: '#0FC27B', amber: '#F5B900', }; const F = "'Inter', system-ui, sans-serif"; const FT = "'Inter Tight', 'Inter', system-ui, sans-serif"; const FM = "'JetBrains Mono', ui-monospace, monospace"; /* ── three motion helpers, nothing else eases ─────────────────────────── */ const MOTION = { enter: (o) => animate(Object.assign({ ease: Easing.easeOutCubic }, o)), draw: (o) => animate(Object.assign({ ease: Easing.easeInOutQuart }, o)), pop: (o) => animate(Object.assign({ ease: Easing.easeOutBack }, o)), }; const CANVAS = { w: 1920, h: 1080 }; const DASH = { w: 1370, h: 760 }; const DS = 1.28, DX = 84, DY = 54; // hero dashboard: scale + visual origin const BASE = { x: DX, y: DY }; // reference for the refresh-sweep mask const HEADER_H = 66; /* refresh sweep: front position in stack space, then a 0..1 "data shown" mask */ const sweepX = (T, CUES) => MOTION.draw({ from: -340, to: 2280, start: CUES.Refresh + 1.0, end: CUES.Refresh + 1.85 })(T); const clearAt = (T, CUES, cx) => clamp((cx - sweepX(T, CUES)) / 160 + 0.5, 0, 1); function useLife(idx, cx) { const { T, CUES } = useComposition(); const wake = MOTION.enter({ from: 0, to: 1, start: 0.3 + idx * 0.14, end: 1.25 + idx * 0.14 })(T); return Math.min(wake, clearAt(T, CUES, cx)); } /* ── icons ───────────────────────────────────────────────────────────── */ const ICON = { grid: 'M4 4h6.5v6.5H4zM13.5 4H20v6.5h-6.5zM4 13.5h6.5V20H4zM13.5 13.5H20V20h-6.5z', bars: 'M4 6h5v13H4zM10.5 9.5h5V19h-5zM17 13h4v6h-4z', chat: 'M4.5 6.5a2 2 0 012-2h11a2 2 0 012 2v7a2 2 0 01-2 2H10l-5.5 4z', bank: 'M3.5 9.5L12 4.5l8.5 5M6 10.5v7.5M12 10.5v7.5M18 10.5v7.5M3.5 19.5h17', doc: 'M6.5 3.5h7L18 8v12H6.5zM13 3.5V8h4.5M9.5 14l2.2 2.2L16 11.9', people: 'M9.4 11.2a3.3 3.3 0 100-6.6 3.3 3.3 0 000 6.6zM3.6 19.6c0-3 2.6-5.2 5.8-5.2s5.8 2.2 5.8 5.2M16.4 6.4a3 3 0 010 5.6M18 14.9c1.9.7 3.2 2.4 3.2 4.7', search: 'M11 4.5a6.5 6.5 0 100 13 6.5 6.5 0 000-13zM15.9 15.9L20 20', refresh: 'M20 12a8 8 0 11-2.4-5.7M20.2 4.4V10H15', plus: 'M12 5.5v13M5.5 12h13', chev: 'M8.5 10.5l3.5 3.5 3.5-3.5', filter: 'M4.5 6h15l-6.2 7.3v6.2l-2.6-1.7v-4.5z', check: 'M5.5 12.6l4.4 4.4 8.6-9', cal: 'M5 6.5h14v13H5zM5 10.6h14M9 4.4v4M15 4.4v4', trend: 'M4 16.5l4.6-5.2 3.4 3 3.6-6.3 4.4 5', dots: 'M6 12h.01M12 12h.01M18 12h.01', sort: 'M7 5.5v13M4 15.5l3 3 3-3M17 18.5v-13M14 8.5l3-3 3 3', }; function Ico({ d, size = 19, color = C.ink3, sw = 1.7, style }) { return ( ); } function Mark({ size = 30, r = 9 }) { return (
A
); } function Avatar({ initials, size = 28, bg = C.w4, fg = C.ink3 }) { return (
{initials}
); } function Chip({ label, bg = C.w3, fg = C.ink3, dot, style }) { return (
{dot ? : null} {label}
); } const money = (v, cur = '£') => cur + v.toFixed(1) + 'M'; const commas = (n) => Math.round(n).toLocaleString('en-GB'); /* ── window shell ────────────────────────────────────────────────────── */ function Shell({ x, y, w, h, depth, children, ring = 0 }) { const veil = depth * 0.055; const shadow = depth === 0 ? '0 26px 56px -20px rgba(20,53,42,0.22), 0 4px 12px -4px rgba(20,53,42,0.10)' : `0 ${16 + depth * 6}px ${44 + depth * 14}px -${10 + depth * 4}px rgba(20,53,42,${(0.12 - depth * 0.014).toFixed(3)})`; return (
{children} {veil > 0 ?
: null} {ring > 0 ? (
) : null}
); } /* ── dashboard widgets ───────────────────────────────────────────────── */ function Card({ x, y, w, h, title, right, children, life, lift = 0 }) { return (
{title}
{right}
{children} {life < 0.995 ? (
{[0.55, 0.85, 0.4, 0.7].map((f, i) => (
))}
) : null}
); } function WCapital({ x, y, w, h }) { const { T, CUES } = useComposition(); const life = useLife(0, BASE.x + 670.7); const p = MOTION.draw({ from: 0, to: 1, start: CUES.Raise + 0.15, end: CUES.Raise + 2.1 })(T); const raised = 612 * p; const pct = 68 * p; const stats = [['LP commitments', '84'], ['Average ticket', '£7.3M'], ['Final close', 'Q1 2027']]; return ( }>
£{raised.toFixed(0)}M
of £900M target
{pct.toFixed(0)}%
Hard cap £1.05B
{stats.map(([k, v], i) => { const o = MOTION.pop({ from: 0, to: 1, start: CUES.Raise + 1.0 + i * 0.16, end: CUES.Raise + 1.7 + i * 0.16 })(T); return (
{k}
{v}
); })}
); } const TREND = [18, 26, 22, 34, 41, 38, 52, 47, 61, 58, 69, 74]; const TREND_L = ['S', 'O', 'N', 'D', 'J', 'F', 'M', 'A', 'M', 'J', 'J', 'A']; function WTrend({ x, y, w, h }) { const { T, CUES } = useComposition(); const life = useLife(1, BASE.x + 1377.3); const p = MOTION.draw({ from: 0, to: 1, start: CUES.Trend + 0.2, end: CUES.Trend + 2.5 })(T); const PW = w - 36, PH = 96, PX = 18, PY = 34; const max = 82; const pts = TREND.map((v, i) => [PX + (i / (TREND.length - 1)) * PW, PY + PH - (v / max) * PH]); const n = pts.length - 1; const cut = p * n; const kept = pts.slice(0, Math.floor(cut) + 1); if (cut > 0 && cut < n) { const i = Math.floor(cut), f = cut - i; kept.push([pts[i][0] + (pts[i + 1][0] - pts[i][0]) * f, pts[i][1] + (pts[i + 1][1] - pts[i][1]) * f]); } const line = kept.map((q, i) => (i ? 'L' : 'M') + q[0].toFixed(1) + ' ' + q[1].toFixed(1)).join(' '); const last = kept[kept.length - 1]; const area = kept.length > 1 ? line + ` L${last[0].toFixed(1)} ${PY + PH} L${PX} ${PY + PH} Z` : ''; const tip = MOTION.pop({ from: 0, to: 1, start: CUES.Trend + 2.4, end: CUES.Trend + 3.0 })(T); const tipO = clamp(tip, 0, 1) * clamp((CUES.Pipeline + 1.2 - T) / 0.6, 0, 1); return (
}>
{[0, 0.5, 1].map((g, i) => ( ))} {area ? : null} {line ? : null} {pts.map((q, i) => { const on = clamp((cut - i) * 3, 0, 1); if (on <= 0.01) return null; return ; })} {last && p > 0.02 ? : null}
{TREND_L.map((l, i) => (
0.5 ? C.ink4 : C.cap, width: 12, textAlign: 'center' }}>{l}
))}
{tipO > 0.01 ? (
August
£74.0M
) : null}
); } const STAGES = [['Sourced', 42, '£410M'], ['Screening', 28, '£286M'], ['Diligence', 16, '£171M'], ['IC review', 7, '£92M'], ['Closed', 4, '£57M']]; function WPipeline({ x, y, w, h }) { const { T, CUES } = useComposition(); const life = useLife(2, BASE.x + 556.8); const hi = Math.round(interpolate([CUES.Pipeline + 0.75, CUES.Pipeline + 1.9, CUES.Pipeline + 2.85], [1, 2, 3], Easing.easeInOutCubic)(T)); const hiOn = clamp((T - (CUES.Pipeline + 0.55)) * 2, 0, 1) * clamp((CUES.Deals + 0.4 - T) / 0.5, 0, 1); return ( }>
{STAGES.map(([k, v, val], i) => { const g = MOTION.draw({ from: 0, to: 1, start: 1.1 + i * 0.13, end: 2.3 + i * 0.13 })(T); const on = i === hi ? hiOn : 0; return (
0.4 ? C.deep : C.ink3, letterSpacing: '-0.005em' }}>{k}
0.4 ? C.accent : `rgba(20,53,42,${(0.14 + (1 - i / 5) * 0.5).toFixed(2)})`, borderRadius: 5, }} />
{Math.round(v * g)}
{val}
); })}
); } const MIX = [['Pension funds', 38, C.deep], ['Family offices', 24, C.deepest], ['Endowments', 21, '#9FB53F'], ['Insurance', 17, C.accent]]; function WMix({ x, y, w, h }) { const { T, CUES } = useComposition(); const life = useLife(3, BASE.x + 1027.8); const p = clamp(MOTION.draw({ from: 0, to: 1, start: CUES.Raise + 1.1, end: CUES.Raise + 2.8 })(T), 0, 1); const R = 46, SW = 15, CIRC = 2 * Math.PI * R; let acc = 0; return ( }>
{MIX.map(([k, v, col], i) => { const len = (v / 100) * CIRC * p; const off = (acc / 100) * CIRC * p; acc += v; return ( ); })}
{Math.round(84 * p)}
investors
{MIX.map(([k, v, col], i) => { const o = clamp(MOTION.enter({ from: 0, to: 1, start: CUES.Raise + 1.3 + i * 0.14, end: CUES.Raise + 1.9 + i * 0.14 })(T), 0, 1); return (
{k} {v}%
); })}
); } const MILES = [['Fund IV second close', '12 Oct', '£190M expected'], ['LP advisory board', '24 Oct', 'Agenda circulated'], ['Q3 valuations due', '31 Oct', '4 portfolio cos']]; function WMilestones({ x, y, w, h }) { const { T, CUES } = useComposition(); const life = useLife(4, BASE.x + 1498.9); const done = clamp(MOTION.pop({ from: 0, to: 1, start: CUES.Close + 0.25, end: CUES.Close + 1.0 })(T), 0, 1); return ( }>
{MILES.map(([k, d, sub], i) => { const o = clamp(MOTION.enter({ from: 0, to: 1, start: 1.5 + i * 0.14, end: 2.2 + i * 0.14 })(T), 0, 1); const lit = i === 0 ? done : 0; return (
0.2 ? `rgba(247,255,210,${lit.toFixed(2)})` : C.w2, border: '1px solid ' + (lit > 0.2 ? `rgba(203,232,28,${(0.7 * lit).toFixed(2)})` : C.w4), opacity: o, transform: `translateY(${((1 - o) * 8).toFixed(2)}px)`, }}>
0.4 ? C.accent : C.w1, border: '1px solid ' + (lit > 0.4 ? C.accent : C.w7), }}> {lit > 0.4 ? : null}
{k}
{sub}
{d}
); })}
); } const FEED_NEW = [ ['MO', 'Maria Okonjo', 'Ardent Pension Trust', 'Call', 'Confirmed £75M for the second close', 'just now'], ['DL', 'David Lindqvist', 'Nordwall Family Office', 'Email', 'Side letter returned, no amendments', '6m ago'], ]; const FEED_OLD = [ ['PR', 'Priya Raman', 'Kingsmere Endowment', 'Meeting', 'IC pack walkthrough, follow-up booked', '41m ago'], ['TH', 'Tom Halvorsen', 'Thornbury Insurance', 'Call', 'Allocation increased to £60M', '1h ago'], ['SO', 'Sofia Oduya', 'Meridian Advisors', 'Email', 'Introduction to Fund IV', '2h ago'], ]; const TYPE_COL = { Call: [C.tint, C.deepest], Email: ['#E8F0FF', C.blue], Meeting: [C.w3, C.ink3] }; function FeedRow({ r, o }) { const [av, name, acct, type, note, when] = r; const tc = TYPE_COL[type] || [C.w3, C.ink3]; return (
{name}
{acct}
{note}
{when}
); } function WFeed({ x, y, w, h }) { const { T, CUES } = useComposition(); const life = useLife(5, BASE.x + 1027.8); const a1 = clamp(MOTION.enter({ from: 0, to: 1, start: CUES.Feed + 0.35, end: CUES.Feed + 1.15 })(T), 0, 1); const a2 = clamp(MOTION.enter({ from: 0, to: 1, start: CUES.Feed + 1.5, end: CUES.Feed + 2.3 })(T), 0, 1); /* a1 lands the older of the two new rows (David) at the clip top; a2 slides Maria in above him - so the list never exposes an ungated slot. */ const rows = [[FEED_NEW[0], a2], [FEED_NEW[1], a1], [FEED_OLD[0], 1], [FEED_OLD[1], 1], [FEED_OLD[2], 1]]; const shift = -((1 - a1) + (1 - a2)) * 44; return ( 0.5 ? '+' + Math.round(a1 + a2) + ' new' : 'Last 24 hours'} bg={a1 + a2 > 0.5 ? C.accent : C.w3} fg={a1 + a2 > 0.5 ? C.deep : C.ink3} />
}>
{rows.map(([r, o], i) => )}
); } /* ── dashboard window ────────────────────────────────────────────────── */ const NAV = [ ['Dashboard', ICON.grid], ['Deals', ICON.bars], ['Interactions', ICON.chat], ['Funds', ICON.bank], ['Commitments', ICON.doc], ['Investors', ICON.people], ]; function Dashboard({ x, y }) { const { T, CUES } = useComposition(); const spin = clamp((T - (CUES.Refresh + 0.9)) / 1.0, 0, 1); const btn = clamp(MOTION.pop({ from: 0, to: 1, start: CUES.Refresh + 0.85, end: CUES.Refresh + 1.15 })(T), 0, 1) * clamp((CUES.Refresh + 1.9 - T) / 0.4, 0, 1); const hover = T >= CUES.Refresh + 0.75 ? -1 : T < CUES.Raise ? -1 : T < CUES.Trend ? 4 : T < CUES.Pipeline ? 3 : T < CUES.Deals ? 1 : T < CUES.Feed ? 1 : T < CUES.Close ? 2 : 4; return ( {/* sidebar */}
zed Capital
Investor relations
Search
⌘K
Records
{NAV.map(([k, d], i) => { const on = i === 0; const hv = i === hover ? 1 : 0; return (
{k}
{on ?
: null}
); })}
Saved views
{['Fund IV - live pipeline', 'LPs to re-up'].map((k, i) => (
{k}
))}
Rachel Tan
{/* header */}
Fund IV - portfolio overview
Live · synced 2 min ago
{['Overview', 'Pipeline', 'Investors'].map((k, i) => (
{k}
))}
0.05 ? C.accent : C.deep, color: btn > 0.05 ? C.deep : C.w1, transform: `scale(${(1 - btn * 0.05).toFixed(3)})`, }}> 0.05 ? C.deep : C.accent} sw={2} style={{ transform: `rotate(${(spin * 360).toFixed(1)}deg)` }} /> Refresh
{/* body */}
); } /* ── cursor ──────────────────────────────────────────────────────────── */ const PATH = [ [0.0, 1745, 97], [1.4, 1745, 97], [2.4, 1638, 184], [3.0, 1638, 184], [3.9, 770, 302], [7.2, 770, 302], [8.1, 1784, 271], [10.5, 1784, 271], [11.7, 642, 579], [12.9, 642, 615], [13.85, 642, 653], [14.3, 642, 653], [15.4, 238, 286], [17.6, 238, 286], [18.7, 980, 732], [20.4, 980, 732], [21.2, 1583, 592], [21.9, 1583, 592], [22.6, 238, 435], [23.2, 238, 435], [23.8, 1745, 97], [25.0, 1745, 97], ]; const CLICKS = [15.62, 23.92]; function Cursor({ show }) { const { T } = useComposition(); const ts = PATH.map((p) => p[0]); const cx = interpolate(ts, PATH.map((p) => p[1]), Easing.easeInOutCubic)(T); const cy = interpolate(ts, PATH.map((p) => p[2]), Easing.easeInOutCubic)(T); if (!show) return null; return (
{CLICKS.map((c, i) => { const k = clamp((T - c) / 0.5, 0, 1); if (k <= 0 || k >= 1) return null; const r = 8 + k * 34; return (
); })}
); } function Piece({ tw }) { const { T, CUES, authoredTotal } = useComposition(); const ph = (T / (authoredTotal || 25)) * Math.PI * 2; const drift = tw.float ? 1 : 0; const gx = drift * 7 * Math.sin(ph); const gy = drift * 5 * Math.sin(ph + 1.2); const gs = 1 + drift * (0.004 + 0.004 * Math.cos(ph)); const sx = sweepX(T, CUES); const sweepOn = clamp((T - (CUES.Refresh + 0.95)) * 6, 0, 1) * clamp((CUES.Refresh + 2.0 - T) * 3, 0, 1); return (
{sweepOn > 0.01 ? (
) : null}
); } /* ── clock + responsive frame ─────────────────────────────────────────── */ function HeroDashboardGraphic(props) { const p = props || {}; const bool = (v, dflt) => (v === undefined || v === null || v === '' ? dflt : v !== false && v !== 'false'); const tw = { zoom: +p.zoom || 100, float: bool(p.float, true), cursor: bool(p.cursor, true), }; const loop = bool(p.loop, true); const autoplay = bool(p.autoplay, true); const reduce = typeof window !== 'undefined' && window.matchMedia ? window.matchMedia('(prefers-reduced-motion: reduce)').matches : false; const staticT = p.staticT === undefined ? 5.2 : Number(p.staticT); const [T, setT] = React.useState(reduce ? staticT : 0); const boxRef = React.useRef(null); const [scale, setScale] = React.useState(1); React.useEffect(() => { if (!autoplay || reduce) { setT(staticT); return; } let raf = 0, t0 = 0; const step = (now) => { if (!t0) t0 = now; const el = (now - t0) / 1000; setT(loop ? el % TOTAL : Math.min(el, TOTAL)); if (loop || el < TOTAL) raf = requestAnimationFrame(step); }; raf = requestAnimationFrame(step); return () => cancelAnimationFrame(raf); }, [autoplay, loop, reduce, staticT]); React.useEffect(() => { const el = boxRef.current; if (!el) return; const fit = () => { const w = el.clientWidth, h = el.clientHeight; if (w > 0 && h > 0) setScale(Math.min(w / CANVAS.w, h / CANVAS.h)); }; fit(); if (typeof ResizeObserver === 'undefined') { window.addEventListener('resize', fit); return () => window.removeEventListener('resize', fit); } const ro = new ResizeObserver(fit); ro.observe(el); return () => ro.disconnect(); }, []); const ctx = React.useMemo(() => ({ T, CUES, authoredTotal: TOTAL }), [T]); return (
); } /* convenience mount for plain HTML pages */ HeroDashboardGraphic.mount = function (el, props) { const node = React.createElement(HeroDashboardGraphic, props || {}); if (ReactDOM.createRoot) ReactDOM.createRoot(el).render(node); else ReactDOM.render(node, el); }; window.HeroDashboardGraphic = HeroDashboardGraphic; if (typeof module !== 'undefined') module.exports = { HeroDashboardGraphic };