a title's two faces: a portrait poster and its wide card, wordmark, badge, progress
npx shadcn add @ag/poster-cardAlready installed? Preview an update
npx shadcn add @ag/poster-card --dry-runYour 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 redmedia-lineon a grey track along the foot of both says how far it was watched. It draws and moves nothing: aShelfmoves it and rings the open one, its picture and its words as two layers.heightsizes it alone.
the demo above, verbatim
"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
"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]"
/>
)
}
