Skip to content
react-cards-opening

Your own UI on core

If your product has its own way of moving, keep core and leave ui out. The rules stay the same (the queue, the presses, when a card counts as opened) and the picture is whatever you draw. This guide builds a working reveal from a div and CSS transitions.

phase: idle

  1. Unopened
  2. Unopened
  3. Unopened
  4. Unopened
  5. Unopened
  6. Unopened

No ui package and no GSAP in this one. Press the first button, then the card.

What a renderer owes the rules

  1. Say where the card is. Call opening.enter(phase) as the reveal moves on: hum, turn, hold, land.
  2. Say when it is face up. Call opening.reveal(). That is when onOpen fires. It is safe to call twice.
  3. Say when it is home. Call opening.landed(). The next card comes up, or the run ends.
  4. Be ready to jump. Hand opening.attach() four functions: turn now, finish now, cancel, close.

The effect

tsx
import { holdSeconds, showFor, tierOf, DEFAULT_RARITIES, useCardOpening, useOpeningPhase } from "@react-cards-opening/core";

function Reveal({ cards, opened, onOpen }) {
  const hunt = useCardOpening({ cards, opened, onOpen });
  const { opening, run, step, current } = hunt;
  const phase = useOpeningPhase(opening);

  useEffect(() => {
    if (!run || !current) return;
    const plan = showFor(tierOf(DEFAULT_RARITIES, current.rarity).effect);
    let timers: number[] = [];
    const after = (ms: number, act: () => void) => timers.push(window.setTimeout(act, ms));
    const clear = () => timers.splice(0).forEach(clearTimeout);

    const land = () => {
      clear();
      opening.reveal();
      opening.enter("land");
      after(320, opening.landed);
    };
    const turn = () => {
      clear();
      opening.enter("turn");
      after(420, () => {
        opening.reveal();
        opening.enter("hold");
      });
      after(420 + holdSeconds([current.name, current.line], plan) * 1000, land);
    };

    opening.enter("hum");
    after(900, turn);
    const detach = opening.attach({ turn, finish: land, cancel: clear, close: () => {} });
    return () => {
      clear();
      detach();
    };
  }, [run?.id, step]);

  return (
    <button className="card" data-phase={phase} onClick={hunt.pressCard} disabled={!run}>
      {current?.name}
    </button>
  );
}

The look

css
.card {
  transition: transform 0.42s cubic-bezier(0.34, 1.4, 0.64, 1), opacity 0.3s;
  transform: rotateY(180deg);
}
.card[data-phase="hum"] { animation: shake 0.3s 2; }
.card[data-phase="turn"],
.card[data-phase="hold"] { transform: rotateY(0); }
.card[data-phase="land"] { transform: scale(0.6); opacity: 0; }

What you get for free

  • hunt.pressCard turns a face-down card and sends a face-up one home. You did not write that branch.
  • hunt.pressBackdrop stops the run the right way: a turned card still lands, a face-down one goes back unopened.
  • hunt.openAll opens everything and calls your cancel.
  • A replay never calls onOpen.
  • The show plan tells you how loud to be for the tier, and holdSeconds how long to wait.

With an animation library

The same contract works with any clock. With a timeline, turn is a seek to a label and finish is a jump to the end. That is exactly what CardOpening does with GSAP, and reading its source is the fastest way to a renderer of your own.

Last updated . Read this page as Markdown.