All patterns

Tutorial Card Play

The tutorial thumbnail grows into a player, and the controls only arrive once the frame has stopped moving.

onboardingpremiumfriendlyinteraction · finite · advanced · ~0.6s
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.

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

/**
 * Vibary · Tutorial Card Play
 *
 * The thumbnail grows into the player, and the controls only arrive
 * once the frame has stopped moving — controls that ride the expansion
 * are unreadable and unclickable for the whole of it.
 *
 * The poster is drawn in the file (a gradient and a few rectangles), so
 * the pattern carries no asset and no real media element.
 *
 * Self-contained: depends only on `react` and `motion`. Works with zero
 * props; tune via `variant`, `title`, `durationSeconds`, `accent`,
 * `posterSrc`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type TutorialVideoPlayProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Title of the featured tutorial. */
  title?: string;
  /** Poster photograph; omit for the synthesized stand-in. */
  posterSrc?: string;
  /** One line about what it covers. */
  summary?: string;
  /** Length of the tutorial, in seconds. */
  durationSeconds?: number;
  /** Title of the second, unopened row. */
  nextTitle?: string;
  /** Play control and scrub color. */
  accent?: string;
  /** Fires when the player opens. */
  onOpen?: () => void;
  /** Fires when the player closes. */
  onClose?: () => void;
};

type VariantConfig = {
  expand: { type: "spring"; stiffness: number; damping: number };
  /** Pause after the frame settles before the controls appear. */
  controlDelay: number;
  /** How far the controls rise as they arrive, in px. */
  rise: number;
};

// Quality rule: the poster may scale, the interface may not. Every
// spring sits at or above a 0.8 damping ratio, so the frame arrives at
// its size once — a player that bounces is a player whose scrub bar
// cannot be hit — and the timecode is plain tabular text that never
// scales as it counts.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // Straight open, controls almost immediately. For a help drawer.
  subtle: {
    expand: { type: "spring", stiffness: 480, damping: 44 },
    controlDelay: 0.12,
    rise: 5,
  },
  // Frame settles, then the controls arrive. The all-purpose setting.
  default: {
    expand: { type: "spring", stiffness: 360, damping: 36 },
    controlDelay: 0.2,
    rise: 8,
  },
  // A weightier open with a longer wait, for a hero tutorial card.
  playful: {
    expand: { type: "spring", stiffness: 290, damping: 30 },
    controlDelay: 0.28,
    rise: 11,
  },
};

/** Neutral surfaces are mixed from the inherited text color, so the card
 *  reads correctly on a light page and on a dark one. */
const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

/** Thumbnail and player are cut to the same ratio (2.25× apart), so the
 *  shared frame scales uniformly and the poster inside it never
 *  stretches on the way up. */
const THUMB_WIDTH = 112;
const THUMB_HEIGHT = 63;
const PLAYER_WIDTH = 252;
const PLAYER_HEIGHT = 142;
const STAGE_HEIGHT = 280;

const clock = (seconds: number) => {
  const whole = Math.max(0, Math.floor(seconds));
  const minutes = Math.floor(whole / 60);
  return `${minutes}:${String(whole % 60).padStart(2, "0")}`;
};

export default function TutorialVideoPlay({
  variant = "default",
  title = "Your first three minutes",
  summary = "Documents, boards and the search bar.",
  durationSeconds = 192,
  nextTitle = "Sharing and permissions",
  accent = "#5B5BD6",
  posterSrc,
  onOpen,
  onClose,
}: TutorialVideoPlayProps) {
  const [open, setOpen] = useState(false);
  const [playing, setPlaying] = useState(false);
  const [elapsed, setElapsed] = useState(0);
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];

  // The playhead is a timer, not a media element: this pattern is about
  // the transition into the player, so nothing here loads or decodes.
  useEffect(() => {
    if (!playing || elapsed >= durationSeconds) return;
    const timer = setTimeout(() => setElapsed(elapsed + 1), 1000);
    return () => clearTimeout(timer);
  }, [playing, elapsed, durationSeconds]);

  const openPlayer = () => {
    setOpen(true);
    setPlaying(true);
    onOpen?.();
  };

  const closePlayer = () => {
    setOpen(false);
    setPlaying(false);
    setElapsed(0);
    onClose?.();
  };

  const morph = reduceMotion
    ? { duration: 0.2, ease: "easeOut" as const }
    : cfg.expand;

  return (
    <div
      style={{
        position: "relative",
        width: 320,
        height: STAGE_HEIGHT,
        padding: 18,
        borderRadius: 18,
        border: `1px solid ${tone(12)}`,
        background: tone(6),
        boxSizing: "border-box",
        overflow: "hidden",
      }}
    >
      <div style={{ fontSize: 15, fontWeight: 650 }}>Learn the basics</div>
      <p style={{ margin: "4px 0 14px", fontSize: 12, opacity: 0.55 }}>
        Three short tutorials. Nothing longer than four minutes.
      </p>

      <button
        type="button"
        onClick={openPlayer}
        style={{
          display: "flex",
          gap: 12,
          width: "100%",
          padding: 0,
          textAlign: "left",
          fontFamily: "inherit",
          color: "inherit",
          background: "transparent",
          border: "none",
          cursor: "pointer",
        }}
      >
        <motion.span
          layoutId={reduceMotion ? undefined : "tutorial-poster"}
          transition={morph}
          style={{
            position: "relative",
            flex: "none",
            display: "block",
            width: THUMB_WIDTH,
            height: THUMB_HEIGHT,
            borderRadius: 10,
            overflow: "hidden",
          }}
        >
          <Poster accent={accent} src={posterSrc} />
          <span
            aria-hidden
            style={{
              position: "absolute",
              inset: 0,
              display: "grid",
              placeItems: "center",
            }}
          >
            <PlayBadge size={26} accent={accent} />
          </span>
        </motion.span>

        <span style={{ minWidth: 0 }}>
          <span style={{ display: "block", fontSize: 13.5, fontWeight: 650 }}>
            {title}
          </span>
          <span
            style={{
              display: "block",
              marginTop: 3,
              fontSize: 11.5,
              lineHeight: 1.45,
              opacity: 0.55,
            }}
          >
            {summary}
          </span>
          <span
            style={{
              display: "block",
              marginTop: 5,
              fontSize: 11,
              fontVariantNumeric: "tabular-nums",
              opacity: 0.42,
            }}
          >
            {`${clock(durationSeconds)} · Beginner`}
          </span>
        </span>
      </button>

      <div
        aria-hidden
        style={{
          display: "flex",
          gap: 12,
          marginTop: 14,
          paddingTop: 14,
          borderTop: `1px solid ${tone(10)}`,
          opacity: 0.4,
        }}
      >
        <span
          style={{
            position: "relative",
            flex: "none",
            display: "block",
            width: THUMB_WIDTH,
            height: THUMB_HEIGHT,
            borderRadius: 10,
            overflow: "hidden",
          }}
        >
          <Poster accent={accent} src={posterSrc} muted />
        </span>
        <span style={{ minWidth: 0 }}>
          <span style={{ display: "block", fontSize: 13.5, fontWeight: 650 }}>
            {nextTitle}
          </span>
          <span
            style={{
              display: "block",
              marginTop: 5,
              fontSize: 11,
              fontVariantNumeric: "tabular-nums",
            }}
          >
            2:40 · Beginner
          </span>
        </span>
      </div>

      <AnimatePresence>
        {open && (
          <motion.div
            key="player"
            initial={{ opacity: 0 }}
            animate={{ opacity: 1 }}
            exit={{ opacity: 0 }}
            transition={{ duration: reduceMotion ? 0.16 : 0.2, ease: "easeOut" }}
            style={{
              position: "absolute",
              inset: 0,
              padding: 18,
              boxSizing: "border-box",
              // Opaque, because the list is still mounted underneath.
              // `Canvas`/`CanvasText` are the CSS system colors for page
              // background and text, so the player follows the host
              // app's color scheme.
              background: "Canvas",
              color: "CanvasText",
            }}
          >
            <motion.div
              layoutId={reduceMotion ? undefined : "tutorial-poster"}
              transition={morph}
              style={{
                position: "relative",
                width: PLAYER_WIDTH,
                height: PLAYER_HEIGHT,
                margin: "0 auto",
                borderRadius: 12,
                overflow: "hidden",
              }}
            >
              <Poster accent={accent} src={posterSrc} />

              {/* Controls arrive after the frame has stopped moving. A
                  scrub bar that is still travelling cannot be aimed at. */}
              <motion.div
                initial={
                  reduceMotion ? { opacity: 0 } : { opacity: 0, y: cfg.rise }
                }
                animate={{ opacity: 1, y: 0 }}
                transition={{
                  duration: 0.26,
                  delay: cfg.controlDelay,
                  ease: "easeOut",
                }}
                style={{
                  position: "absolute",
                  left: 0,
                  right: 0,
                  bottom: 0,
                  padding: "18px 10px 9px",
                  // A literal scrim: it sits on the poster, which stands
                  // in for footage, so it is not a themed surface.
                  background:
                    "linear-gradient(to top, rgba(8,8,14,0.72), rgba(8,8,14,0))",
                  color: "#ffffff",
                }}
              >
                <div
                  style={{
                    position: "relative",
                    height: 3,
                    borderRadius: 3,
                    background: "rgba(255,255,255,0.28)",
                    overflow: "hidden",
                  }}
                >
                  <motion.div
                    animate={{ scaleX: elapsed / durationSeconds }}
                    transition={{
                      duration: reduceMotion || !playing ? 0 : 1,
                      ease: "linear",
                    }}
                    style={{
                      position: "absolute",
                      inset: 0,
                      borderRadius: 3,
                      background: accent,
                      transformOrigin: "0% 50%",
                    }}
                  />
                </div>
                <div
                  style={{
                    display: "flex",
                    alignItems: "center",
                    gap: 9,
                    marginTop: 8,
                  }}
                >
                  <button
                    type="button"
                    onClick={() => setPlaying((value) => !value)}
                    aria-label={playing ? "Pause" : "Play"}
                    style={{
                      display: "grid",
                      placeItems: "center",
                      width: 22,
                      height: 22,
                      padding: 0,
                      color: "inherit",
                      background: "transparent",
                      border: "none",
                      cursor: "pointer",
                    }}
                  >
                    <svg width="12" height="12" viewBox="0 0 12 12" fill="none">
                      {playing ? (
                        <path
                          d="M3.4 1.6h2v8.8h-2zM6.6 1.6h2v8.8h-2z"
                          fill="currentColor"
                        />
                      ) : (
                        <path d="M2.8 1.5 10 6l-7.2 4.5z" fill="currentColor" />
                      )}
                    </svg>
                  </button>
                  <span
                    style={{
                      fontSize: 10.5,
                      fontVariantNumeric: "tabular-nums",
                      opacity: 0.85,
                    }}
                  >
                    {`${clock(elapsed)} / ${clock(durationSeconds)}`}
                  </span>
                  <span style={{ flex: 1 }} />
                  <span style={{ fontSize: 10.5, opacity: 0.7 }}>Captions</span>
                </div>
              </motion.div>
            </motion.div>

            <motion.div
              initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: cfg.rise }}
              animate={{ opacity: 1, y: 0 }}
              transition={{
                duration: 0.26,
                delay: cfg.controlDelay + 0.06,
                ease: "easeOut",
              }}
              style={{ width: PLAYER_WIDTH, margin: "12px auto 0" }}
            >
              <div style={{ fontSize: 14.5, fontWeight: 650 }}>{title}</div>
              <p
                style={{
                  margin: "4px 0 0",
                  fontSize: 12,
                  lineHeight: 1.5,
                  opacity: 0.6,
                }}
              >
                {summary}
              </p>
              <button
                type="button"
                onClick={closePlayer}
                style={{
                  marginTop: 12,
                  padding: "8px 13px",
                  fontSize: 12.5,
                  fontWeight: 600,
                  fontFamily: "inherit",
                  color: "inherit",
                  background: "transparent",
                  border: `1px solid ${tone(18)}`,
                  borderRadius: 9,
                  cursor: "pointer",
                }}
              >
                Back to the list
              </button>
            </motion.div>
          </motion.div>
        )}
      </AnimatePresence>
    </div>
  );
}

/** A synthesized poster frame: a wash and a few rectangles standing in
 *  for a screen recording. No image request, no media element. */
function Poster({
  accent,
  src,
  muted = false,
}: {
  accent: string;
  src?: string;
  muted?: boolean;
}) {
  if (src) {
    return (
      <span
        style={{
          position: "absolute",
          inset: 0,
          display: "block",
          background: "#12121A",
        }}
      >
        <img
          src={src}
          alt=""
          style={{
            width: "100%",
            height: "100%",
            objectFit: "cover",
            display: "block",
            opacity: muted ? 0.5 : 1,
          }}
        />
      </span>
    );
  }
  return (
    <span
      style={{
        position: "absolute",
        inset: 0,
        display: "block",
        background: `linear-gradient(140deg, color-mix(in srgb, ${accent} 34%, #12121A), #12121A)`,
      }}
    >
      <svg
        viewBox={`0 0 ${THUMB_WIDTH} ${THUMB_HEIGHT}`}
        preserveAspectRatio="none"
        width="100%"
        height="100%"
        aria-hidden
        style={{ display: "block", opacity: muted ? 0.5 : 1 }}
      >
        <rect
          x="14"
          y="12"
          width="84"
          height="40"
          rx="4"
          fill="#FFFFFF"
          opacity="0.1"
        />
        <rect
          x="14"
          y="12"
          width="84"
          height="7"
          rx="3.5"
          fill="#FFFFFF"
          opacity="0.14"
        />
        <circle cx="19" cy="15.5" r="1.4" fill="#FFFFFF" opacity="0.4" />
        <circle cx="24" cy="15.5" r="1.4" fill="#FFFFFF" opacity="0.28" />
        <rect
          x="18"
          y="23"
          width="20"
          height="25"
          rx="3"
          fill="#FFFFFF"
          opacity="0.13"
        />
        <rect
          x="42"
          y="23"
          width="52"
          height="4"
          rx="2"
          fill="#FFFFFF"
          opacity="0.22"
        />
        <rect
          x="42"
          y="30"
          width="38"
          height="4"
          rx="2"
          fill="#FFFFFF"
          opacity="0.14"
        />
        <rect x="42" y="38" width="26" height="10" rx="3" fill={accent} opacity="0.75" />
        <path
          d="m74 40 6.5 9.5-2.8.4 2 3.6-2 1.1-2-3.6-2 2z"
          fill="#FFFFFF"
          opacity="0.85"
        />
      </svg>
    </span>
  );
}

/** Play affordance on the poster — a filled disc with a triangle. */
function PlayBadge({ size, accent }: { size: number; accent: string }) {
  return (
    <span
      style={{
        display: "grid",
        placeItems: "center",
        width: size,
        height: size,
        borderRadius: 999,
        background: accent,
        boxShadow: "0 4px 12px rgba(0,0,0,0.32)",
      }}
    >
      <svg
        width={size * 0.4}
        height={size * 0.4}
        viewBox="0 0 12 12"
        fill="none"
        style={{ marginLeft: size * 0.05 }}
      >
        <path d="M2.8 1.5 10 6l-7.2 4.5z" fill="#ffffff" />
      </svg>
    </span>
  );
}

About this pattern

Opening a tutorial without the jolt of a modal. The thumbnail is a shared frame that grows into the player at a matched aspect ratio, so the poster scales uniformly instead of stretching, and the transport controls are deliberately late: a scrub bar that is still travelling cannot be aimed at, so it fades up only after the frame has landed. The poster is drawn in the file from a wash and a handful of rectangles and the playhead is a timer, which keeps the pattern about the transition rather than about media loading — swap in a real element and the choreography is unchanged. The timecode is plain tabular text that counts without ever scaling.

Tutorial libraryOnboarding flowHelp centre cardFeature walkthrough