All patterns

Fullscreen Toggle

A panel expands to fill the frame while surrounding chrome fades.

navigationpremiumfuturisticinteraction · finite · intermediate · ~0.5s
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.

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

/**
 * Vibary · Fullscreen Toggle
 *
 * One panel takes over the frame. Rather than opening a second copy of
 * the panel in a dialog, the panel itself is measured in both places and
 * travels between them, while the chrome around it stands down. The
 * detail that only fits at full size arrives after the panel has landed.
 *
 * Self-contained: depends only on `motion` (react ships with your app).
 * Surfaces are mixed from the inherited text color, so the frame reads
 * correctly on a light page and on a dark one.
 * Works with zero props; tune via `variant`, `defaultExpanded`.
 * Press the expand control, or Escape to come back.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type FullscreenToggleProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Start with the panel already filling the frame. */
  defaultExpanded?: boolean;
  /** Notified whenever the panel enters or leaves full size. */
  onExpandedChange?: (expanded: boolean) => void;
};

type VariantConfig = {
  spring: { type: "spring"; stiffness: number; damping: number };
  /** Seconds the surrounding chrome takes to stand down. */
  chromeFade: number;
  /** Seconds before the extra detail arrives. */
  detailDelay: number;
};

// Quality rule: this is the largest surface in the component, and a large
// surface that overshoots reads as a mistake rather than as energy. Every
// spring is at or above a 0.8 damping ratio (ζ = damping / 2√stiffness),
// and the title is carried with layout="position" at a constant font size
// so no glyph is ever scaled by the reflow.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // ζ ≈ 1.00 — lands flat. For a dashboard where this happens often.
  subtle: {
    spring: { type: "spring", stiffness: 580, damping: 48 },
    chromeFade: 0.11,
    detailDelay: 0.07,
  },
  // ζ ≈ 0.90 — one soft settle, the weight that sells the takeover.
  default: {
    spring: { type: "spring", stiffness: 360, damping: 34 },
    chromeFade: 0.18,
    detailDelay: 0.14,
  },
  // ζ ≈ 0.81 — a longer, more cinematic arrival for a media surface.
  playful: {
    spring: { type: "spring", stiffness: 270, damping: 27 },
    chromeFade: 0.25,
    detailDelay: 0.21,
  },
};

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 SPARK = "M0 46 L26 38 L52 41 L78 24 L104 29 L130 14 L156 19 L182 6";
const AREA = `${SPARK} L182 56 L0 56 Z`;

const TILES = [
  ["Sessions", "18.4k"],
  ["Conversion", "3.1%"],
  ["Refunds", "0.4%"],
] as const;

export default function FullscreenToggle({
  variant = "default",
  defaultExpanded = false,
  onExpandedChange,
}: FullscreenToggleProps) {
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];
  const [full, setFull] = useState(defaultExpanded);

  // Reduced motion: the panel changes place without travelling. The
  // takeover still happens, it just stops being a journey.
  const reflow = reduceMotion ? { duration: 0 } : cfg.spring;

  const setExpanded = (next: boolean) => {
    setFull(next);
    onExpandedChange?.(next);
  };

  useEffect(() => {
    if (!full) return;
    const onKey = (event: KeyboardEvent) => {
      if (event.key !== "Escape") return;
      setFull(false);
      onExpandedChange?.(false);
    };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [full, onExpandedChange]);

  return (
    <div
      style={{
        position: "relative",
        width: 336,
        height: 300,
        padding: 12,
        borderRadius: 18,
        background: tone(6),
        color: "inherit",
        border: `1px solid ${tone(12)}`,
        boxShadow: "0 14px 36px rgba(0,0,0,0.16)",
        // The panel fills this frame, not the browser window, so the
        // pattern works inside a card or a split view. For a true
        // fullscreen panel, position the expanded state `fixed` with
        // inset 0 instead of `absolute`, and lock body scroll while it
        // is open.
        overflow: "hidden",
      }}
    >
      {/* Chrome stands down rather than being removed: opacity only, so
          nothing under it reflows and nothing scales its text. */}
      <motion.div
        initial={false}
        animate={{ opacity: full ? 0 : 1 }}
        transition={{
          duration: reduceMotion ? 0 : cfg.chromeFade,
          ease: "easeOut",
          // Coming back, the chrome waits for the panel to be most of the
          // way home — two things arriving at once reads as a flicker.
          delay: full || reduceMotion ? 0 : cfg.chromeFade,
        }}
        style={{ pointerEvents: full ? "none" : "auto" }}
      >
        <div
          style={{
            display: "flex",
            alignItems: "baseline",
            justifyContent: "space-between",
            padding: "2px 4px 10px",
          }}
        >
          <span style={{ fontSize: 13, fontWeight: 650 }}>Storefront</span>
          <span style={{ fontSize: 11, opacity: 0.45 }}>Last 30 days</span>
        </div>

        <div style={{ display: "flex", gap: 8, marginBottom: 10 }}>
          {TILES.map(([label, value]) => (
            <div
              key={label}
              style={{
                flex: 1,
                padding: "8px 10px",
                borderRadius: 12,
                background: tone(8),
                border: `1px solid ${tone(12)}`,
              }}
            >
              <div style={{ fontSize: 10.5, opacity: 0.5 }}>{label}</div>
              <div style={{ fontSize: 13, fontWeight: 650, marginTop: 2 }}>
                {value}
              </div>
            </div>
          ))}
        </div>
      </motion.div>

      {/* Opaque ground for the expanded state, so the panel never shows
          the chrome through itself on the way back. */}
      <AnimatePresence>
        {full && (
          <motion.div
            key="ground"
            aria-hidden
            initial={{ opacity: 0 }}
            animate={{ opacity: 1 }}
            exit={{ opacity: 0, transition: { duration: cfg.chromeFade } }}
            transition={{ duration: reduceMotion ? 0 : cfg.chromeFade }}
            style={{
              position: "absolute",
              inset: 0,
              // `Canvas` is the CSS system color for page background: it
              // follows the host app's light or dark surface with no
              // configuration.
              background: "Canvas",
            }}
          />
        )}
      </AnimatePresence>

      {/* The panel is one element in two places. `layout` measures it
          before and after the switch and animates the difference — no
          second copy, no cross-fade between two panels. */}
      <motion.section
        layout
        transition={reflow}
        aria-label="Revenue detail"
        style={{
          position: full ? "absolute" : "relative",
          inset: full ? 0 : "auto",
          zIndex: 2,
          display: "flex",
          flexDirection: "column",
          height: full ? "auto" : 150,
          padding: full ? 16 : 12,
          borderRadius: full ? 18 : 14,
          background: tone(8),
          border: `1px solid ${tone(full ? 10 : 12)}`,
          overflow: "hidden",
        }}
      >
        <motion.div
          layout="position"
          transition={reflow}
          style={{
            display: "flex",
            alignItems: "center",
            gap: 8,
            marginBottom: 10,
          }}
        >
          {/* Constant font size plus layout="position": the heading is
              carried to its new place without a single frame of scaled
              type. */}
          <span style={{ flex: 1, fontSize: 13, fontWeight: 650 }}>Revenue</span>
          <span style={{ fontSize: 11.5, opacity: 0.5 }}>+12.4%</span>
          <button
            type="button"
            onClick={() => setExpanded(!full)}
            aria-pressed={full}
            aria-label={full ? "Exit full size" : "Expand to full size"}
            style={{
              display: "grid",
              placeItems: "center",
              width: 26,
              height: 26,
              borderRadius: 8,
              border: `1px solid ${tone(14)}`,
              background: tone(6),
              color: "inherit",
              cursor: "pointer",
            }}
          >
            {/* Corner arrows turn outward to expand and inward to come
                back — the same four strokes, rotated half a turn. */}
            <motion.svg
              width="14"
              height="14"
              viewBox="0 0 20 20"
              fill="none"
              stroke="currentColor"
              strokeWidth="1.7"
              strokeLinecap="round"
              strokeLinejoin="round"
              aria-hidden
              initial={false}
              animate={{ rotate: full ? 180 : 0 }}
              transition={reduceMotion ? { duration: 0 } : { duration: 0.26, ease: "easeOut" }}
            >
              {full ? (
                <>
                  <path d="M8.5 3.5v5h-5" />
                  <path d="M11.5 16.5v-5h5" />
                  <path d="M8.5 8.5 3.2 3.2M11.5 11.5l5.3 5.3" />
                </>
              ) : (
                <>
                  <path d="M12 3.5h4.5V8" />
                  <path d="M8 16.5H3.5V12" />
                  <path d="M16.5 3.5 11 9M3.5 16.5 9 11" />
                </>
              )}
            </motion.svg>
          </button>
        </motion.div>

        {/* Synthesized imagery: an inline SVG chart rather than an asset,
            so the file stays one copyable unit. */}
        <motion.div layout transition={reflow} style={{ flex: 1, minHeight: 0 }}>
          <svg
            viewBox="0 0 182 56"
            preserveAspectRatio="none"
            width="100%"
            height="100%"
            aria-hidden
            style={{ display: "block", overflow: "visible" }}
          >
            <path d={AREA} fill={ACCENT} opacity={0.16} />
            <path
              d={SPARK}
              fill="none"
              stroke={ACCENT}
              strokeWidth="1.6"
              strokeLinecap="round"
              strokeLinejoin="round"
              vectorEffect="non-scaling-stroke"
            />
          </svg>
        </motion.div>

        {/* Detail that only earns its place at full size. It arrives once
            the panel has landed, so two motions never compete. */}
        <AnimatePresence>
          {full && (
            <motion.div
              key="detail"
              initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 8 }}
              animate={{ opacity: 1, y: 0 }}
              exit={{ opacity: 0, transition: { duration: 0.1 } }}
              transition={{
                duration: reduceMotion ? 0.12 : 0.26,
                ease: "easeOut",
                delay: reduceMotion ? 0 : cfg.detailDelay,
              }}
              style={{ paddingTop: 12 }}
            >
              <div style={{ display: "flex", gap: 8 }}>
                {TILES.map(([label, value]) => (
                  <div
                    key={label}
                    style={{
                      flex: 1,
                      padding: "9px 10px",
                      borderRadius: 12,
                      background: tone(7),
                      border: `1px solid ${tone(12)}`,
                    }}
                  >
                    <div style={{ fontSize: 10.5, opacity: 0.5 }}>{label}</div>
                    <div style={{ fontSize: 13, fontWeight: 650, marginTop: 2 }}>
                      {value}
                    </div>
                  </div>
                ))}
              </div>
              <div style={{ fontSize: 11.5, opacity: 0.5, marginTop: 10 }}>
                Press Escape to return to the dashboard.
              </div>
            </motion.div>
          )}
        </AnimatePresence>
      </motion.section>
    </div>
  );
}

About this pattern

The move behind focus modes, expandable dashboard tiles and media players. There is no second copy of the panel in an overlay: the same element switches from a tile in the flow to an absolutely positioned surface at inset zero, and a layout animation measures both boxes and travels between them. Everything else is timing discipline — the chrome around it only changes opacity so nothing reflows behind the motion, an opaque ground fades in so the panel never shows the chrome through itself on the way back, and the extra detail that earns its place at full size waits until the panel has landed. The heading rides on layout="position" at a constant font size, so no glyph is ever scaled by the reflow.

Expandable dashboard tileMedia playerFocus mode in an editorReport or chart detail

Where it shows up

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

  • OverviewLast 30 days
    Revenue$48,210+12.4%
    Orders1,284+3.1%
    Refunds$1,940−0.8%
    Revenue by day
    Dashboard

    A single tile expands over the board while the rest of the dashboard recedes.

Related patterns