All patterns

Stack Push Page

A new screen arrives over the current one, which holds back and dims to signal depth.

navigationpremiumcalminteraction · finite · intermediate · ~0.4s
Interactive · click to play
Variant

The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.

419 lines · react + motion only
import { useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

/**
 * Vibary · Stack Push Page
 *
 * The new page slides over the current one, which parallaxes back and
 * dims behind it. Going back reverses only the top page — the one
 * underneath comes forward to meet it.
 *
 * Self-contained: depends only on `motion` (react ships with your app).
 * Pages follow the host app's color scheme and everything on them is
 * mixed from the inherited text color, so the stack reads correctly on a
 * light page and on a dark one.
 * Works with zero props; tune via `variant`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type StackPushPageProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Notified with the depth of the stack after every move. */
  onDepthChange?: (depth: number) => void;
};

type VariantConfig = {
  spring: { type: "spring"; stiffness: number; damping: number };
  /** How far the page underneath travels, as a percentage of its width. */
  parallax: number;
  /** Opacity of the dimming layer over the page underneath. */
  dim: number;
  popDuration: number;
};

// Quality rule: a full page overshooting its own edge is the most visible
// wobble in the library — the whole screen would rebound. Every spring is
// at or above a 0.8 damping ratio, and nothing here scales: pages
// translate and dim, because scaling a page scales every glyph on it.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // Crisp and administrative. For flows people walk up and down all day.
  subtle: {
    spring: { type: "spring", stiffness: 480, damping: 44 },
    parallax: 18,
    dim: 0.18,
    popDuration: 0.24,
  },
  // The platform-standard feel: one soft settle, clear depth. All-purpose.
  default: {
    spring: { type: "spring", stiffness: 380, damping: 34 },
    parallax: 26,
    dim: 0.26,
    popDuration: 0.26,
  },
  // More parallax and a slower arrival, for flows where each step is a
  // deliberate choice.
  playful: {
    spring: { type: "spring", stiffness: 320, damping: 30 },
    parallax: 34,
    dim: 0.32,
    popDuration: 0.28,
  },
};

const ACCENT = "#7C7CF0";

/** Theme-adaptive neutral: `currentColor` is the text color this component
 *  inherits — near-black on a light page, near-white on a dark one — so
 *  mixing it with `transparent` yields a surface, border or fill that is
 *  correctly toned in either theme. Nothing to configure. */
const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

const REQUESTS = [
  {
    subject: "Refund not received",
    customer: "Priya Raman",
    plan: "Team · annual",
    meta: "12m ago · Open",
    body: "The refund for order 10476 was approved on Monday but has not appeared on the statement yet. Could you confirm it was sent to the original card?",
    orders: "14 orders · $3,480 lifetime",
  },
  {
    subject: "Seat count on the annual plan",
    customer: "Marco Silva",
    plan: "Business · annual",
    meta: "1h ago · Waiting",
    body: "We are adding four people next month. Can the seats be added mid-term, and does the price prorate to the renewal date?",
    orders: "31 orders · $12,900 lifetime",
  },
  {
    subject: "Export finished but empty",
    customer: "Aiko Tanaka",
    plan: "Team · monthly",
    meta: "3h ago · Open",
    body: "The data export completed in a few seconds and the download is a 0 KB file. Repeating it produced the same result twice.",
    orders: "7 orders · $640 lifetime",
  },
] as const;

type Route =
  | { kind: "inbox" }
  | { kind: "request"; index: number }
  | { kind: "customer"; index: number };

function Chevron() {
  return (
    <svg
      width="14"
      height="14"
      viewBox="0 0 20 20"
      fill="none"
      stroke="currentColor"
      strokeWidth="2"
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden
    >
      <path d="M12.5 4.5 7 10l5.5 5.5" />
    </svg>
  );
}

export default function StackPushPage({
  variant = "default",
  onDepthChange,
}: StackPushPageProps) {
  const [stack, setStack] = useState<Route[]>([{ kind: "inbox" }]);
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];

  const push = (route: Route) => {
    const next = [...stack, route];
    setStack(next);
    onDepthChange?.(next.length - 1);
  };

  const pop = () => {
    if (stack.length < 2) return;
    const next = stack.slice(0, -1);
    setStack(next);
    onDepthChange?.(next.length - 1);
  };

  // Reduced motion: the stack still goes deeper and shallower, and the
  // page underneath still recedes — it simply cross-fades instead of
  // sliding a full screen width.
  const pageMotion = (depth: number) =>
    reduceMotion
      ? {
          initial: { opacity: 0 },
          animate: { opacity: 1 },
          exit: { opacity: 0, transition: { duration: 0.12 } },
          transition: { duration: 0.14, ease: "easeOut" as const },
        }
      : {
          initial: { x: "100%" },
          // Only the top page sits at rest. Everything under it is held
          // back a fraction of a screen, which is the entire depth cue.
          animate: { x: depth === 0 ? "0%" : `-${cfg.parallax}%` },
          exit: {
            x: "100%",
            transition: { duration: cfg.popDuration, ease: "easeInOut" as const },
          },
          transition: cfg.spring,
        };

  const titleFor = (route: Route) =>
    route.kind === "inbox"
      ? "Support inbox"
      : route.kind === "request"
        ? "Request"
        : REQUESTS[route.index].customer;

  return (
    <div
      style={{
        position: "relative",
        width: 300,
        height: 400,
        borderRadius: 22,
        // The pages follow the host app's colour scheme: `Canvas` and
        // `CanvasText` are the CSS system colors for page background and
        // page text, so a pushed page is opaque and legible in a light app
        // and in a dark one. Everything on it mixes from `currentColor`.
        background: "Canvas",
        color: "CanvasText",
        border: `1px solid ${tone(14)}`,
        boxShadow: "0 18px 44px rgba(0,0,0,0.2)",
        // Pages are positioned against this frame rather than the
        // viewport, so the pattern drops into a preview or an embedded
        // card. For a real app shell, make this the routed region and give
        // each page `position: fixed; inset: 0` instead.
        overflow: "hidden",
      }}
    >
      <AnimatePresence initial={false}>
        {stack.map((route, index) => {
          const depth = stack.length - 1 - index;
          const isTop = depth === 0;
          return (
            <motion.div
              key={`${index}-${route.kind}`}
              {...pageMotion(depth)}
              aria-hidden={!isTop}
              style={{
                position: "absolute",
                inset: 0,
                display: "flex",
                flexDirection: "column",
                background: "Canvas",
                color: "CanvasText",
                // The shadow is what separates two pages of the same
                // colour while one is sliding across the other.
                boxShadow: index === 0 ? undefined : "-14px 0 30px rgba(0,0,0,0.22)",
              }}
            >
              <div
                style={{
                  display: "flex",
                  alignItems: "center",
                  gap: 8,
                  height: 50,
                  padding: "0 12px",
                  borderBottom: `1px solid ${tone(12)}`,
                }}
              >
                {index > 0 && (
                  <button
                    type="button"
                    onClick={pop}
                    aria-label="Back"
                    style={{
                      display: "grid",
                      placeItems: "center",
                      width: 28,
                      height: 28,
                      borderRadius: 8,
                      border: 0,
                      background: tone(8),
                      color: ACCENT,
                      cursor: "pointer",
                    }}
                  >
                    <Chevron />
                  </button>
                )}
                <div style={{ fontSize: 13.5, fontWeight: 650 }}>
                  {titleFor(route)}
                </div>
              </div>

              <div style={{ flex: 1, overflow: "hidden" }}>
                {route.kind === "inbox" && (
                  <div style={{ padding: "6px 8px" }}>
                    {REQUESTS.map((request, requestIndex) => (
                      <button
                        key={request.subject}
                        type="button"
                        onClick={() => push({ kind: "request", index: requestIndex })}
                        style={{
                          display: "flex",
                          alignItems: "center",
                          gap: 10,
                          width: "100%",
                          padding: "10px 10px",
                          borderRadius: 11,
                          border: 0,
                          background: "transparent",
                          color: "inherit",
                          fontFamily: "inherit",
                          textAlign: "left",
                          cursor: "pointer",
                        }}
                      >
                        <span style={{ flex: 1, minWidth: 0 }}>
                          <span
                            style={{
                              display: "block",
                              fontSize: 12.5,
                              fontWeight: 600,
                            }}
                          >
                            {request.subject}
                          </span>
                          <span
                            style={{
                              display: "block",
                              fontSize: 11,
                              opacity: 0.5,
                              marginTop: 2,
                            }}
                          >
                            {request.customer} · {request.meta}
                          </span>
                        </span>
                        <span style={{ opacity: 0.35, transform: "scaleX(-1)" }}>
                          <Chevron />
                        </span>
                      </button>
                    ))}
                  </div>
                )}

                {route.kind === "request" && (
                  <div style={{ padding: "14px 16px" }}>
                    <div style={{ fontSize: 15, fontWeight: 650 }}>
                      {REQUESTS[route.index].subject}
                    </div>
                    <div style={{ fontSize: 11, opacity: 0.5, marginTop: 3 }}>
                      {REQUESTS[route.index].meta}
                    </div>
                    <p
                      style={{
                        margin: "12px 0 0",
                        fontSize: 12.5,
                        lineHeight: 1.6,
                        opacity: 0.75,
                      }}
                    >
                      {REQUESTS[route.index].body}
                    </p>
                    <button
                      type="button"
                      onClick={() => push({ kind: "customer", index: route.index })}
                      style={{
                        display: "flex",
                        alignItems: "center",
                        gap: 10,
                        width: "100%",
                        marginTop: 16,
                        padding: "11px 12px",
                        borderRadius: 12,
                        border: `1px solid ${tone(12)}`,
                        background: tone(6),
                        color: "inherit",
                        fontFamily: "inherit",
                        textAlign: "left",
                        cursor: "pointer",
                      }}
                    >
                      <span style={{ flex: 1, minWidth: 0 }}>
                        <span style={{ display: "block", fontSize: 12.5, fontWeight: 600 }}>
                          {REQUESTS[route.index].customer}
                        </span>
                        <span style={{ display: "block", fontSize: 11, opacity: 0.5 }}>
                          View customer
                        </span>
                      </span>
                      <span style={{ opacity: 0.4, transform: "scaleX(-1)" }}>
                        <Chevron />
                      </span>
                    </button>
                  </div>
                )}

                {route.kind === "customer" && (
                  <div style={{ padding: "14px 16px" }}>
                    <div
                      aria-hidden
                      style={{
                        display: "grid",
                        placeItems: "center",
                        width: 44,
                        height: 44,
                        borderRadius: 999,
                        background: tone(10),
                        color: ACCENT,
                        fontSize: 15,
                        fontWeight: 650,
                      }}
                    >
                      {REQUESTS[route.index].customer.charAt(0)}
                    </div>
                    <div style={{ fontSize: 15, fontWeight: 650, marginTop: 10 }}>
                      {REQUESTS[route.index].customer}
                    </div>
                    <div style={{ fontSize: 11.5, opacity: 0.5, marginTop: 2 }}>
                      {REQUESTS[route.index].plan}
                    </div>
                    <div
                      style={{
                        marginTop: 14,
                        paddingTop: 12,
                        borderTop: `1px solid ${tone(12)}`,
                        fontSize: 12.5,
                        opacity: 0.75,
                      }}
                    >
                      {REQUESTS[route.index].orders}
                    </div>
                  </div>
                )}
              </div>

              {/* Dimming the page underneath does the work a scale would
                  otherwise be asked to do, without touching type size. */}
              <motion.div
                aria-hidden
                initial={false}
                animate={{ opacity: isTop ? 0 : 1 }}
                transition={
                  reduceMotion
                    ? { duration: 0 }
                    : { duration: 0.24, ease: "easeOut" }
                }
                style={{
                  position: "absolute",
                  inset: 0,
                  background: `rgba(0,0,0,${cfg.dim})`,
                  pointerEvents: isTop ? "none" : "auto",
                }}
              />
            </motion.div>
          );
        })}
      </AnimatePresence>
    </div>
  );
}

About this pattern

The navigation model behind almost every mobile app: screens stack, and the way they move is what tells you whether you went forward or back. The arriving screen covers the frame while the one underneath is held at a fraction of a screen width and dimmed — that difference in travel is the entire depth cue, and it is what makes the back gesture feel like uncovering rather than replacing. Nothing scales: a page that scales scales every glyph on it, so depth is carried by translation, a dimming layer and an edge shadow instead. Popping is a plain ease rather than a spring, because a full screen rebounding at the edge of the frame is the most visible wobble a stack can produce.

Mobile screen navigationSupport inbox drill-downSettings sub-pagesRecord browsing flow

Where it shows up

Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.

  • 10:15
    Home
    Contract renewalPriya Raman · 10:14
    Q3 hiring planMarcus Bell · 09:02
    Venue confirmedDana Whitfield · Tue
    Invoice 4821 clearedBilling · Tue
    HomeSearchActivityProfile
    Mobile navigation

    The pushed screen covers the previous one, which shifts back a fraction of the width.

Related patterns