the title a browse page leads with: key art, wordmark, facts, play and more
npx shadcn add @ag/billboardAlready installed? Preview an update
npx shadcn add @ag/billboard --dry-runYour installed source stays unchanged until you update it. How updates work
Latest changes · 2026-11-05
- New: the title a browse page leads with,
<Billboard art wordmark facts actions badges label />: key art in a rounded frame, the words on a shade from the left, ← and → between its actions, every length a share of its width as measured on a TV.
the demo above, verbatim
"use client"
import { Info, Play } from "lucide-react"
import { Art, badgeOf, title, Wordmark } from "@/app/browse-titles"
import { Sample } from "@/app/samples"
import { Billboard } from "@/registry/base-nova/ui/billboard"
import { Button } from "@/registry/base-nova/ui/button"
import { MaterialProvider } from "@/registry/base-nova/ui/material"
import { TitleFacts } from "@/registry/base-nova/ui/media-spec"
const LEAD = title("salt-road")
const RANK = badgeOf(LEAD)
export default function Demo() {
return (
<div className="space-y-12">
<Sample
name="lead"
glance="wide"
title="the title a page leads with"
description="← → between the actions"
>
<MaterialProvider scheme="dark">
<div className="dark bg-black p-4 text-white">
<Billboard
label={LEAD.title}
art={<Art t={LEAD} sizes="(max-width: 768px) 100vw, 900px" />}
wordmark={<Wordmark t={LEAD} />}
facts={
<TitleFacts
facts={[
LEAD.kind,
LEAD.genre,
String(LEAD.year),
LEAD.length,
]}
age={LEAD.age}
/>
}
actions={
<>
<Button
size="lg"
corners="pill"
icon={<Play className="fill-current" />}
>
Play
</Button>
<Button
size="lg"
material="glass"
variant="secondary"
icon={<Info />}
>
More info
</Button>
</>
}
badges={[{ label: "New arrival" }, ...(RANK ? [RANK] : [])]}
/>
</div>
</MaterialProvider>
</Sample>
</div>
)
}the source, every file an install writes
"use client"
import type * as React from "react"
import { cn } from "@/lib/utils"
import { Badge } from "@/registry/base-nova/ui/badge"
import { ownedHere } from "@/registry/base-nova/ui/key-router"
import { schemeOf } from "@/registry/base-nova/ui/material-glass"
// THE BILLBOARD: the one title a browse page leads with. Its key art fills a rounded
// frame edge to edge under a hairline; a shade from the left and the foot carries the
// words: the title as artwork, its facts (media-spec's `TitleFacts`), the actions
// (`button`s: play as the solid one, more as glass), and small badges at the lower
// right ("New", "No. 1 in films"). ← and → move between its actions. As `shelves`'
// header it stands on the page's focus line, and the page fades it away once the focus
// goes down into the rows.
//
// Measured on a TV's browse page (1011 by 461 px of 1087, its corner 10): the
// wordmark's foot 130 over the bottom, the facts' baseline 94.5, the actions 47 tall and
// 17 in, the badges 18 up and 17 in. Every length is a share of its width, so it reads
// the same on a TV and in a window.
/** A billboard's width over its height. */
export const BILLBOARD_ASPECT = 2.1909
export type BillboardBadge = {
icon?: React.ReactNode
label: React.ReactNode
}
export function Billboard({
art,
wordmark,
facts,
actions,
badges = [],
label,
className,
}: {
/** The key art, filling the frame (an `Image fill`). */
art: React.ReactNode
/** The title as artwork. */
wordmark: React.ReactNode
facts?: React.ReactNode
/** Its buttons, in order. */
actions?: React.ReactNode
badges?: readonly BillboardBadge[]
/** Its accessible name: the title in words. */
label: string
className?: string
}) {
return (
<section
data-slot="billboard"
aria-label={label}
className={cn(
// Words over a picture: it stands in the dark scheme whatever the page's.
schemeOf("dark"),
"@container relative aspect-(--billboard-aspect) w-full text-white",
className,
)}
style={{ "--billboard-aspect": BILLBOARD_ASPECT } as React.CSSProperties}
>
<div className="absolute inset-0 isolate overflow-hidden rounded-[0.99cqw] bg-neutral-950 ring-1 ring-white/50">
<div data-slot="billboard-art" className="absolute inset-0 -z-10">
{art}
</div>
<div
aria-hidden
className="absolute inset-0 -z-10 bg-linear-to-r from-black/55 to-transparent to-50%"
/>
<div
aria-hidden
className="absolute inset-0 -z-10 bg-linear-to-t from-black/45 to-transparent to-55%"
/>
<div
data-slot="billboard-wordmark"
className="absolute bottom-[12.87cqw] left-[1.684cqw] flex max-h-[5.067cqw] max-w-[27.39cqw] items-end text-[4.79cqw] leading-none"
>
{wordmark}
</div>
{facts && (
<div
data-slot="billboard-facts"
className="absolute bottom-[9.05cqw] left-[1.684cqw] text-[1.41cqw] leading-none"
>
{facts}
</div>
)}
{actions && (
// biome-ignore lint/a11y/noStaticElementInteractions: the arrows move focus between the buttons it holds
<div
data-slot="billboard-actions"
className="absolute bottom-[1.684cqw] left-[1.684cqw] flex h-[4.656cqw] items-center gap-[1.684cqw]"
onKeyDown={(e) => {
if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return
if (ownedHere(e.nativeEvent, e.currentTarget, ["button", "link"]))
return
const all = [
...e.currentTarget.querySelectorAll<HTMLElement>(
"button,a[href]",
),
]
const i = all.indexOf(document.activeElement as HTMLElement)
const next = all[i + (e.key === "ArrowRight" ? 1 : -1)]
if (i < 0 || !next) return
e.preventDefault()
next.focus()
}}
>
{actions}
</div>
)}
{badges.length > 0 && (
<div
data-slot="billboard-badges"
className="absolute right-[1.684cqw] bottom-[1.78cqw] hidden h-[2.77cqw] items-center gap-[0.776cqw] sm:flex"
>
{badges.map((b, i) => (
<Badge
// biome-ignore lint/suspicious/noArrayIndexKey: badges are a fixed list in their given order
key={i}
variant="secondary"
scheme="dark"
className="h-full gap-[0.4em] rounded-[0.3em] bg-black/62 px-[0.55em] font-medium font-sans text-[1.259cqw] text-white normal-case"
>
{b.icon}
{b.label}
</Badge>
))}
</div>
)}
</div>
</section>
)
}