Splash

A full screen cover that closes as a circle when nothing is asking for it any more. Driven by presence in the DOM, not by state.

API

Props

proptypedefault
as?ElementType"div"the element or component to render
initial?booleantruemarks the splash shown on the first paint
className?stringmerged over the defaults; disables the clip animation
…restprops of astyped against the chosen element

Parts

Splash.Serve is the part that decides. While at least one is mounted anywhere in the document, the splash stays open; when the last one unmounts, it closes.

proptypedefault
instant?booleanfalsecloses with no transition when unmounted

Examples

Usage

tsx
<Splash>
  <SplashServeFonts />
  <div className="flex h-full w-full items-center justify-center bg-main">
    <Spinner />
  </div>
</Splash>

You saw it work on this site: the splash you passed through on the first paint is the one below, waiting for the fonts and for a minimum duration. There is no demo here, because a splash that can be replayed on demand is not a splash.

tsx
const SplashServeFonts = () => {
  const [isReady, setIsReady] = useState(false);

  useEffect(() => {
    const fontsReady = "fonts" in document ? document.fonts.ready : Promise.resolve();
    const minDuration = new Promise((resolve) => window.setTimeout(resolve, MIN_DURATION_MS));

    Promise.all([fontsReady, minDuration]).then(() => setIsReady(true));
  }, []);

  if (isReady) return null;

  return <Splash.Serve />;
};

Details

Presence instead of state

Nothing is lifted and nothing is shared. Each part of the app that needs more time mounts its own Splash.Serve, and the CSS asks the document whether any of them exist — a :has() selector on the root. Several reasons to wait therefore compose for free: the splash closes when the last one is gone, and no component needs to know about the others.

css
[data-margo-splash] {
  clip-path: circle(150% at 50% 50%);
  transition: clip-path 2500ms ease-out, visibility 2500ms ease-out;
}

:root:not(:has([data-margo-splash-serve])) [data-margo-splash] {
  visibility: hidden;
  clip-path: circle(0% at 50% 50%);
}

This is also why it survives the server. The splash is open by default in the markup, so it covers the first paint before any JavaScript has run; hydration then only removes the reasons to keep it.

The closing animation

It closes as a circle collapsing to the centre over two and a half seconds, animating clip-path and visibility together — the second is what keeps the cover from intercepting anything once it is gone. The element is also inert and aria-hidden, so it is never focusable or announced, not even while it is on screen.

instant on the Serve removes the transition entirely, for the cases where a wait ended in a way that should not be celebrated — a retry, a restored session.

Reacting to it elsewhere

The margo-splash-idle variant matches while no splash is being served, so any element can style itself against the same signal — an entrance animation that should only start once the cover is gone, for instance.

tsx
<div className="margo-splash-idle:opacity-0 transition-opacity"></div>

Passing a className to the Splash disables the clip animation on purpose: a custom cover usually wants its own way out, and two animations on the same element would fight.