Swish UI
All componentsCards and layout Free

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.

Found on