← ui

billboard

ui

the title a browse page leads with: key art, wordmark, facts, play and more

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

Your 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.
Updated 2026-11-05 · release notes

also called hero, key art

shelf media

uses badge, key-router, material

the demo above, verbatim

billboard.demo.tsx
"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

billboard.tsx
"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>
  )
}