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

PropTypeDefaultWhat it does
titleReactNodenoneThe headline, clamped to two lines.
metaReactNodenoneOne quiet line under the title, such as channel and date.
thumbnailReactNodenoneAn img, svg or picture. It is cropped to fill the frame.
kind"video" | "audio" | "podcast" | "image" | "album" | "document" | "live"noneDraws a badge with an icon and a word in the top corner. Live uses the danger fill.
durationnumbernoneLength in seconds, drawn as 4:05 or 1:02:30 in a time element.
progressnumbernoneHow much has been watched, 0 to 1. Draws a bar on the frame and adds the share to the accessible name.
ratiostring"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.
hrefstringnoneMakes the card a link. Without it the card is a button.
...restComponentProps<"a"> | ComponentProps<"button">noneEvery native attribute of the link or the button, including onClick, target and ref.

Anatomy

root.eq-media-cardThe a or the button; data-layout is stacked or row.
body.eq-media-card-bodyTitle 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-thumbThe frame that crops the thumbnail.
kind.eq-media-card-kindType badge, top left.
duration.eq-media-card-durationA time element, bottom right.
progress.eq-media-card-progressThe watched bar along the bottom of the frame.
sr.eq-media-card-srVisually hidden text read with the progress.

Keyboard

EnterFollows 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;
  }
}

EqUIp is in beta: free for non-commercial use, no warranty, and things can change. Terms

Nothing picked yet. Press A on a part, or use the plus on the board, and the install command builds here.

Loading the index.

Loading the index