A YouTube-style responsive video card grid with thumbnail placeholders, duration chips, watched-progress underlines, and channel meta.
npx shadcn@latest add @paragon/video-grid"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>
);
}