← ui

poster-card

ui

a title's two faces: a portrait poster and its wide card, wordmark, badge, progress

npx shadcn add @ag/poster-card
Already installed? Preview an update
npx shadcn add @ag/poster-card --dry-run

Your installed source stays unchanged until you update it. How updates work

Latest changes · 2026-11-05
  • New: a title's two faces, <PosterCard open poster art wordmark badge progress />: its portrait poster, or open its 16:9 card of the same height with the wordmark at the lower left and the badge at the lower right; a red media-line on a grey track along the foot of both says how far it was watched. It draws and moves nothing: a Shelf moves it and rings the open one, its picture and its words as two layers. height sizes it alone.
Updated 2026-11-05 · release notes

also called title card, poster

shelf media

uses badge, media-line, shelf

the demo above, verbatim

poster-card.demo.tsx
"use client"

import { Award } from "lucide-react"
import { Art, title, Wordmark } from "@/app/browse-titles"
import { Sample } from "@/app/samples"
import { PosterCard } from "@/registry/base-nova/ui/poster-card"

const STARTED = title("glass-valley")
const NEW = title("blue-hour")

export default function Demo() {
  return (
    <div className="space-y-12">
      <Sample
        name="faces"
        glance="wide"
        title="a title's two faces"
        description="its poster, and its card of the same height; a shelf moves between them and rings the open one"
      >
        <div className="dark flex items-start gap-2 bg-black p-4 text-white">
          <PosterCard
            open={false}
            height="12rem"
            poster={<Art t={STARTED} sizes="200px" />}
            wordmark={<Wordmark t={STARTED} />}
            progress={{ duration: 49 * 60_000, at: 12 * 60_000 }}
          />
          <PosterCard
            open
            height="12rem"
            poster={<Art t={STARTED} sizes="200px" />}
            art={<Art t={STARTED} sizes="400px" />}
            wordmark={<Wordmark t={STARTED} />}
            badge={{
              icon: <Award className="size-[1em]" />,
              label: "No. 4 in series",
            }}
            progress={{ duration: 49 * 60_000, at: 12 * 60_000 }}
          />
        </div>
      </Sample>
      <Sample
        name="not-started"
        title="a title not started"
        description="no line along its foot"
      >
        <div className="dark flex items-start gap-2 bg-black p-4 text-white">
          <PosterCard
            open={false}
            height="12rem"
            poster={<Art t={NEW} sizes="200px" />}
            wordmark={<Wordmark t={NEW} />}
          />
          <PosterCard
            open
            height="12rem"
            poster={<Art t={NEW} sizes="200px" />}
            art={<Art t={NEW} sizes="400px" />}
            wordmark={<Wordmark t={NEW} />}
          />
        </div>
      </Sample>
    </div>
  )
}

the source, every file an install writes

poster-card.tsx
"use client"

import type * as React from "react"
import { cn } from "@/lib/utils"
import { CARD_LAYER, MOTION } from "@/registry/base-nova/lib/shelf-motion"
import { Badge } from "@/registry/base-nova/ui/badge"
import { MediaLine } from "@/registry/base-nova/ui/media-line"

// A TITLE'S TWO FACES, as a browse row draws it: its portrait poster, and open its 16:9
// card of the same height, its own art with the title's wordmark at the lower left and a
// badge at the lower right. A started title wears its line along the foot of both, red
// to where it was left on an opaque grey track. It draws and moves nothing: `shelf`
// draws the posters in its row and the cards in its focus frame, moves them on the
// curves measured from the reference, and rings the open one. Open, it is two layers the
// shelf fades and moves apart while a step changes the title (`CARD_LAYER`): the picture
// (art, shade, line) and the words (wordmark, badge).
//
// Every length is a share of the face's height, as measured on a TV's browse row (the
// card 510.5 by 286.5 px of 1087, the wordmark 10 in and 14 up, the badge 10 in and
// 17.5 up, the line 7 tall), so the face is the same at any size. Its size is its box's
// (a shelf's), or `height` alone.

/** A poster's width over its height (175 by 286.5). */
export const POSTER_ASPECT = 0.6108
/** The open card's width over its height. */
export const OPEN_ASPECT = 16 / 9

export type PosterBadge = {
  /** A glyph before the words (a ranking's mark). */
  icon?: React.ReactNode
  label: React.ReactNode
}

export type PosterProgress = {
  /** The title's length, ms. */
  duration: number
  /** Where it was left, ms. */
  at: number
}

export function PosterCard({
  open,
  poster,
  art,
  wordmark,
  badge,
  progress,
  height,
  className,
}: {
  /** The face: the card (true) or the poster. */
  open: boolean
  /** The portrait picture, filling the poster (an `Image fill`). */
  poster: React.ReactNode
  /** The wide picture the card shows; the poster when absent. */
  art?: React.ReactNode
  /** The title as artwork: on the poster's foot, at the card's lower left. Sized in
   *  `em` of the font the face sets for it. */
  wordmark?: React.ReactNode
  /** A chip at the card's lower right ("No. 4 in films"). */
  badge?: PosterBadge
  /** Started: a line along the foot, red to where it was left. */
  progress?: PosterProgress
  /** Its height alone, outside a shelf (any CSS length); its width follows. */
  height?: string
  className?: string
}) {
  return (
    <div
      data-slot="poster-card"
      data-open={open || undefined}
      className={cn(
        "relative overflow-hidden bg-neutral-900 text-white [container-type:size]",
        height ? "h-(--poster-h) w-(--poster-w)" : "size-full",
        className,
      )}
      style={
        height
          ? ({
              "--poster-h": height,
              "--poster-w": `calc(${height} * ${open ? OPEN_ASPECT : POSTER_ASPECT})`,
            } as React.CSSProperties)
          : undefined
      }
    >
      {open ? (
        <>
          <div
            data-slot="poster-card-art"
            className={cn("absolute inset-0 bg-neutral-900", CARD_LAYER.art)}
          >
            {/* Wider than the card by the drift either side, so the picture still
                meets both edges while it settles. */}
            <div
              data-slot="poster-card-picture"
              className="absolute inset-y-0"
              style={{ left: `-${OVERSCAN}cqw`, right: `-${OVERSCAN}cqw` }}
            >
              {art ?? poster}
            </div>
            <Shade />
            <Line progress={progress} />
          </div>
          <div
            data-slot="poster-card-words"
            className={cn("absolute inset-0", CARD_LAYER.text)}
          >
            {wordmark && (
              <div
                data-slot="poster-card-wordmark"
                className="absolute bottom-[4.883cqh] left-[3.483cqh] flex max-h-[22.3cqh] max-w-[48cqh] items-end text-[9.085cqh] leading-none"
              >
                {wordmark}
              </div>
            )}
            {badge && (
              <Badge
                variant="secondary"
                scheme="dark"
                className="absolute right-[3.483cqh] bottom-[6.095cqh] h-auto gap-[0.4em] rounded-[0.3em] bg-black/62 px-[0.55em] py-[0.3em] font-medium font-sans text-[4.429cqh] text-white normal-case"
              >
                {badge.icon}
                {badge.label}
              </Badge>
            )}
          </div>
        </>
      ) : (
        <>
          <div data-slot="poster-card-picture" className="absolute inset-0">
            {poster}
          </div>
          <Shade />
          {wordmark && (
            <div
              data-slot="poster-card-wordmark"
              className="absolute inset-x-[6%] bottom-[8%] flex justify-center text-center text-[7.5cqh] leading-none"
            >
              {wordmark}
            </div>
          )}
          <Line progress={progress} />
        </>
      )}
    </div>
  )
}

/** How far past each side the card's picture reaches: the art's drift, in hundredths
 *  of the card's width. */
const OVERSCAN = (MOTION.artTravel * 100).toFixed(4)

/** The words' ground: a shade rising from the foot. */
function Shade() {
  return (
    <div
      aria-hidden
      className="pointer-events-none absolute inset-0 bg-linear-to-t from-black/55 to-transparent to-50%"
    />
  )
}

/** How much has been watched, along the foot. */
function Line({ progress }: { progress?: PosterProgress }) {
  if (!progress) return null
  return (
    <MediaLine
      duration={progress.duration}
      at={progress.at}
      className="absolute inset-x-0 bottom-0 h-[2.438cqh] bg-[#646467] text-[#e60712]"
    />
  )
}