Media
Media card
A thumbnail with a type badge, duration, title and meta, where the whole card is one link or button.
- Built on
<a><button><time>aspect-ratioline-clamp- Client JavaScript
- None
- Works without React
- Yes, as HTML and CSS
- Source, gzipped
- 2.7 kB
- Built from
- Icon
Code
import { MediaCard } from "@/components/equip/media-card";
function Frame() {
return (
<svg viewBox="0 0 160 90" aria-hidden="true">
<rect width="160" height="90" style={{ fill: "color-mix(in srgb, var(--eq-chart-2) 40%, var(--eq-raised))" }} />
<rect x="18" y="20" width="70" height="50" rx="4" style={{ fill: "var(--eq-raised)" }} />
<rect x="26" y="30" width="40" height="5" rx="2" style={{ fill: "var(--eq-chart-2)" }} />
<rect x="26" y="41" width="54" height="4" rx="2" style={{ fill: "var(--eq-tint-strong)" }} />
<rect x="26" y="50" width="46" height="4" rx="2" style={{ fill: "var(--eq-tint-strong)" }} />
<circle cx="118" cy="45" r="22" style={{ fill: "color-mix(in srgb, var(--eq-chart-1) 70%, var(--eq-raised))" }} />
</svg>
);
}
export default function Demo() {
return (
<MediaCard
href="#onboarding-walkthrough"
layout="row"
style={{ width: 216 }}
thumbnail={<Frame />}
duration={245}
title="Setting up SSO"
meta="Admin guide"
/>
);
}HTMLworks in any framework, with the part's CSS
<a href="#onboarding-walkthrough" style="width:216px" class="eq-media-card" data-layout="row">
<span class="eq-media-card-body">
<span class="eq-media-card-title">Setting up SSO</span>
<span class="eq-media-card-meta">Admin guide</span>
</span>
<span class="eq-media-card-thumb"><svg viewBox="0 0 160 90" aria-hidden="true"><rect width="160" height="90" style="fill:color-mix(in srgb, var(--eq-chart-2) 40%, var(--eq-raised))" /><rect x="18" y="20" width="70" height="50" rx="4" style="fill:var(--eq-raised)" /><rect x="26" y="30" width="40" height="5" rx="2" style="fill:var(--eq-chart-2)" /><rect x="26" y="41" width="54" height="4" rx="2" style="fill:var(--eq-tint-strong)" /><rect x="26" y="50" width="46" height="4" rx="2" style="fill:var(--eq-tint-strong)" /><circle cx="118" cy="45" r="22" style="fill:color-mix(in srgb, var(--eq-chart-1) 70%, var(--eq-raised))" /></svg><time class="eq-media-card-duration" dateTime="PT0H4M5S">4:05</time></span>
</a>Examples
A library grid
Each card is one link, so a keyboard user tabs once per item and hears the title first.
Code
import { MediaCard, type MediaKind } from "@/components/equip/media-card";
const items: { title: string; meta: string; kind: MediaKind; duration?: number; hue: string }[] = [
{ title: "Quarterly all-hands, October", meta: "Company · 1.2k views", kind: "video", duration: 3750, hue: "var(--eq-chart-1)" },
{ title: "Designing for slow networks", meta: "Field notes · Episode 41", kind: "podcast", duration: 2312, hue: "var(--eq-chart-3)" },
{ title: "Warehouse opening, Rotterdam", meta: "32 photos", kind: "album", hue: "var(--eq-chart-4)" },
{ title: "Product review", meta: "Streaming now · 84 watching", kind: "live", hue: "var(--eq-chart-5)" },
];
function Art({ hue, seed }: { hue: string; seed: number }) {
return (
<svg viewBox="0 0 160 90" aria-hidden="true">
<rect width="160" height="90" style={{ fill: `color-mix(in srgb, ${hue} 30%, var(--eq-raised))` }} />
<circle cx={40 + seed * 24} cy="38" r="16" style={{ fill: `color-mix(in srgb, ${hue} 75%, var(--eq-raised))` }} />
<path d={`M0 ${70 - seed * 4} Q50 ${48 + seed * 3} 90 64 T160 ${58 - seed * 2} V90 H0Z`} style={{ fill: `color-mix(in srgb, ${hue} 55%, var(--eq-ink))` }} />
</svg>
);
}
export default function Example() {
return (
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(170px, 1fr))", gap: 12 }}>
{items.map((item, i) => (
<MediaCard
key={item.title}
href={`#media-${i}`}
title={item.title}
meta={item.meta}
kind={item.kind}
duration={item.duration}
thumbnail={<Art hue={item.hue} seed={i} />}
/>
))}
</div>
);
}HTMLworks in any framework, with the part's CSS
<div style="display:grid;grid-template-columns:repeat(auto-fill, minmax(170px, 1fr));gap:12px">
<a href="#media-0" class="eq-media-card" data-layout="stacked">
<span class="eq-media-card-body">
<span class="eq-media-card-title">Quarterly all-hands, October</span>
<span class="eq-media-card-meta">Company · 1.2k views</span>
</span>
<span class="eq-media-card-thumb"><svg viewBox="0 0 160 90" aria-hidden="true"><rect width="160" height="90" style="fill:color-mix(in srgb, var(--eq-chart-1) 30%, var(--eq-raised))" /><circle cx="40" cy="38" r="16" style="fill:color-mix(in srgb, var(--eq-chart-1) 75%, var(--eq-raised))" /><path d="M0 70 Q50 48 90 64 T160 58 V90 H0Z" style="fill:color-mix(in srgb, var(--eq-chart-1) 55%, var(--eq-ink))" /></svg><span class="eq-media-card-kind" data-kind="video"><svg aria-hidden="true" focusable="false" class="eq-icon" data-size="14" style="--_size:14px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M5 6h8.5a2 2 0 0 1 2 2v8a2 2 0 0 1 -2 2h-8.5a2 2 0 0 1 -2 -2v-8a2 2 0 0 1 2 -2z" /><path d="M15.5 10.5l5.5-3v9l-5.5-3" /></svg>Video</span><time class="eq-media-card-duration" dateTime="PT1H2M30S">1:02:30</time></span>
</a>
<a href="#media-1" class="eq-media-card" data-layout="stacked">
<span class="eq-media-card-body">
<span class="eq-media-card-title">Designing for slow networks</span>
<span class="eq-media-card-meta">Field notes · Episode 41</span>
</span>
<span class="eq-media-card-thumb"><svg viewBox="0 0 160 90" aria-hidden="true"><rect width="160" height="90" style="fill:color-mix(in srgb, var(--eq-chart-3) 30%, var(--eq-raised))" /><circle cx="64" cy="38" r="16" style="fill:color-mix(in srgb, var(--eq-chart-3) 75%, var(--eq-raised))" /><path d="M0 66 Q50 51 90 64 T160 56 V90 H0Z" style="fill:color-mix(in srgb, var(--eq-chart-3) 55%, var(--eq-ink))" /></svg><span class="eq-media-card-kind" data-kind="podcast"><svg aria-hidden="true" focusable="false" class="eq-icon" data-size="14" style="--_size:14px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M4 16v-4a8 8 0 0 1 16 0v4" /><path d="M5.5 13.5h1a1.5 1.5 0 0 1 1.5 1.5v3.5a1.5 1.5 0 0 1 -1.5 1.5h-1a1.5 1.5 0 0 1 -1.5 -1.5v-3.5a1.5 1.5 0 0 1 1.5 -1.5z" /><path d="M17.5 13.5h1a1.5 1.5 0 0 1 1.5 1.5v3.5a1.5 1.5 0 0 1 -1.5 1.5h-1a1.5 1.5 0 0 1 -1.5 -1.5v-3.5a1.5 1.5 0 0 1 1.5 -1.5z" /></svg>Podcast</span><time class="eq-media-card-duration" dateTime="PT0H38M32S">38:32</time></span>
</a>
<a href="#media-2" class="eq-media-card" data-layout="stacked">
<span class="eq-media-card-body">
<span class="eq-media-card-title">Warehouse opening, Rotterdam</span>
<span class="eq-media-card-meta">32 photos</span>
</span>
<span class="eq-media-card-thumb"><svg viewBox="0 0 160 90" aria-hidden="true"><rect width="160" height="90" style="fill:color-mix(in srgb, var(--eq-chart-4) 30%, var(--eq-raised))" /><circle cx="88" cy="38" r="16" style="fill:color-mix(in srgb, var(--eq-chart-4) 75%, var(--eq-raised))" /><path d="M0 62 Q50 54 90 64 T160 54 V90 H0Z" style="fill:color-mix(in srgb, var(--eq-chart-4) 55%, var(--eq-ink))" /></svg><span class="eq-media-card-kind" data-kind="album"><svg aria-hidden="true" focusable="false" class="eq-icon" data-size="14" style="--_size:14px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3.5 21 8l-9 4.5L3 8z" /><path d="M3 12l9 4.5 9-4.5" /><path d="M3 16l9 4.5 9-4.5" /></svg>Album</span></span>
</a>
<a href="#media-3" class="eq-media-card" data-layout="stacked">
<span class="eq-media-card-body">
<span class="eq-media-card-title">Product review</span>
<span class="eq-media-card-meta">Streaming now · 84 watching</span>
</span>
<span class="eq-media-card-thumb"><svg viewBox="0 0 160 90" aria-hidden="true"><rect width="160" height="90" style="fill:color-mix(in srgb, var(--eq-chart-5) 30%, var(--eq-raised))" /><circle cx="112" cy="38" r="16" style="fill:color-mix(in srgb, var(--eq-chart-5) 75%, var(--eq-raised))" /><path d="M0 58 Q50 57 90 64 T160 52 V90 H0Z" style="fill:color-mix(in srgb, var(--eq-chart-5) 55%, var(--eq-ink))" /></svg><span class="eq-media-card-kind" data-kind="live"><svg aria-hidden="true" focusable="false" class="eq-icon" data-size="14" style="--_size:14px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 18 0a9 9 0 1 0 -18 0" /><path d="M8 12a4 4 0 1 0 8 0a4 4 0 1 0 -8 0" /></svg>Live</span></span>
</a>
</div>Continue watching
Without href the card is a button; progress draws a bar and adds the watched share to the accessible name.
Pick a lesson to resume.
Code
"use client";
import { useState } from "react";
import { MediaCard } from "@/components/equip/media-card";
const lessons = [
{ title: "Lesson 3: Reading a balance sheet", meta: "Finance basics", duration: 1124, progress: 0.62 },
{ title: "Lesson 4: Cash flow in practice", meta: "Finance basics", duration: 1460, progress: 0.08 },
];
function Slide({ n }: { n: number }) {
return (
<svg viewBox="0 0 160 90" aria-hidden="true">
<rect width="160" height="90" style={{ fill: "var(--eq-tint-strong)" }} />
{[0, 1, 2, 3].map((b) => (
<rect key={b} x={30 + b * 26} y={70 - (b + n) * 9} width="16" height={(b + n) * 9} style={{ fill: "var(--eq-chart-2)" }} />
))}
</svg>
);
}
export default function Example() {
const [opened, setOpened] = useState<string | null>(null);
return (
<div style={{ display: "grid", gap: 8, maxWidth: 360 }}>
{lessons.map((lesson, i) => (
<MediaCard
key={lesson.title}
layout="row"
kind="video"
thumbnail={<Slide n={i + 1} />}
onClick={() => setOpened(lesson.title)}
{...lesson}
/>
))}
<p style={{ margin: 0, fontSize: "var(--eq-text-s)", color: "var(--eq-ink-2)" }} aria-live="polite">
{opened ? `Resuming ${opened}` : "Pick a lesson to resume."}
</p>
</div>
);
}Square artwork
ratio takes any CSS aspect-ratio, so album covers and portrait posters keep their shape.
Code
import { MediaCard } from "@/components/equip/media-card";
const albums = [
{ title: "Night shift", meta: "Okonkwo Trio · 9 tracks", duration: 2410, hue: "var(--eq-chart-3)" },
{ title: "Low tide", meta: "Marta Lind · Single", duration: 214, hue: "var(--eq-chart-2)" },
];
export default function Example() {
return (
<div style={{ display: "flex", flexWrap: "wrap", gap: 12 }}>
{albums.map((album, i) => (
<MediaCard
key={album.title}
href={`#album-${i}`}
ratio="1"
kind="audio"
style={{ width: 150 }}
title={album.title}
meta={album.meta}
duration={album.duration}
thumbnail={
<svg viewBox="0 0 100 100" aria-hidden="true">
<rect width="100" height="100" style={{ fill: `color-mix(in srgb, ${album.hue} 35%, var(--eq-raised))` }} />
{[0, 1, 2, 3].map((r) => (
<circle key={r} cx="50" cy="50" r={12 + r * 10} style={{ fill: "none", stroke: album.hue, strokeWidth: 3 }} />
))}
</svg>
}
/>
))}
</div>
);
}HTMLworks in any framework, with the part's CSS
<div style="display:flex;flex-wrap:wrap;gap:12px">
<a href="#album-0" style="width:150px" class="eq-media-card" data-layout="stacked">
<span class="eq-media-card-body">
<span class="eq-media-card-title">Night shift</span>
<span class="eq-media-card-meta">Okonkwo Trio · 9 tracks</span>
</span>
<span class="eq-media-card-thumb" style="--_ratio:1"><svg viewBox="0 0 100 100" aria-hidden="true"><rect width="100" height="100" style="fill:color-mix(in srgb, var(--eq-chart-3) 35%, var(--eq-raised))" /><circle cx="50" cy="50" r="12" style="fill:none;stroke:var(--eq-chart-3);stroke-width:3" /><circle cx="50" cy="50" r="22" style="fill:none;stroke:var(--eq-chart-3);stroke-width:3" /><circle cx="50" cy="50" r="32" style="fill:none;stroke:var(--eq-chart-3);stroke-width:3" /><circle cx="50" cy="50" r="42" style="fill:none;stroke:var(--eq-chart-3);stroke-width:3" /></svg><span class="eq-media-card-kind" data-kind="audio"><svg aria-hidden="true" focusable="false" class="eq-icon" data-size="14" style="--_size:14px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M9 17.5V5.5l11-2v12" /><path d="M4 17.5a2.5 2.5 0 1 0 5 0a2.5 2.5 0 1 0 -5 0" /><path d="M15 15.5a2.5 2.5 0 1 0 5 0a2.5 2.5 0 1 0 -5 0" /></svg>Audio</span><time class="eq-media-card-duration" dateTime="PT0H40M10S">40:10</time></span>
</a>
<a href="#album-1" style="width:150px" class="eq-media-card" data-layout="stacked">
<span class="eq-media-card-body">
<span class="eq-media-card-title">Low tide</span>
<span class="eq-media-card-meta">Marta Lind · Single</span>
</span>
<span class="eq-media-card-thumb" style="--_ratio:1"><svg viewBox="0 0 100 100" aria-hidden="true"><rect width="100" height="100" style="fill:color-mix(in srgb, var(--eq-chart-2) 35%, var(--eq-raised))" /><circle cx="50" cy="50" r="12" style="fill:none;stroke:var(--eq-chart-2);stroke-width:3" /><circle cx="50" cy="50" r="22" style="fill:none;stroke:var(--eq-chart-2);stroke-width:3" /><circle cx="50" cy="50" r="32" style="fill:none;stroke:var(--eq-chart-2);stroke-width:3" /><circle cx="50" cy="50" r="42" style="fill:none;stroke:var(--eq-chart-2);stroke-width:3" /></svg><span class="eq-media-card-kind" data-kind="audio"><svg aria-hidden="true" focusable="false" class="eq-icon" data-size="14" style="--_size:14px" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M9 17.5V5.5l11-2v12" /><path d="M4 17.5a2.5 2.5 0 1 0 5 0a2.5 2.5 0 1 0 -5 0" /><path d="M15 15.5a2.5 2.5 0 1 0 5 0a2.5 2.5 0 1 0 -5 0" /></svg>Audio</span><time class="eq-media-card-duration" dateTime="PT0H3M34S">3:34</time></span>
</a>
</div>Props
| Prop | Type | Default | What it does |
|---|---|---|---|
title | ReactNode | none | The headline, clamped to two lines. |
meta | ReactNode | none | One quiet line under the title, such as channel and date. |
thumbnail | ReactNode | none | An img, svg or picture. It is cropped to fill the frame. |
kind | "video" | "audio" | "podcast" | "image" | "album" | "document" | "live" | none | Draws a badge with an icon and a word in the top corner. Live uses the danger fill. |
duration | number | none | Length in seconds, drawn as 4:05 or 1:02:30 in a time element. |
progress | number | none | How much has been watched, 0 to 1. Draws a bar on the frame and adds the share to the accessible name. |
ratio | string | "16 / 9" | Frame shape as a CSS aspect-ratio, such as "1" for album art. |
layout | "stacked" | "row" | "stacked" | Frame above the text, or beside it. |
href | string | none | Makes the card a link. Without it the card is a button. |
...rest | ComponentProps<"a"> | ComponentProps<"button"> | none | Every native attribute of the link or the button, including onClick, target and ref. |
Anatomy
| root | .eq-media-card | The a or the button; data-layout is stacked or row. |
|---|---|---|
| body | .eq-media-card-body | Title and meta. First in the source, drawn under or beside the frame. |
| title | .eq-media-card-title | |
| meta | .eq-media-card-meta | |
| thumb | .eq-media-card-thumb | The frame that crops the thumbnail. |
| kind | .eq-media-card-kind | Type badge, top left. |
| duration | .eq-media-card-duration | A time element, bottom right. |
| progress | .eq-media-card-progress | The watched bar along the bottom of the frame. |
| sr | .eq-media-card-sr | Visually hidden text read with the progress. |
Keyboard
| Enter | Follows the link, or presses the card when it is a button (Space too). |
|---|
Notes
- The whole card is a single link or button, so there is one tab stop per item and no nested interactive elements.
- The title comes first in the source, so the accessible name starts with it rather than with the badges.
- Give thumbnail images an empty alt; the title already names the card.
- Renders on the server with no client JavaScript.
- The type words and "watched" are English; copy the part and change them to translate.
Source
Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.
components/equip/media-card.tsx104 lines
import type { ComponentProps, CSSProperties, ReactNode } from "react";
import { Icon, type IconName } from "./icon";
import "./media-card.css";
export type MediaKind = "video" | "audio" | "podcast" | "image" | "album" | "document" | "live";
const kinds: Record<MediaKind, { icon: IconName; label: string }> = {
video: { icon: "video", label: "Video" },
audio: { icon: "music", label: "Audio" },
podcast: { icon: "headphones", label: "Podcast" },
image: { icon: "image", label: "Photo" },
album: { icon: "layers", label: "Album" },
document: { icon: "file-text", label: "Document" },
live: { icon: "record", label: "Live" },
};
type Shared = {
/** The headline. Clamped to two lines. */
title: ReactNode;
/** One quiet line under the title: channel, date, size. */
meta?: ReactNode;
/** An img, svg or picture, cropped to fill the frame. */
thumbnail: ReactNode;
/** Draws a small badge with an icon and a word, top left. */
kind?: MediaKind;
/** Length in seconds, drawn as 4:05 or 1:02:30, bottom right. */
duration?: number;
/** How much has been watched or heard, 0 to 1. Draws a thin bar along the bottom of the frame. */
progress?: number;
/** Frame shape as a CSS aspect-ratio. */
ratio?: string;
/** Stacked, or thumbnail beside the text. */
layout?: "stacked" | "row";
};
export type MediaCardProps =
| (Shared & Omit<ComponentProps<"a">, "title" | "children"> & { href: string })
| (Shared & Omit<ComponentProps<"button">, "title" | "children"> & { href?: undefined });
/** 245 gives "4:05", 3750 gives "1:02:30". */
function clock(seconds: number): string {
const total = Math.max(0, Math.round(seconds));
const h = Math.floor(total / 3600);
const m = Math.floor((total % 3600) / 60);
const s = String(total % 60).padStart(2, "0");
return h > 0 ? `${h}:${String(m).padStart(2, "0")}:${s}` : `${m}:${s}`;
}
/** The same length as an ISO 8601 duration, for the time element. */
function iso(seconds: number): string {
const total = Math.max(0, Math.round(seconds));
return `PT${Math.floor(total / 3600)}H${Math.floor((total % 3600) / 60)}M${total % 60}S`;
}
function Inner({ title, meta, thumbnail, kind, duration, progress, ratio }: Shared) {
const watched = progress === undefined ? undefined : Math.min(1, Math.max(0, progress));
return (
<>
{/* Text first, so the accessible name starts with the title rather than the badges. */}
<span className="eq-media-card-body">
<span className="eq-media-card-title">{title}</span>
{meta != null && meta !== false && <span className="eq-media-card-meta">{meta}</span>}
</span>
<span className="eq-media-card-thumb" style={ratio ? ({ "--_ratio": ratio } as CSSProperties) : undefined}>
{thumbnail}
{kind && (
<span className="eq-media-card-kind" data-kind={kind}>
<Icon name={kinds[kind].icon} size={14} />
{kinds[kind].label}
</span>
)}
{duration !== undefined && (
<time className="eq-media-card-duration" dateTime={iso(duration)}>
{clock(duration)}
</time>
)}
{watched !== undefined && (
<span className="eq-media-card-progress" style={{ "--_watched": watched } as CSSProperties}>
<span className="eq-media-card-sr">, {Math.round(watched * 100)}% watched</span>
</span>
)}
</span>
</>
);
}
export function MediaCard(props: MediaCardProps) {
const { title, meta, thumbnail, kind, duration, progress, ratio, layout = "stacked", className, ...native } = props;
const inner = { title, meta, thumbnail, kind, duration, progress, ratio };
const classes = ["eq-media-card", className].filter(Boolean).join(" ");
if (typeof native.href === "string") {
return (
<a {...(native as ComponentProps<"a">)} className={classes} data-layout={layout}>
<Inner {...inner} />
</a>
);
}
const { href: _unused, ...button } = native as ComponentProps<"button"> & { href?: undefined };
return (
<button type="button" {...button} className={classes} data-layout={layout}>
<Inner {...inner} />
</button>
);
}components/equip/media-card.css159 lines
@layer equip {
.eq-media-card {
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: var(--eq-space-2);
min-width: 0;
margin: 0;
padding: var(--eq-space-2);
border: 1px solid var(--eq-line);
border-radius: var(--eq-radius-l);
background: var(--eq-raised);
color: var(--eq-ink);
font: var(--eq-weight) var(--eq-text-m) / 1.35 var(--eq-font);
text-align: start;
text-decoration: none;
cursor: pointer;
transition:
background-color var(--eq-dur) var(--eq-ease),
border-color var(--eq-dur) var(--eq-ease),
scale var(--eq-dur) var(--eq-ease);
}
.eq-media-card[data-layout="row"] {
flex-direction: row;
align-items: center;
gap: var(--eq-space-3);
}
@media (hover: hover) {
.eq-media-card:hover {
border-color: var(--eq-line-strong);
background: color-mix(in srgb, var(--eq-raised), var(--eq-ink) 4%);
}
}
.eq-media-card:active:not(:disabled) {
scale: var(--eq-press);
}
.eq-media-card:focus-visible {
outline: 2px solid var(--eq-focus);
outline-offset: 2px;
}
.eq-media-card:disabled {
opacity: 0.45;
cursor: not-allowed;
}
/* The frame comes first on screen though the text comes first in the source. */
.eq-media-card-thumb {
position: relative;
order: -1;
display: block;
flex: none;
aspect-ratio: var(--_ratio, 16 / 9);
overflow: hidden;
border-radius: var(--eq-radius-m);
background: var(--eq-tint-strong);
}
.eq-media-card[data-layout="row"] .eq-media-card-thumb {
width: min(40%, 160px);
}
.eq-media-card-thumb > :is(img, svg, picture, video, canvas) {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.eq-media-card-body {
display: grid;
gap: 2px;
min-width: 0;
padding-inline: var(--eq-space-1);
}
.eq-media-card[data-layout="row"] .eq-media-card-body {
padding-inline: 0;
}
.eq-media-card-title {
display: -webkit-box;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
line-clamp: 2;
font-weight: var(--eq-weight-strong);
font-size: var(--eq-text-s);
line-height: 1.3;
}
.eq-media-card-meta {
overflow: hidden;
color: var(--eq-ink-2);
font-size: var(--eq-text-xs);
white-space: nowrap;
text-overflow: ellipsis;
}
/* Badges sit on any picture, so they carry their own solid ground. */
.eq-media-card-kind,
.eq-media-card-duration {
position: absolute;
box-sizing: border-box;
display: inline-flex;
align-items: center;
gap: 3px;
height: 20px;
padding-inline: 6px;
border-radius: var(--eq-radius-s);
background: color-mix(in srgb, var(--eq-solid) 84%, transparent);
color: var(--eq-solid-ink);
font: var(--eq-weight-strong) var(--eq-text-xs) / 1 var(--eq-font);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.eq-media-card-kind {
top: var(--eq-space-1);
left: var(--eq-space-1);
}
.eq-media-card-kind[data-kind="live"] {
background: var(--eq-danger);
color: var(--eq-solid-ink);
}
.eq-media-card-duration {
right: var(--eq-space-1);
bottom: var(--eq-space-1);
}
.eq-media-card-progress {
position: absolute;
inset: auto 0 0;
height: 3px;
background:
linear-gradient(to right, var(--eq-solid) calc(var(--_watched) * 100%), transparent 0),
color-mix(in srgb, var(--eq-solid-ink) 55%, transparent);
}
.eq-media-card-progress ~ .eq-media-card-duration,
.eq-media-card-duration:has(~ .eq-media-card-progress) {
bottom: calc(var(--eq-space-1) + 3px);
}
.eq-media-card-sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
}