Skip to content
Kinetic
PrimitivesDOM4 files

Lottie Ratio Poster

A slot for a Lottie that costs nothing until the slot is nearly on screen. The box is reserved at a provisional ratio and filled with a poster drawn in CSS; an IntersectionObserver on the already-sized box starts the fetch; the resolved JSON reports its own composition size in its `w` and `h` fields, the box takes that exact ratio, the player mounts underneath and the poster crossfades away. The readout prices the provisional guess in pixels of layout shift. No Lottie runtime is imported — loading and playback go through a render prop, so the packaged part is the reservation, not the player.

lazy-loadaspect-ratiolayout-shiftposterlottie

Live preview

The real component, running in this page.

02

Install

Copies the source into your project. Nothing is imported from us at runtime.

$npx shadcn@latest add https://kinetic-seven-taupe.vercel.app/r/lottie-ratio-poster.json

No runtime dependencies.

Also installs use-in-view, use-reduced-motion, use-resize-observer from this registry.

03

Prompt

The whole technique, written for an agent.

Build it with your agent

A single self-contained brief: the technique, the constants and why they hold, the pitfalls, and the definition of done. Paste it and the component gets written in your own conventions instead of copied in ours.

Claude CodeCursorCodexCopilotGemini CLI
.md
# Build: Lottie Ratio Poster

A slot for a Lottie that costs nothing until the slot is nearly on screen. The box is reserved at a provisional ratio and filled with a poster drawn in CSS; an IntersectionObserver on the already-sized box starts the fetch; the resolved JSON reports its own composition size in its `w` and `h` fields, the box takes that exact ratio, the player mounts underneath and the poster crossfades away. The readout prices the provisional guess in pixels of layout shift. No Lottie runtime is imported — loading and playback go through a render prop, so the packaged part is the reservation, not the player.

Build this as a React component in my project. Match my existing conventions —
file layout, styling system, and naming — rather than the ones implied below.
Everything after this line is specification, not code to copy verbatim.

## What it does

Reserves the box, holds a drawn poster, fetches the JSON only in view, then corrects the ratio from the animation's own w and h.

- Category: primitive
- Runtime cost: dom
- Dependencies: none beyond React

## How it works

1. The reserved box is a single `aspect-ratio` declaration on a full-width element, applied on first paint from the provisional ratio. That is what makes everything after it possible: a box with height cannot shift when it fills, and a box with height gives the IntersectionObserver something real to test.
2. The intersection test runs on that reserved box, with `once: true` and a 240px root margin, so the fetch starts roughly one flick of the wrist before the box arrives. Observing an unreserved, zero-height element instead is the classic failure — a collapsed box intersects the viewport from almost anywhere on the page, so the lazy load fires immediately and the laziness was decorative.
3. A Lottie JSON carries its composition size at the root: `w` and `h`, alongside `v`, `fr`, `ip`, `op` and `layers`. `w / h` is therefore the exact box before the player has been constructed, let alone measured, so the correction happens on the frame the file lands rather than after a layout pass.
4. The provisional ratio and the real ratio are both kept, and the difference is reported as `Math.abs(width / ratio - width / actual)` — the number of pixels everything below the box moved, at the current width. It is the same quantity that feeds Cumulative Layout Shift, in the units the user actually experienced.
5. The in-flight promise is stored in a ref rather than guarded by a boolean. StrictMode double-invokes the Effect: with a `started` flag the first call is abandoned by its own cleanup and the second is blocked by the flag, so the load silently never completes. Reusing the promise means one request and one subscription per Effect run.
6. The poster is dissolved two animation frames after `ready`, not on the same tick — one frame for React to commit the player and one for the browser to paint it. It is then unmounted on a timer rather than on `transitionend`, because a transition on a subtree that has been hidden never fires the event and the poster would sit over the player permanently.
7. A zero or missing dimension is treated as a failed load rather than divided by. `w / 0` is Infinity, which collapses `aspect-ratio` and takes the reserved box with it — the exact failure the component exists to prevent, arriving through the component itself.

## Constants — use these values

These are tuned. Each one has a reason; do not round them off or replace them
with values that "look cleaner".

- `DEFAULT_RATIO = 16 / 9`
  The provisional ratio, and named as a guess rather than a default so nobody inherits it by accident. Take the real number out of the design file: against the 3:2 composition in the demo, 16:9 costs 47 pixels of shift at 380px wide.
- `ROOT_MARGIN = '240px'`
  About a third of a laptop viewport, or roughly 400ms of ordinary scrolling — enough for a one-megabyte JSON on a decent connection to land before the box arrives. Larger and you are prefetching things the user will never reach; smaller and the poster is still up when the box is centred.
- `FADE_MS = 320`
  Poster crossfade. Long enough to read as a dissolve rather than a cut, short enough that the poster is not still visibly over the first second of the animation.
- `FADE_SLACK = 60`
  Extra milliseconds before the poster is unmounted. The transition is driven by a class change, so the removal timer and the transition clock do not share an origin; without the slack the poster can be pulled at opacity 0.04.

## API

```ts
function LottieRatioPoster<T extends LottieRatioSource>(props: { load: () => Promise<T>; children: (state: { source: T; reduced: boolean }) => ReactNode; ratio?: number; rootMargin?: string; fade?: number; label?: string; className?: string }): JSX.Element
```

- `load: () => Promise<T>`
  Fetches the animation JSON. Called at most once, and only once the reserved box is within `rootMargin`.
- `children: (state: { source: T; reduced: boolean }) => ReactNode`
  Render prop for the player. Mounted absolutely inside the reserved box.
- `ratio: number` — default `16 / 9`
  Provisional width / height, used until the JSON reports its own.
- `rootMargin: string` — default `'240px'`
  How early to start fetching, as an IntersectionObserver root margin.
- `fade: number` — default `320`
  Poster crossfade in milliseconds.
- `label: string` — default `'animation'`
  Short text on the poster.
- `className: string`
  Merged onto the outer element via `cn`.

## Reference implementation of the core

This is the part that is hard to get right. Adapt it; do not treat the rest of
the component as fixed.

```tsx
useEffect(() => {
  if (!inView) return;
  let alive = true;

  request.current ??= loadRef.current();
  setPhase((prev) => (prev === 'waiting' ? 'loading' : prev));

  request.current.then(
    (data) => {
      if (alive) apply(data);
    },
    () => {
      if (alive) setPhase('failed');
    },
  );

  return () => {
    alive = false;
  };
}, [inView, apply]);

// The real ratio comes from the JSON's own composition size, so the box is
// exact from the frame the file lands rather than from the frame the player
// finishes measuring itself.
const actual = source ? source.w / source.h : null;
const applied = actual ?? ratio;

// What the guess cost, in the units the user experiences it in. Everything
// below this box moves by exactly this many pixels at the current width.
const shift =
  actual !== null && width > 0 ? Math.abs(width / ratio - width / actual) : 0;
```

## Requirements

These are not optional. A version of this component that skips any of them is
not finished.

**Cleanup.** The intersection subscription is removed by `useInView`, which also unobserves after the first hit because it is used with `once`. Both timers are cleared and both `requestAnimationFrame` handles cancelled on teardown. The load promise cannot be aborted, so it is neutered instead: an `alive` flag in the Effect closure means a late resolution calls no setter after teardown. The promise itself is deliberately retained in a ref across Effect runs, which is what makes the request happen once rather than once per run.

**Reduced motion.** The poster loses its shimmer and the crossfade collapses to a zero-duration swap, so the poster is replaced rather than dissolved. The fetch is unaffected — reduced motion is a request about movement, not about content, and withholding the animation entirely would leave a permanent placeholder where the user expected something. The render prop is handed `reduced: true` so the player itself can park on its last frame, which is the conventional still for a Lottie.

**Frame-rate independence.** Any smoothing must be a function of delta time.
`x += (target - x) * 0.1` converges twice as fast at 120 Hz as at 60 Hz; use
`x = target + (x - target) * Math.pow(smoothing, dt)` instead, where
`smoothing` is the fraction of the gap remaining after one second.

**Strict mode.** Assume effects run twice in development. Setup must be
idempotent and teardown must fully undo it.

## Known pitfalls — avoid these specifically

- Lazy-loading against an unreserved box. A zero-height element intersects the viewport from nearly anywhere in the document, so the observer fires on mount and the fetch was never deferred. Reserve first, observe second — in that order, in the same element.
- Waiting for the player to report the size. `lottie-web` measures its own SVG after construction, which is after the layout has already happened at the wrong ratio. The JSON's `w` and `h` are available the instant the fetch resolves, before anything is instantiated.
- Guarding a one-shot fetch with a boolean ref. StrictMode runs the Effect, tears it down and runs it again; the first call is discarded by its own cleanup and the second is refused by the flag, so the promise never lands and the poster stays up in development only. Hold the promise itself in the ref.
- Dissolving the poster in the same commit that mounts the player. The player has not painted, so the crossfade runs over an empty box and reads as a flash. Two `requestAnimationFrame` calls put it after the paint.
- Unmounting the poster on `transitionend`. If the subtree is hidden mid-transition — a route change, a tab switch, an `Activity` boundary — no event is delivered and the poster is stranded on top of the player. Use a timer.
- Dividing by a dimension you did not check. A malformed or truncated JSON with `h: 0` produces an Infinite ratio, `aspect-ratio` gives up, and the reserved box collapses to nothing.
- Shipping the Lottie runtime for a decoration. `lottie-web` is around 60KB gzipped before any animation data; if the thing being animated is one badge in a footer, the runtime costs more than the effect is worth, and this component is where you notice.

## Notes

- No Lottie runtime is imported and no `.json` is committed. `load` is any function returning the parsed animation, and `children` is where the player goes, so the same slot works for `lottie-web`, `@lottiefiles/dotlottie-web`, a video, or a canvas.
- Type the resolved JSON yourself and pass it as the generic: `LottieRatioPoster<MyLottie>` gives the render prop the full object rather than only `w` and `h`.
- For reduced motion the render prop receives `reduced: true`. The conventional response with `lottie-web` is to construct it with `autoplay: false` and call `anim.goToAndStop(anim.getDuration(true) - 1, true)` — `getDuration(true)` returns frames, so the last frame is one less than that.

## Definition of done

On load the box already occupies its full reserved height, with a poster in it and a readout reading `reserved 1.78 · waiting for viewport`. Scroll it into view and the readout goes to `fetching json` roughly a scroll-flick before the box arrives. When the JSON lands the box snaps to 1.50, the player fades up under the dissolving poster, and the readout names the shift in pixels. Set the provisional ratio to 1.5 and reload: same sequence, and the readout says `no shift`. With reduced motion the poster does not shimmer and the swap is instant.

When you are finished, tell me which of the requirements above you implemented
and where, so I can check them.
140 lines · 1898 words
04

Source

4 files, exactly as installed.

installs tocomponents/motion/lottie-ratio-poster.tsx
'use client';

import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
import { cn } from '@/lib/utils';
import { useInView } from '@/hooks/use-in-view';
import { useReducedMotion } from '@/hooks/use-reduced-motion';
import { useResizeObserver } from '@/hooks/use-resize-observer';

/* ==========================================================================
   Lottie Ratio Poster
   --------------------------------------------------------------------------
   The slot a Lottie goes into, minus the Lottie.

   A Lottie JSON is regularly one to six megabytes of vector keyframes, and
   the runtime that plays it is another sixty on top. Neither should be on the
   critical path for a decoration two screens down. So: reserve the box, draw
   a poster into it, and fetch the JSON only when the box is about to be seen.

   The one number that makes this work is the ratio. A Lottie carries its own
   composition size at the root of the JSON — `w` and `h` — so once the file
   lands you know the exact box it wants. Before it lands you are guessing,
   and the cost of a wrong guess is a layout shift at the worst possible
   moment: after the user has started reading. This component makes that cost
   a number on screen rather than a surprise, so you can go and fix the guess.

   It deliberately does not import `lottie-web`. Loading and playback are the
   consumer's, through a render prop; what is packaged here is the reservation
   and the swap, which is the part everyone gets wrong and nobody extracts.
   ========================================================================== */

/** The two fields every Lottie JSON carries at its root. */
export interface LottieRatioSource {
  /** Composition width in the JSON's own units. The `w` field. */
  w: number;
  /** Composition height. The `h` field. */
  h: number;
}

export type LottieRatioPhase = 'waiting' | 'loading' | 'ready' | 'failed';

export interface LottieRatioRender<T> {
  /** The resolved JSON, whatever shape you resolved it as. */
  source: T;
  /**
   * True when the user asked for reduced motion. Park the animation on its
   * last frame instead of playing it — `anim.goToAndStop(anim.getDuration(true) - 1, true)`.
   */
  reduced: boolean;
}

export interface LottieRatioPosterProps<T extends LottieRatioSource> {
  /**
   * Fetches the animation JSON. Called at most once, and only after the
   * reserved box comes within `rootMargin` of the viewport. Kept in a ref, so
   * an inline arrow does not restart the load on every parent render.
   */
  load: () => Promise<T>;
  /** Renders the player once the JSON has landed. */
  children: (state: LottieRatioRender<T>) => ReactNode;
  /**
   * Provisional width / height, used until the JSON reports its own. Get this
   * from the design file — it is the difference between a reserved box and a
   * nearly reserved box.
   */
  ratio?: number;
  /** How early to start fetching, as an IntersectionObserver root margin. */
  rootMargin?: string;
  /** Poster crossfade in milliseconds. Skipped entirely under reduced motion. */
  fade?: number;
  /** Text on the poster. Keep it short; it is a placeholder, not a caption. */
  label?: string;
  className?: string;
}

/** 16:9. A guess, and named as one — see the shift readout for its cost. */
const DEFAULT_RATIO = 16 / 9;
/** Poster crossfade, ms. */
const FADE_MS = 320;
/** Start fetching this far before the box scrolls in. */
const ROOT_MARGIN = '240px';
/** Slack after the fade before the poster is unmounted, ms. */
const FADE_SLACK = 60;

function formatRatio(value: number): string {
  return value.toFixed(2);
}

export function LottieRatioPoster<T extends LottieRatioSource = LottieRatioSource>({
  load,
  children,
  ratio = DEFAULT_RATIO,
  rootMargin = ROOT_MARGIN,
  fade = FADE_MS,
  label = 'animation',
  className,
}: LottieRatioPosterProps<T>) {
  const host = useRef<HTMLDivElement>(null);

  const [phase, setPhase] = useState<LottieRatioPhase>('waiting');
  const [source, setSource] = useState<T | null>(null);
  const [fading, setFading] = useState(false);
  const [posterGone, setPosterGone] = useState(false);

  const loadRef = useRef(load);
  loadRef.current = load;

  /**
   * The in-flight promise, held across effect runs. StrictMode double-invokes
   * this effect and Activity-style remounts re-run it; a plain `started` flag
   * would let the first call be abandoned and block the second from ever
   * being made. Reusing the promise means the request happens exactly once
   * and every run subscribes to the same result.
   */
  const request = useRef<Promise<T> | null>(null);

  const reduced = useReducedMotion();
  const { width } = useResizeObserver(host);

  // The box already has its reserved height here, which is what makes the
  // intersection test mean anything. Observing a zero-height element reports
  // it as visible from anywhere on the page.
  const { inView } = useInView(host, { rootMargin, once: true });

  const apply = useCallback((data: T) => {
    // A composition with a zero dimension would divide the ratio to Infinity
    // and collapse the box. Treat it as a failed load and keep the poster.
    if (!(data.w > 0) || !(data.h > 0)) {
      setPhase('failed');
      return;
    }
    setSource(data);
    setPhase('ready');
  }, []);

  // #region critical
  useEffect(() => {
    if (!inView) return;
    let alive = true;

    request.current ??= loadRef.current();
    setPhase((prev) => (prev === 'waiting' ? 'loading' : prev));

    request.current.then(
      (data) => {
        if (alive) apply(data);
      },
      () => {
        if (alive) setPhase('failed');
      },
    );

    return () => {
      alive = false;
    };
  }, [inView, apply]);

  // The real ratio comes from the JSON's own composition size, so the box is
  // exact from the frame the file lands rather than from the frame the player
  // finishes measuring itself.
  const actual = source ? source.w / source.h : null;
  const applied = actual ?? ratio;

  // What the guess cost, in the units the user experiences it in. Everything
  // below this box moves by exactly this many pixels at the current width.
  const shift =
    actual !== null && width > 0 ? Math.abs(width / ratio - width / actual) : 0;
  // #endregion critical

  useEffect(() => {
    if (phase !== 'ready') return;
    if (reduced) {
      setFading(true);
      return;
    }
    // Two frames: one for React to commit the player into the DOM, one for
    // the browser to paint it. Dissolving the poster before the thing behind
    // it has drawn shows the empty box through the gap.
    let second = 0;
    const first = requestAnimationFrame(() => {
      second = requestAnimationFrame(() => setFading(true));
    });
    return () => {
      cancelAnimationFrame(first);
      cancelAnimationFrame(second);
    };
  }, [phase, reduced]);

  useEffect(() => {
    if (!fading) return;
    if (reduced) {
      setPosterGone(true);
      return;
    }
    // Unmounted on a timer rather than on transitionend: a transition on a
    // display:none subtree never fires one, and the poster would be pinned
    // over the player forever.
    const timer = window.setTimeout(() => setPosterGone(true), fade + FADE_SLACK);
    return () => window.clearTimeout(timer);
  }, [fading, reduced, fade]);

  return (
    <div className={cn('flex w-full flex-col gap-2', className)}>
      <div
        ref={host}
        className="relative w-full overflow-hidden border border-line bg-surface"
        // The whole technique in one declaration. Set before anything is
        // fetched, so the box occupies its final space from first paint.
        style={{ aspectRatio: String(applied) }}
      >
        {phase === 'ready' && source ? (
          <div className="absolute inset-0">{children({ source, reduced })}</div>
        ) : null}

        {posterGone ? null : (
          <div
            aria-hidden
            className={cn(
              'absolute inset-0 transition-opacity ease-out',
              fading ? 'opacity-0' : 'opacity-100',
            )}
            style={{ transitionDuration: reduced ? '0ms' : `${fade}ms` }}
          >
            <div className="absolute inset-0 bg-linear-to-br from-surface via-bg to-surface" />
            <div
              className={cn(
                'absolute inset-0 bg-linear-to-r from-transparent via-line-strong to-transparent opacity-30',
                !reduced && phase !== 'failed' ? 'animate-pulse' : null,
              )}
            />
            <div className="absolute inset-0 grid place-items-center">
              <span className="font-mono text-[10px] uppercase tracking-[0.08em] text-faint">
                {phase === 'failed' ? 'unavailable' : label}
              </span>
            </div>
          </div>
        )}
      </div>

      <p className="font-mono text-[10px] uppercase tracking-[0.08em] text-faint">
        {phase === 'waiting' ? (
          <>reserved {formatRatio(ratio)} &middot; waiting for viewport</>
        ) : null}
        {phase === 'loading' ? (
          <>reserved {formatRatio(ratio)} &middot; fetching json</>
        ) : null}
        {phase === 'failed' ? <span className="text-muted">load failed &middot; poster held</span> : null}
        {phase === 'ready' && actual !== null ? (
          <>
            <span>{formatRatio(ratio)} guess</span>
            <span className="mx-2 text-line-strong">&rarr;</span>
            <span className="text-fg">{formatRatio(actual)} actual</span>
            <span className="mx-2 text-line-strong">/</span>
            <span className={shift >= 1 ? 'text-accent' : undefined}>
              {shift < 1 ? 'no shift' : `${Math.round(shift)} px shift`}
            </span>
          </>
        ) : null}
      </p>
    </div>
  );
}
05

API

Every parameter, its type and what happens when you change it.

function LottieRatioPoster<T extends LottieRatioSource>(props: { load: () => Promise<T>; children: (state: { source: T; reduced: boolean }) => ReactNode; ratio?: number; rootMargin?: string; fade?: number; label?: string; className?: string }): JSX.Element
Parameters
load() => Promise<T>Fetches the animation JSON. Called at most once, and only once the reserved box is within rootMargin.
children(state: { source: T; reduced: boolean }) => ReactNodeRender prop for the player. Mounted absolutely inside the reserved box.
rationumber = 16 / 9Provisional width / height, used until the JSON reports its own.
rootMarginstring = '240px'How early to start fetching, as an IntersectionObserver root margin.
fadenumber = 320Poster crossfade in milliseconds.
labelstring = 'animation'Short text on the poster.
classNamestringMerged onto the outer element via cn.
06

How it works

The technique in order, not the code in order.

  1. 01

    The reserved box is a single aspect-ratio declaration on a full-width element, applied on first paint from the provisional ratio. That is what makes everything after it possible: a box with height cannot shift when it fills, and a box with height gives the IntersectionObserver something real to test.

  2. 02

    The intersection test runs on that reserved box, with once: true and a 240px root margin, so the fetch starts roughly one flick of the wrist before the box arrives. Observing an unreserved, zero-height element instead is the classic failure — a collapsed box intersects the viewport from almost anywhere on the page, so the lazy load fires immediately and the laziness was decorative.

  3. 03

    A Lottie JSON carries its composition size at the root: w and h, alongside v, fr, ip, op and layers. w / h is therefore the exact box before the player has been constructed, let alone measured, so the correction happens on the frame the file lands rather than after a layout pass.

  4. 04

    The provisional ratio and the real ratio are both kept, and the difference is reported as Math.abs(width / ratio - width / actual) — the number of pixels everything below the box moved, at the current width. It is the same quantity that feeds Cumulative Layout Shift, in the units the user actually experienced.

  5. 05

    The in-flight promise is stored in a ref rather than guarded by a boolean. StrictMode double-invokes the Effect: with a started flag the first call is abandoned by its own cleanup and the second is blocked by the flag, so the load silently never completes. Reusing the promise means one request and one subscription per Effect run.

  6. 06

    The poster is dissolved two animation frames after ready, not on the same tick — one frame for React to commit the player and one for the browser to paint it. It is then unmounted on a timer rather than on transitionend, because a transition on a subtree that has been hidden never fires the event and the poster would sit over the player permanently.

  7. 07

    A zero or missing dimension is treated as a failed load rather than divided by. w / 0 is Infinity, which collapses aspect-ratio and takes the reserved box with it — the exact failure the component exists to prevent, arriving through the component itself.

Load-bearing constants

Load-bearing constants
DEFAULT_RATIO = 16 / 9The provisional ratio, and named as a guess rather than a default so nobody inherits it by accident. Take the real number out of the design file: against the 3:2 composition in the demo, 16:9 costs 47 pixels of shift at 380px wide.
ROOT_MARGIN = '240px'About a third of a laptop viewport, or roughly 400ms of ordinary scrolling — enough for a one-megabyte JSON on a decent connection to land before the box arrives. Larger and you are prefetching things the user will never reach; smaller and the poster is still up when the box is centred.
FADE_MS = 320Poster crossfade. Long enough to read as a dissolve rather than a cut, short enough that the poster is not still visibly over the first second of the animation.
FADE_SLACK = 60Extra milliseconds before the poster is unmounted. The transition is driven by a class change, so the removal timer and the transition clock do not share an origin; without the slack the poster can be pulled at opacity 0.04.
07

Behaviour

What it does about motion sensitivity, teardown and being finished.

Reduced motion

The poster loses its shimmer and the crossfade collapses to a zero-duration swap, so the poster is replaced rather than dissolved. The fetch is unaffected — reduced motion is a request about movement, not about content, and withholding the animation entirely would leave a permanent placeholder where the user expected something. The render prop is handed reduced: true so the player itself can park on its last frame, which is the conventional still for a Lottie.

Cleanup

The intersection subscription is removed by useInView, which also unobserves after the first hit because it is used with once. Both timers are cleared and both requestAnimationFrame handles cancelled on teardown. The load promise cannot be aborted, so it is neutered instead: an alive flag in the Effect closure means a late resolution calls no setter after teardown. The promise itself is deliberately retained in a ref across Effect runs, which is what makes the request happen once rather than once per run.

Definition of done

On load the box already occupies its full reserved height, with a poster in it and a readout reading reserved 1.78 · waiting for viewport. Scroll it into view and the readout goes to fetching json roughly a scroll-flick before the box arrives. When the JSON lands the box snaps to 1.50, the player fades up under the dissolving poster, and the readout names the shift in pixels. Set the provisional ratio to 1.5 and reload: same sequence, and the readout says no shift. With reduced motion the poster does not shimmer and the swap is instant.

Pitfalls

  • Lazy-loading against an unreserved box. A zero-height element intersects the viewport from nearly anywhere in the document, so the observer fires on mount and the fetch was never deferred. Reserve first, observe second — in that order, in the same element.
  • Waiting for the player to report the size. lottie-web measures its own SVG after construction, which is after the layout has already happened at the wrong ratio. The JSON's w and h are available the instant the fetch resolves, before anything is instantiated.
  • Guarding a one-shot fetch with a boolean ref. StrictMode runs the Effect, tears it down and runs it again; the first call is discarded by its own cleanup and the second is refused by the flag, so the promise never lands and the poster stays up in development only. Hold the promise itself in the ref.
  • Dissolving the poster in the same commit that mounts the player. The player has not painted, so the crossfade runs over an empty box and reads as a flash. Two requestAnimationFrame calls put it after the paint.
  • Unmounting the poster on transitionend. If the subtree is hidden mid-transition — a route change, a tab switch, an Activity boundary — no event is delivered and the poster is stranded on top of the player. Use a timer.
  • Dividing by a dimension you did not check. A malformed or truncated JSON with h: 0 produces an Infinite ratio, aspect-ratio gives up, and the reserved box collapses to nothing.
  • Shipping the Lottie runtime for a decoration. lottie-web is around 60KB gzipped before any animation data; if the thing being animated is one badge in a footer, the runtime costs more than the effect is worth, and this component is where you notice.

Notes

  • No Lottie runtime is imported and no .json is committed. load is any function returning the parsed animation, and children is where the player goes, so the same slot works for lottie-web, @lottiefiles/dotlottie-web, a video, or a canvas.
  • Type the resolved JSON yourself and pass it as the generic: LottieRatioPoster<MyLottie> gives the render prop the full object rather than only w and h.
  • For reduced motion the render prop receives reduced: true. The conventional response with lottie-web is to construct it with autoplay: false and call anim.goToAndStop(anim.getDuration(true) - 1, true) — getDuration(true) returns frames, so the last frame is one less than that.