Skip to content
Stageshow Gallery

Instant payout

A cursorless system story: payouts stagger in, an instant payout card arrives, a line ties it to its row, and it sends.

Animated stillsAnimated
Available balance
$84,210.50
Payouts
Chase ···· 4821$12,400.00PendingPaid
$8,915.20PendingPaid
$21,060.75PendingPaid
$5,330.00PendingPaid
Instant payout
$12,400.00
To
Chase ···· 4821
Fee
$124.00
Pay out nowSendingSent

Arrives in about 30 minutes

payout-flow.tsx

"use client";

import {
  useEffect,
  useLayoutEffect,
  useRef,
  useState,
  useSyncExternalStore,
} from "react";
import { Check, Landmark, Zap } from "lucide-react";

const REDUCED = "(prefers-reduced-motion: reduce)";

function useReducedMotion() {
  return useSyncExternalStore(
    (cb) => {
      const mq = window.matchMedia(REDUCED);
      mq.addEventListener("change", cb);
      return () => mq.removeEventListener("change", cb);
    },
    () => window.matchMedia(REDUCED).matches,
    () => false,
  );
}

function useInView<T extends Element>() {
  const ref = useRef<T>(null);
  const [inView, setInView] = useState(false);
  useEffect(() => {
    const el = ref.current;
    if (!el) return;
    const io = new IntersectionObserver(([e]) => setInView(e.isIntersecting), {
      threshold: 0.3,
    });
    io.observe(el);
    return () => io.disconnect();
  }, []);
  return [ref, inView] as const;
}

function useLoop(holds: readonly number[], playing: boolean) {
  const [step, setStep] = useState(0);
  useEffect(() => {
    if (!playing) return;
    let i = 0;
    let t = 0;
    const next = () => {
      t = window.setTimeout(() => {
        i = (i + 1) % holds.length;
        setStep(i);
        next();
      }, holds[i]);
    };
    next();
    return () => {
      window.clearTimeout(t);
      setStep(0);
    };
  }, [playing, holds]);
  return playing ? step : 0;
}

const ROWS = [
  { amount: "12,400.00", bank: "Chase ···· 4821" },
  { amount: "8,915.20", bank: "Chase ···· 4821" },
  { amount: "21,060.75", bank: "Mercury ···· 0937" },
  { amount: "5,330.00", bank: "Chase ···· 4821" },
];

// poster, exit, empty, rows, card, line, sending, sent
const HOLDS = [2800, 800, 500, 1400, 1100, 1000, 1300, 700] as const;

const EASE = "ease-[cubic-bezier(.25,.8,.25,1)]";

export default function PayoutFlow() {
  const reduced = useReducedMotion();
  const [ref, inView] = useInView<HTMLDivElement>();
  const step = useLoop(HOLDS, inView && !reduced);

  const rowRef = useRef<HTMLDivElement>(null);
  const cardRef = useRef<HTMLDivElement>(null);
  const [line, setLine] = useState<{ x1: number; x2: number; y: number } | null>(null);

  useLayoutEffect(() => {
    const root = ref.current;
    const row = rowRef.current;
    const card = cardRef.current;
    if (!root || !row || !card) return;
    // Layout offsets ignore transforms, so the line lands where things settle.
    const measure = () => {
      const panel = row.offsetParent as HTMLElement;
      setLine({
        x1: panel.offsetLeft + row.offsetLeft + row.offsetWidth + 8,
        x2: card.offsetLeft - 4,
        y: panel.offsetTop + row.offsetTop + row.offsetHeight / 2,
      });
    };
    measure();
    const ro = new ResizeObserver(measure);
    ro.observe(root);
    return () => ro.disconnect();
  }, [ref]);

  const exiting = step === 1;
  const rowsIn = step === 0 || step >= 3;
  const cardIn = step === 0 || step >= 4;
  const lineIn = step === 0 || step >= 5;
  const sending = step === 6;
  const sent = step === 0 || step === 1 || step === 7;
  const noteIn = step === 0;

  return (
    <div
      ref={ref}
      className="@container relative h-full w-full overflow-hidden bg-[radial-gradient(120%_90%_at_0%_0%,#eef2ff_0%,#f4f4f5_55%)] text-zinc-900 dark:bg-[radial-gradient(120%_90%_at_0%_0%,#172036_0%,#09090b_60%)] dark:text-zinc-100"
    >
      {/* Balance and payouts */}
      <section className="absolute top-12 left-12 w-[380px] rounded-2xl bg-white p-6 @max-[480px]:top-4 @max-[480px]:left-4 @max-[480px]:w-[328px] @max-[480px]:p-5 shadow-[0_1px_2px_rgba(0,0,0,.05),0_20px_40px_-20px_rgba(0,0,0,.2)] ring-1 ring-black/5 dark:bg-zinc-900 dark:ring-white/10">
        <div className="text-[13px] text-zinc-500">Available balance</div>
        <div className="mt-1 text-[34px] font-semibold tracking-tight tabular-nums">
          <span className="text-zinc-400">$</span>84,210.50
        </div>
        <div className="mt-6 mb-2 flex items-center justify-between text-[13px]">
          <span className="font-medium">Payouts</span>
          <span className="h-1.5 w-10 rounded-full bg-zinc-200 dark:bg-zinc-700" />
        </div>
        {ROWS.map((r, i) => {
          const pending = i === 0 && !sent;
          return (
            <div
              key={i}
              ref={i === 0 ? rowRef : undefined}
              className={`grid h-12 grid-cols-[28px_1fr_auto_64px] items-center gap-3 ${i >= 1 ? "@max-[480px]:hidden" : ""} border-t border-black/5 transition-[opacity,translate] duration-700 dark:border-white/10 ${EASE} ${
                rowsIn && !exiting ? "translate-y-0 opacity-100" : exiting ? "opacity-0" : "translate-y-2 opacity-0"
              }`}
              style={{
                transitionDelay: rowsIn && !exiting ? `${i * 90}ms` : exiting ? `${(ROWS.length - i) * 60}ms` : "0ms",
                transitionDuration: exiting ? "350ms" : undefined,
              }}
            >
              <span className="grid size-7 place-items-center rounded-full bg-zinc-100 dark:bg-zinc-800">
                <Landmark className="size-3.5 text-zinc-500" aria-hidden />
              </span>
              {i === 0 ? (
                <span className="text-[13px] text-zinc-600 dark:text-zinc-400">{r.bank}</span>
              ) : (
                <span className="h-1.5 rounded-full bg-zinc-200 dark:bg-zinc-700" style={{ width: `${[0, 70, 52, 64][i]}%` }} />
              )}
              <span className="text-[13px] font-medium tabular-nums">${r.amount}</span>
              <span className="grid justify-items-end text-[12px] font-medium">
                <span
                  className={`col-start-1 row-start-1 rounded-full px-2 py-0.5 text-amber-700 transition-opacity duration-300 dark:text-amber-300 ${pending ? "bg-amber-50 opacity-100 dark:bg-amber-500/15" : "opacity-0"}`}
                >
                  Pending
                </span>
                <span
                  className={`col-start-1 row-start-1 rounded-full px-2 py-0.5 text-emerald-700 transition-opacity duration-300 dark:text-emerald-300 ${pending ? "opacity-0" : "bg-emerald-50 opacity-100 dark:bg-emerald-500/15"}`}
                >
                  Paid
                </span>
              </span>
            </div>
          );
        })}
      </section>

      {/* Instant payout */}
      <section
        ref={cardRef}
        className={`absolute top-12 left-[492px] w-[264px] @max-[480px]:top-[196px] @max-[480px]:left-10 @max-[480px]:w-[280px] rounded-2xl bg-white p-5 shadow-[0_1px_2px_rgba(0,0,0,.05),0_24px_48px_-16px_rgba(0,0,0,.28)] ring-1 ring-black/5 transition-[opacity,translate] dark:bg-zinc-800 dark:ring-white/10 ${EASE} ${
          cardIn && !exiting ? "translate-x-0 opacity-100 duration-[900ms]" : exiting ? "opacity-0 duration-300" : "translate-x-6 opacity-0 duration-0"
        }`}
        style={{ transitionDelay: exiting ? "60ms" : "0ms" }}
      >
        <div className="flex items-center gap-2 text-[14px] font-medium">
          <span className="grid size-6 place-items-center rounded-md bg-amber-400 text-amber-950">
            <Zap className="size-3.5" fill="currentColor" aria-hidden />
          </span>
          Instant payout
        </div>
        <div className="mt-4 text-[28px] font-semibold tracking-tight tabular-nums">
          <span className="text-zinc-400">$</span>12,400.00
        </div>
        <dl className="mt-3 space-y-2 text-[13px]">
          <div className="flex justify-between">
            <dt className="text-zinc-500">To</dt>
            <dd>Chase ···· 4821</dd>
          </div>
          <div className="flex justify-between">
            <dt className="text-zinc-500">Fee</dt>
            <dd className="tabular-nums">$124.00</dd>
          </div>
        </dl>
        <div
          className={`relative mt-5 grid h-10 place-items-center rounded-xl text-[14px] font-medium text-white transition-colors duration-300 ${
            sent ? "bg-emerald-600" : "bg-zinc-900 dark:bg-zinc-100 dark:text-zinc-900"
          }`}
        >
          <span className={`col-start-1 row-start-1 transition-opacity duration-200 ${!sending && !sent ? "opacity-100" : "opacity-0"}`}>
            Pay out now
          </span>
          <span className={`col-start-1 row-start-1 inline-flex items-center gap-2 transition-opacity duration-200 ${sending ? "opacity-100" : "opacity-0"}`}>
            <span className="size-3.5 rounded-full border-2 border-current border-t-transparent motion-safe:animate-spin" />
            Sending
          </span>
          <span className={`col-start-1 row-start-1 inline-flex items-center gap-2 text-white transition-opacity duration-200 ${sent ? "opacity-100" : "opacity-0"}`}>
            <Check className="size-4" strokeWidth={2.5} aria-hidden />
            Sent
          </span>
        </div>
      </section>

      {line && (
        <svg className="pointer-events-none absolute inset-0 h-full w-full overflow-visible @max-[480px]:hidden" aria-hidden>
          <path
            d={`M ${line.x1} ${line.y} H ${line.x2}`}
            className="stroke-zinc-400 dark:stroke-zinc-500"
            strokeWidth={1.25}
            fill="none"
            pathLength={1}
            strokeDasharray={1}
            strokeDashoffset={lineIn && !exiting ? 0 : 1}
            style={{
              transition: lineIn && !exiting ? "stroke-dashoffset 900ms cubic-bezier(.45,0,.1,1)" : "opacity 200ms",
              opacity: exiting ? 0 : 1,
            }}
          />
          <circle
            cx={line.x2}
            cy={line.y}
            r={3}
            className="fill-zinc-400 transition-opacity duration-300 dark:fill-zinc-500"
            style={{ opacity: lineIn && !exiting ? 1 : 0, transitionDelay: lineIn && !exiting ? "800ms" : "0ms" }}
          />
        </svg>
      )}

      <p
        className={`absolute top-[300px] left-[492px] @max-[480px]:top-[446px] @max-[480px]:left-10 text-[13px] text-zinc-500 transition-[opacity,translate] duration-700 ${EASE} ${
          noteIn ? "translate-y-0 opacity-100" : "-translate-y-1 opacity-0"
        }`}
      >
        Arrives in about 30 minutes
      </p>
    </div>
  );
}