create Scrim

Takes the nodes and how they animate, and returns a store that covers and uncovers them.

Usage

I write one store per scrim, in a config file. It lives outside React, so a route loader, the router or a plain function can call it.

tsx
// config/scrim.config.ts
export const SCRIM_NODE_CURTAIN = "curtain";

export const scrimRouteConfig = createScrim({
  nodes: [SCRIM_NODE_CURTAIN],
  animation: { duration: 650, easing: "cubic-bezier(0.65, 0, 0.35, 1)" },
  enter: ({ curtain }, { play }) => play(curtain, [{ transform: "translateY(0)" }]),
  leave: ({ curtain }, { play }) => play(curtain, [{ transform: "translateY(-100%)" }]),
});

Nodes

Every element the store animates is a node. I name them in nodes, and each component registers its own with node(). The names are typed, so a typo doesn't compile.

tsx
// config/scrim.config.ts
export const SCRIM_NODE_CURTAIN = "curtain";
export const SCRIM_NODE_PAGE = "page";

export const scrimRouteConfig = createScrim({
  nodes: [SCRIM_NODE_CURTAIN, SCRIM_NODE_PAGE],
  // ...
});

// features/scrim/scrim_route.tsx
<div ref={scrimRouteConfig.node(SCRIM_NODE_CURTAIN)} className="fixed inset-0 z-9999 bg-main" />

// shared/components/sections/main.tsx
<main ref={scrimRouteConfig.node(SCRIM_NODE_PAGE)}>{children}</main>

enter and status

status says where the scrim starts from. From idle it is off screen, so I give it a first keyframe. From leaving it was interrupted halfway, so I give only the target and it reverses from where it is.

tsx
enter: ({ curtain }, { status, play }) =>
  play(
    curtain,
    status === "idle"
      ? [{ transform: "translateY(100%)" }, { transform: "translateY(0)" }]
      : [{ transform: "translateY(0)" }],
  ),

play

play runs a Web Animation with the defaults from animation, and any call can override them. Here the page rises in while the curtain leaves.

tsx
animation: { duration: 650, easing: "cubic-bezier(0.65, 0, 0.35, 1)" },
leave: ({ curtain, page }, { play }) =>
  Promise.all([
    play(curtain, [{ transform: "translateY(-100%)" }]),
    play(
      page,
      [
        { opacity: 0, transform: "translateY(4rem)" },
        { opacity: 1, transform: "translateY(0)" },
      ],
      { delay: 500, duration: 900, easing: "cubic-bezier(0.22, 1, 0.36, 1)" },
    ),
  ]),

until and hold

The store starts covered, so the server already sends the splash. The first leave waits for until, then for hold. Every later leave waits for hold only.

tsx
hold: 650,
until: () => document.fonts.ready,

initial

A scrim that should wait for a call starts idle. I use it for the overlay of a global operation.

tsx
export const scrimSpinnerConfig = createScrim({
  nodes: [SCRIM_NODE_OVERLAY],
  initial: "idle",
  animation: { duration: 350 },
  enter: ({ overlay }, { play }) => play(overlay, [{ opacity: 1 }]),
  leave: ({ overlay }, { play }) => play(overlay, [{ opacity: 0 }]),
});

Other engines

The store awaits whatever enter and leave return. A GSAP tween or a Motion animation works in place of play.

tsx
enter: ({ curtain }) => gsap.to(curtain, { yPercent: 0, duration: 0.65, overwrite: true }),
leave: ({ curtain }) => gsap.to(curtain, { yPercent: -100, duration: 0.65, overwrite: true }),

Reference

Options

proptypedefault
nodesreadonly TName[]—the names of the nodes it animates
enter(nodes, { status, play }) => unknown—the cover animation
leave(nodes, { play }) => unknown—the uncover animation
initial?"covered" | "idle""covered"covered for a splash, idle for a scrim that waits for a call
animation?KeyframeAnimationOptions—the defaults of every play call
hold?number0milliseconds to stay covered before leaving
until?(nodes, { play }) => unknown—what the first leave waits for

Returns

proptypedefault
node(name: TName) => ref—the ref that registers a node
cover() => Promise<void>—runs enter; resolves once covered
uncover() => Promise<void>—waits for until and hold, then runs leave
subscribe(listener) => () => void—listens to every change
getStatus() => ScrimStatus—idle, entering, covered or leaving
getIsReady() => boolean—true once until has resolved