// animations.jsx // Reusable animation starter: Stage, Timeline, Sprite, easing helpers. // Usage (in an HTML file that loads React + Babel): // // // // // // Inside , any child can call useTime() to read the current // playhead (seconds). Or wrap content in ... // to only render during that window -- children receive a `localTime` and // `progress` via the useSprite() hook. // // ───────────────────────────────────────────────────────────────────────────── // ── Easing functions (hand-rolled, Popmotion-style) ───────────────────────── // All easings take t ∈ [0,1] and return eased t ∈ [0,1] (may overshoot for back/elastic). const Easing = { linear: (t) => t, // Quad easeInQuad: (t) => t * t, easeOutQuad: (t) => t * (2 - t), easeInOutQuad: (t) => (t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t), // Cubic easeInCubic: (t) => t * t * t, easeOutCubic: (t) => (--t) * t * t + 1, easeInOutCubic: (t) => (t < 0.5 ? 4 * t * t * t : (t - 1) * (2 * t - 2) * (2 * t - 2) + 1), // Quart easeInQuart: (t) => t * t * t * t, easeOutQuart: (t) => 1 - (--t) * t * t * t, easeInOutQuart: (t) => (t < 0.5 ? 8 * t * t * t * t : 1 - 8 * (--t) * t * t * t), // Expo easeInExpo: (t) => (t === 0 ? 0 : Math.pow(2, 10 * (t - 1))), easeOutExpo: (t) => (t === 1 ? 1 : 1 - Math.pow(2, -10 * t)), easeInOutExpo: (t) => { if (t === 0) return 0; if (t === 1) return 1; if (t < 0.5) return 0.5 * Math.pow(2, 20 * t - 10); return 1 - 0.5 * Math.pow(2, -20 * t + 10); }, // Sine easeInSine: (t) => 1 - Math.cos((t * Math.PI) / 2), easeOutSine: (t) => Math.sin((t * Math.PI) / 2), easeInOutSine: (t) => -(Math.cos(Math.PI * t) - 1) / 2, // Back (overshoot) easeOutBack: (t) => { const c1 = 1.70158, c3 = c1 + 1; return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2); }, easeInBack: (t) => { const c1 = 1.70158, c3 = c1 + 1; return c3 * t * t * t - c1 * t * t; }, easeInOutBack: (t) => { const c1 = 1.70158, c2 = c1 * 1.525; return t < 0.5 ? (Math.pow(2 * t, 2) * ((c2 + 1) * 2 * t - c2)) / 2 : (Math.pow(2 * t - 2, 2) * ((c2 + 1) * (t * 2 - 2) + c2) + 2) / 2; }, // Elastic easeOutElastic: (t) => { const c4 = (2 * Math.PI) / 3; if (t === 0) return 0; if (t === 1) return 1; return Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * c4) + 1; }, }; // ── Core interpolation helpers ────────────────────────────────────────────── // Clamp a value to [min, max] const clamp = (v, min, max) => Math.max(min, Math.min(max, v)); // interpolate([0, 0.5, 1], [0, 100, 50], ease?) -> fn(t) // Popmotion-style: linearly maps t across input keyframes to output values, // with optional easing per segment (single fn or array of fns). function interpolate(input, output, ease = Easing.linear) { return (t) => { if (t <= input[0]) return output[0]; if (t >= input[input.length - 1]) return output[output.length - 1]; for (let i = 0; i < input.length - 1; i++) { if (t >= input[i] && t <= input[i + 1]) { const span = input[i + 1] - input[i]; const local = span === 0 ? 0 : (t - input[i]) / span; const easeFn = Array.isArray(ease) ? (ease[i] || Easing.linear) : ease; const eased = easeFn(local); return output[i] + (output[i + 1] - output[i]) * eased; } } return output[output.length - 1]; }; } // animate({from, to, start, end, ease})(t) — simpler single-segment tween. // Returns `from` before `start`, `to` after `end`. function animate({ from = 0, to = 1, start = 0, end = 1, ease = Easing.easeInOutCubic }) { return (t) => { if (t <= start) return from; if (t >= end) return to; const local = (t - start) / (end - start); return from + (to - from) * ease(local); }; } // ── Timeline context ──────────────────────────────────────────────────────── const TimelineContext = React.createContext({ time: 0, duration: 10, playing: false }); const useTime = () => React.useContext(TimelineContext).time; const useTimeline = () => React.useContext(TimelineContext); // ── Sprite ────────────────────────────────────────────────────────────────── // Renders children only when the playhead is inside [start, end]. Provides // a sub-context with `localTime` (seconds since start) and `progress` (0..1). // // // {({ localTime, progress }) => } // // // Or as a plain wrapper — children can call useSprite() themselves. const SpriteContext = React.createContext({ localTime: 0, progress: 0, duration: 0 }); const useSprite = () => React.useContext(SpriteContext); function Sprite({ start = 0, end = Infinity, children, keepMounted = false }) { const { time } = useTimeline(); const visible = time >= start && time <= end; if (!visible && !keepMounted) return null; const duration = end - start; const localTime = Math.max(0, time - start); const progress = duration > 0 && isFinite(duration) ? clamp(localTime / duration, 0, 1) : 0; const value = { localTime, progress, duration, visible }; return ( {typeof children === 'function' ? children(value) : children} ); } // ── Sample sprite components ──────────────────────────────────────────────── // TextSprite: fades/slides text in on entry, holds, then fades out on exit. // Props: text, x, y, size, color, font, entryDur, exitDur, align function TextSprite({ text, x = 0, y = 0, size = 48, color = '#111', font = 'Inter, system-ui, sans-serif', weight = 600, entryDur = 0.45, exitDur = 0.35, entryEase = Easing.easeOutBack, exitEase = Easing.easeInCubic, align = 'left', letterSpacing = '-0.01em', }) { const { localTime, duration } = useSprite(); const exitStart = Math.max(0, duration - exitDur); let opacity = 1; let ty = 0; if (localTime < entryDur) { const t = entryEase(clamp(localTime / entryDur, 0, 1)); opacity = t; ty = (1 - t) * 16; } else if (localTime > exitStart) { const t = exitEase(clamp((localTime - exitStart) / exitDur, 0, 1)); opacity = 1 - t; ty = -t * 8; } const translateX = align === 'center' ? '-50%' : align === 'right' ? '-100%' : '0'; return (
{text}
); } // ImageSprite: scales + fades in; optional Ken Burns drift during hold. function ImageSprite({ src, x = 0, y = 0, width = 400, height = 300, entryDur = 0.6, exitDur = 0.4, kenBurns = false, kenBurnsScale = 1.08, radius = 12, fit = 'cover', placeholder = null, // {label: string} for striped placeholder }) { const { localTime, duration } = useSprite(); const exitStart = Math.max(0, duration - exitDur); let opacity = 1; let scale = 1; if (localTime < entryDur) { const t = Easing.easeOutCubic(clamp(localTime / entryDur, 0, 1)); opacity = t; scale = 0.96 + 0.04 * t; } else if (localTime > exitStart) { const t = Easing.easeInCubic(clamp((localTime - exitStart) / exitDur, 0, 1)); opacity = 1 - t; scale = (kenBurns ? kenBurnsScale : 1) + 0.02 * t; } else if (kenBurns) { const holdSpan = exitStart - entryDur; const holdT = holdSpan > 0 ? (localTime - entryDur) / holdSpan : 0; scale = 1 + (kenBurnsScale - 1) * holdT; } const content = placeholder ? (
{placeholder.label || 'image'}
) : ( ); return (
{content}
); } // RectSprite: simple rectangle that animates position/size/color via props. // Useful demo primitive — takes a `render` fn for per-frame customization. function RectSprite({ x = 0, y = 0, width = 100, height = 100, color = '#111', radius = 8, entryDur = 0.4, exitDur = 0.3, render, // optional: (ctx) => style overrides }) { const spriteCtx = useSprite(); const { localTime, duration } = spriteCtx; const exitStart = Math.max(0, duration - exitDur); let opacity = 1; let scale = 1; if (localTime < entryDur) { const t = Easing.easeOutBack(clamp(localTime / entryDur, 0, 1)); opacity = clamp(localTime / entryDur, 0, 1); scale = 0.4 + 0.6 * t; } else if (localTime > exitStart) { const t = Easing.easeInQuad(clamp((localTime - exitStart) / exitDur, 0, 1)); opacity = 1 - t; scale = 1 - 0.15 * t; } const overrides = render ? render(spriteCtx) : {}; return (
); } function Stage({ width = 1280, height = 720, duration = 10, background = '#f6f4ef', fps = 60, loop = true, autoplay = true, persistKey = 'animstage', children, }) { const [time, setTime] = React.useState(() => { try { const v = parseFloat(localStorage.getItem(persistKey + ':t') || '0'); return isFinite(v) ? clamp(v, 0, duration) : 0; } catch { return 0; } }); const [playing, setPlaying] = React.useState(autoplay); const [hoverTime, setHoverTime] = React.useState(null); const [scale, setScale] = React.useState(1); const stageRef = React.useRef(null); const canvasRef = React.useRef(null); const rafRef = React.useRef(null); const lastTsRef = React.useRef(null); // Persist playhead React.useEffect(() => { try { localStorage.setItem(persistKey + ':t', String(time)); } catch {} }, [time, persistKey]); // Auto-scale to fit viewport React.useEffect(() => { if (!stageRef.current) return; const el = stageRef.current; const measure = () => { const barH = 44; // playback bar height const s = Math.min( el.clientWidth / width, (el.clientHeight - barH) / height ); setScale(Math.max(0.05, s)); }; measure(); const ro = new ResizeObserver(measure); ro.observe(el); window.addEventListener('resize', measure); return () => { ro.disconnect(); window.removeEventListener('resize', measure); }; }, [width, height]); // Animation loop React.useEffect(() => { if (!playing) { lastTsRef.current = null; return; } const step = (ts) => { if (lastTsRef.current == null) lastTsRef.current = ts; const dt = (ts - lastTsRef.current) / 1000; lastTsRef.current = ts; setTime((t) => { let next = t + dt; if (next >= duration) { if (loop) next = next % duration; else { next = duration; setPlaying(false); } } return next; }); rafRef.current = requestAnimationFrame(step); }; rafRef.current = requestAnimationFrame(step); return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current); lastTsRef.current = null; }; }, [playing, duration, loop]); // Keyboard: space = play/pause, ← → = seek React.useEffect(() => { const onKey = (e) => { if (e.target && (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA')) return; if (e.code === 'Space') { e.preventDefault(); setPlaying(p => !p); } else if (e.code === 'ArrowLeft') { setTime(t => clamp(t - (e.shiftKey ? 1 : 0.1), 0, duration)); } else if (e.code === 'ArrowRight') { setTime(t => clamp(t + (e.shiftKey ? 1 : 0.1), 0, duration)); } else if (e.key === '0' || e.code === 'Home') { setTime(0); } }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [duration]); const displayTime = hoverTime != null ? hoverTime : time; const ctxValue = React.useMemo( () => ({ time: displayTime, duration, playing, setTime, setPlaying }), [displayTime, duration, playing] ); return (
{/* Canvas area — vertically centered in remaining space */}
{children}
{/* Playback bar — stacked below canvas, never overlapping */} setPlaying(p => !p)} onReset={() => { setTime(0); }} onSeek={(t) => setTime(t)} onHover={(t) => setHoverTime(t)} />
); } // ── Playback bar ──────────────────────────────────────────────────────────── // Play/pause, return-to-begin, scrub track, time display. // Uses fixed-width time fields so layout doesn't thrash. function PlaybackBar({ time, duration, playing, onPlayPause, onReset, onSeek, onHover }) { const trackRef = React.useRef(null); const [dragging, setDragging] = React.useState(false); const timeFromEvent = React.useCallback((e) => { const rect = trackRef.current.getBoundingClientRect(); const x = clamp((e.clientX - rect.left) / rect.width, 0, 1); return x * duration; }, [duration]); const onTrackMove = (e) => { if (!trackRef.current) return; const t = timeFromEvent(e); if (dragging) { onSeek(t); } else { onHover(t); } }; const onTrackLeave = () => { if (!dragging) onHover(null); }; const onTrackDown = (e) => { setDragging(true); const t = timeFromEvent(e); onSeek(t); onHover(null); }; React.useEffect(() => { if (!dragging) return; const onUp = () => setDragging(false); const onMove = (e) => { if (!trackRef.current) return; const t = timeFromEvent(e); onSeek(t); }; window.addEventListener('mouseup', onUp); window.addEventListener('mousemove', onMove); return () => { window.removeEventListener('mouseup', onUp); window.removeEventListener('mousemove', onMove); }; }, [dragging, timeFromEvent, onSeek]); const pct = duration > 0 ? (time / duration) * 100 : 0; const fmt = (t) => { const total = Math.max(0, t); const m = Math.floor(total / 60); const s = Math.floor(total % 60); const cs = Math.floor((total * 100) % 100); return `${String(m).padStart(1, '0')}:${String(s).padStart(2, '0')}.${String(cs).padStart(2, '0')}`; }; const mono = 'JetBrains Mono, ui-monospace, SFMono-Regular, monospace'; return (
{playing ? ( ) : ( )} {/* Current time: fixed width so it doesn't thrash */}
{fmt(time)}
{/* Scrub track */}
{/* Duration: fixed width */}
{fmt(duration)}
); } function IconButton({ children, onClick, title }) { const [hover, setHover] = React.useState(false); return ( ); } Object.assign(window, { Easing, interpolate, animate, clamp, TimelineContext, useTime, useTimeline, Sprite, SpriteContext, useSprite, TextSprite, ImageSprite, RectSprite, Stage, PlaybackBar, });