Media card play expand
Video cards whose round play button widens into a Play 2:14 pill as the thumbnail scales, on hover, focus or tap.
It imports two small hooks from @/lib/motion and the colour tokens; the zip includes both.
Demo.tsx
"use client";
import { useEffect, useRef, useState } from "react";
import styles from "./demo.module.css";
const VIDEOS = [
{ title: "Plan a sprint in five minutes", tag: "Getting started", length: "2:14", tone: "teal" },
{ title: "Automate your release notes", tag: "Workflows", length: "3:40", tone: "sky" },
{ title: "Run a calm incident review", tag: "Teams", length: "1:58", tone: "sand" },
];
const PLAY_FOR = 3200; // the short playing state, in milliseconds
export default function Demo() {
const [armed, setArmed] = useState<number | null>(null); // touch: first tap opens the pill
const [playing, setPlaying] = useState<number | null>(null);
const pointer = useRef("mouse");
const stopTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
useEffect(() => () => clearTimeout(stopTimer.current), []);
const activate = (i: number) => {
if (pointer.current === "touch" && armed !== i && playing !== i) {
setArmed(i);
return;
}
clearTimeout(stopTimer.current);
if (playing === i) { setPlaying(null); return; } // a second press stops it
setPlaying(i);
setArmed(null);
stopTimer.current = setTimeout(() => setPlaying(null), PLAY_FOR);
};
return (
<div className={styles.root}>
<ul className={styles.grid}>
{VIDEOS.map((v, i) => {
const on = playing === i;
return (
<li key={v.title} className={styles.item}>
<button
type="button"
className={styles.card}
data-tone={v.tone}
data-armed={armed === i || undefined}
data-playing={on || undefined}
onPointerDown={(e) => { pointer.current = e.pointerType; }}
onClick={() => activate(i)}
onBlur={() => armed === i && setArmed(null)}
aria-label={on ? `Pause ${v.title}` : `Play ${v.title}, ${v.length}`}
>
<span className={styles.thumb} aria-hidden="true">
<span className={styles.art}>
<span className={styles.shapeA} />
<span className={styles.shapeB} />
<span className={styles.lines}><i /><i /><i /></span>
</span>
<span className={styles.play}>
<svg viewBox="0 0 16 16">
{on ? <path d="M4.5 3.5h2.5v9H4.5zM9 3.5h2.5v9H9z" /> : <path d="M5 3.2v9.6L12.8 8z" />}
</svg>
<span className={styles.pillText}>{on ? "Playing" : `Play ${v.length}`}</span>
</span>
{on && <span className={styles.bar} />}
</span>
<span className={styles.meta}>
<span className={styles.tag}>{v.tag}</span>
<span className={styles.title}>{v.title}</span>
</span>
</button>
</li>
);
})}
</ul>
</div>
);
}
demo.module.css
.root {
container-type: size; display: grid; place-items: center; width: 100%; height: 100%; padding: 16px;
box-sizing: border-box; background: var(--surface);
}
.grid { width: min(880px, 100%); display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; margin: 0; padding: 0; list-style: none; }
.item { min-width: 0; }
.card {
display: grid; gap: 10px; width: 100%; padding: 0; border: 0; background: none; color: inherit; font: inherit; text-align: left;
cursor: pointer; border-radius: 14px; -webkit-tap-highlight-color: transparent;
}
.card:focus-visible { outline: 2px solid var(--accent-text); outline-offset: 4px; }
/* The thumbnail: a drawn gradient frame whose art scales a touch on hover. */
.thumb { position: relative; display: block; aspect-ratio: 16 / 10; border-radius: 14px; overflow: hidden; background: var(--ink); isolation: isolate; }
.art {
position: absolute; inset: 0; display: block; transition: transform .6s var(--out);
background: linear-gradient(135deg, var(--accent-text), color-mix(in srgb, var(--accent) 80%, var(--sky)));
}
.card[data-tone="sky"] .art { background: linear-gradient(135deg, var(--sky), var(--accent-soft)); }
.card[data-tone="sand"] .art { background: linear-gradient(135deg, var(--sand), color-mix(in srgb, var(--sand) 55%, var(--accent-soft))); }
.shapeA, .shapeB { position: absolute; display: block; border-radius: 50%; }
.shapeA { width: 58%; aspect-ratio: 1; right: -12%; top: -24%; background: color-mix(in srgb, var(--white) 22%, transparent); }
.shapeB { width: 26%; aspect-ratio: 1; right: 18%; bottom: -10%; background: color-mix(in srgb, var(--ink) 12%, transparent); }
.lines { position: absolute; left: 9%; top: 14%; width: 46%; display: grid; gap: 6%; }
.lines i { display: block; height: 7px; border-radius: 4px; background: color-mix(in srgb, var(--white) 70%, transparent); }
.lines i:nth-child(2) { width: 75%; opacity: .7; }
.lines i:nth-child(3) { width: 50%; opacity: .5; }
.card[data-tone="sky"] .lines i, .card[data-tone="sand"] .lines i { background: color-mix(in srgb, var(--ink) 30%, transparent); }
/* Round play button that widens into a pill: the label's max-width opens, padding follows. */
.play {
position: absolute; z-index: 2; left: 10px; bottom: 10px; display: inline-flex; align-items: center; height: 38px; min-width: 38px; padding: 0 11px;
box-sizing: border-box; border-radius: 999px; background: var(--white); color: var(--ink);
box-shadow: 0 8px 20px -10px color-mix(in srgb, var(--ink) 60%, transparent);
transition: padding .45s var(--out), background-color .3s, color .3s;
}
.play svg { flex: none; width: 16px; height: 16px; fill: currentColor; }
.pillText {
max-width: 0; overflow: hidden; white-space: nowrap; font-size: 13px; font-weight: 650; font-variant-numeric: tabular-nums;
opacity: 0; transition: max-width .45s var(--out), opacity .25s, margin .45s var(--out);
}
@media (hover: hover) {
.card:hover .art { transform: scale(1.06); }
.card:hover .play { padding: 0 14px 0 12px; }
.card:hover .pillText { max-width: 90px; opacity: 1; margin-left: 7px; }
}
.card:focus-visible .art, .card[data-armed] .art, .card[data-playing] .art { transform: scale(1.06); }
.card:focus-visible .play, .card[data-armed] .play, .card[data-playing] .play { padding: 0 14px 0 12px; }
.card:focus-visible .pillText, .card[data-armed] .pillText, .card[data-playing] .pillText { max-width: 90px; opacity: 1; margin-left: 7px; }
.card[data-playing] .play { background: var(--ink); color: var(--white); }
/* Playing: a scrim and a progress line for the short preview. */
.card[data-playing] .thumb::after { content: ""; position: absolute; inset: 0; background: color-mix(in srgb, var(--ink) 18%, transparent); pointer-events: none; }
.bar { position: absolute; left: 0; right: 0; bottom: 0; height: 3px; background: var(--white); transform-origin: left; animation: fill 3.2s linear both; z-index: 1; }
.card[data-tone="sky"] .bar, .card[data-tone="sand"] .bar { background: var(--accent-text); }
@keyframes fill { from { scale: 0 1; } }
/* While playing, the art drifts a little so the frame reads as video. */
.card[data-playing] .shapeA { animation: drift 3.2s var(--out) both; }
.card[data-playing] .shapeB { animation: drift 3.2s var(--out) reverse both; }
@keyframes drift { to { translate: -14% 10%; } }
.meta { display: grid; gap: 2px; padding: 0 2px; min-width: 0; }
.tag { font-size: 11px; font-weight: 650; color: var(--accent-text); letter-spacing: .02em; }
.title { font-size: 14px; font-weight: 650; line-height: 1.3; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
@container (max-width: 600px) {
.grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
.item:nth-child(3) { display: none; }
}
@container (max-height: 300px) {
.grid { width: min(560px, 100%); }
.title { font-size: 13px; -webkit-line-clamp: 1; }
.card { gap: 8px; }
}
@container (max-width: 380px) {
.play { left: 8px; bottom: 8px; height: 34px; min-width: 34px; padding: 0 9px; }
.pillText { font-size: 12px; }
.title { font-size: 13px; }
}
@media (prefers-reduced-motion: reduce) {
.art, .play, .pillText { transition: none; }
.card .art, .card:hover .art { transform: none; }
.bar { animation: none; scale: .5 1; }
.card[data-playing] .shapeA, .card[data-playing] .shapeB { animation: none; }
}
Use it for
- Tutorial, webinar or customer story grids
- Feature pages that link short product videos
- Any card where the duration helps people decide to click
Avoid
- Autoplaying video, where the preview state already shows motion
- Long lists of videos, where hover scaling becomes noisy
How it works
Each card is a button; hover, keyboard focus or an armed flag scales the thumbnail art and opens the label inside the play button by transitioning its max-width, opacity and padding, so the circle widens into a pill. On touch the first tap sets the armed flag and the second plays, tracked by the pointerType of the last pointerdown. Playing swaps the icon to pause, darkens the frame and runs a CSS progress line for a few seconds.
Accessibility
Cards are real buttons named with the title and length, and change to Pause while playing. Keyboard focus shows the same expanded pill as hover. Reduced motion keeps the pill and playing states but drops the scaling and transitions.