Tutorial Card Play
The tutorial thumbnail grows into a player, and the controls only arrive once the frame has stopped moving.
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.
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.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Media player
A recording thumbnail opens into a player in place, with controls settling in after.
Related patterns
- Name Your WorkspaceTyping a name rolls the monogram to its new initial and slides a fresh address under the header it will appear in.
- Persona SelectPicking a role lifts that card while the others step back, and a line below says what the answer changed.
- Tour Step HopThe tour tooltip travels to the next control instead of vanishing and popping up somewhere else.
