import { useEffect, useRef } from 'react' // Light build: drops the eval-based expression engine (smaller bundle, no eval // warning). Plenty for a looping mascot; swap to 'lottie-web' only if an asset // genuinely needs After Effects expressions. import lottie, { type AnimationItem } from 'lottie-web/build/player/lottie_light' interface Props { // Parsed Lottie JSON (imported, so it bundles offline). When undefined the // `fallback` is rendered instead — letting the companion ship before any // animation asset exists. animationData?: object loop?: boolean className?: string // Mirror the animation left↔right (for assets drawn facing the wrong way). flip?: boolean fallback: React.ReactNode } // Tighten the SVG viewBox to the animation's actual drawn content so an asset // with a lot of empty artboard padding (common with stock Lottie files) fills // the badge instead of floating tiny in the middle. We union the content bbox // across a few frames (the mascot moves) and make it square so it isn't // distorted by the square container. function fitToContent(anim: AnimationItem, svg: SVGSVGElement) { try { const frames = anim.getDuration(true) let x1 = Infinity, y1 = Infinity, x2 = -Infinity, y2 = -Infinity const SAMPLES = 6 for (let i = 0; i < SAMPLES; i++) { anim.goToAndStop(Math.floor(((frames - 1) * i) / (SAMPLES - 1)), true) const b = svg.getBBox() if (b.width === 0 || b.height === 0) continue x1 = Math.min(x1, b.x) y1 = Math.min(y1, b.y) x2 = Math.max(x2, b.x + b.width) y2 = Math.max(y2, b.y + b.height) } if (!isFinite(x1)) return const cx = (x1 + x2) / 2, cy = (y1 + y2) / 2 const side = Math.max(x2 - x1, y2 - y1) * 1.14 // ~7% breathing room each side svg.setAttribute('viewBox', `${cx - side / 2} ${cy - side / 2} ${side} ${side}`) svg.setAttribute('preserveAspectRatio', 'xMidYMid meet') } catch { /* getBBox unsupported / not laid out — leave the original viewBox */ } finally { anim.goToAndPlay(0, true) } } // A thin wrapper over lottie-web (framework-agnostic, no React peer deps, fully // offline). Reloads the animation whenever the data changes — moods swap by // passing a different `animationData`. export function LottiePlayer({ animationData, loop = true, className, flip, fallback }: Props) { const ref = useRef(null) const anim = useRef(null) useEffect(() => { const container = ref.current if (!container || !animationData) return const item = lottie.loadAnimation({ container, renderer: 'svg', loop, autoplay: true, animationData, }) anim.current = item item.addEventListener('DOMLoaded', () => { const svg = container.querySelector('svg') if (svg) fitToContent(item, svg as SVGSVGElement) }) return () => { item.destroy() anim.current = null } }, [animationData, loop]) if (!animationData) return <>{fallback} return (
) }