Media

Video Grid

A YouTube-style responsive video card grid with thumbnail placeholders, duration chips, watched-progress underlines, and channel meta.

Install

npx shadcn@latest add @paragon/video-grid

video-grid.tsx

"use client";

import * as React from "react";
import { Play } from "lucide-react";
import { cn } from "@/lib/utils";

export interface VideoItem {
  id: string;
  title: string;
  channel: string;
  /** Duration overlay, e.g. "12:04". */
  duration: string;
  /** View count label, e.g. "1.2M views". */
  views: string;
  /** Upload age label, e.g. "3 days ago". */
  age: string;
  /** Two hex colors for the thumbnail gradient placeholder. */
  colors?: [string, string];
  /** Optional watched fraction 0–1 for the red progress underline. */
  progress?: number;
}

export interface VideoGridProps extends React.ComponentProps<"div"> {
  videos?: VideoItem[];
}

const DEFAULT_VIDEOS: VideoItem[] = [
  {
    id: "1",
    title: "Building a design system from scratch in Figma",
    channel: "Design Weekly",
    duration: "18:32",
    views: "412K views",
    age: "2 days ago",
    colors: ["#6366f1", "#8b5cf6"],
    progress: 0.6,
  },
  {
    id: "2",
    title: "React 19: everything you need to know",
    channel: "Frontend Focus",
    duration: "24:10",
    views: "1.2M views",
    age: "1 week ago",
    colors: ["#0ea5e9", "#22d3ee"],
  },
  {
    id: "3",
    title: "The physics of good UI animation",
    channel: "Motion Lab",
    duration: "11:47",
    views: "88K views",
    age: "4 days ago",
    colors: ["#f43f5e", "#fb7185"],
    progress: 0.25,
  },
  {
    id: "4",
    title: "Shipping faster with a component library",
    channel: "Paragon",
    duration: "09:03",
    views: "204K views",
    age: "3 weeks ago",
    colors: ["#10b981", "#34d399"],
  },
];

/**
 * A YouTube-style responsive video card grid: gradient thumbnail placeholder
 * with a duration chip and optional watched-progress underline, a play badge
 * that fades in on hover (pointer-fine only), and title/channel/meta below.
 * Cards are real links; the hover treatment is decorative.
 */
export function VideoGrid({
  videos = DEFAULT_VIDEOS,
  className,
  ...props
}: VideoGridProps) {
  return (
    <div
      data-slot="video-grid"
      className={cn(
        "grid w-full grid-cols-1 gap-4 @sm:grid-cols-2 @2xl:grid-cols-3",
        className,
      )}
      {...props}
    >
      {videos.map((v) => {
        const [from, to] = v.colors ?? ["#64748b", "#334155"];
        return (
          <a
            key={v.id}
            href="#"
            className="group flex flex-col gap-2.5 rounded-xl outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
          >
            <div className="relative aspect-video overflow-hidden rounded-xl">
              <div
                className="absolute inset-0"
                style={{
                  background: `linear-gradient(135deg, ${from}, ${to})`,
                }}
                aria-hidden
              />
              <div
                aria-hidden
                className="absolute inset-0 grid place-items-center bg-black/25 opacity-0 transition-opacity duration-200 ease-out group-hover:opacity-100 group-focus-visible:opacity-100"
              >
                <span className="grid size-11 place-items-center rounded-full bg-black/50 text-white backdrop-blur-sm">
                  <Play className="size-5 translate-x-px fill-current" />
                </span>
              </div>
              <span className="absolute right-1.5 bottom-1.5 rounded bg-black/80 px-1.5 py-0.5 font-mono text-[11px] font-medium tabular-nums text-white">
                {v.duration}
              </span>
              {v.progress != null && (
                <span
                  aria-hidden
                  className="absolute inset-x-0 bottom-0 h-1 bg-black/40"
                >
                  <span
                    className="block h-full bg-destructive"
                    style={{ width: `${Math.round(v.progress * 100)}%` }}
                  />
                </span>
              )}
            </div>
            <div className="flex flex-col gap-0.5">
              <h3 className="line-clamp-2 text-sm font-semibold leading-snug text-foreground">
                {v.title}
              </h3>
              <p className="text-xs text-muted-foreground">{v.channel}</p>
              <p className="text-xs tabular-nums text-muted-foreground">
                {v.views} · {v.age}
              </p>
            </div>
          </a>
        );
      })}
    </div>
  );
}