a browse page: a billboard over shelves, the focused one on a focus line
npx shadcn add @ag/shelvesAlready installed? Preview an update
npx shadcn add @ag/shelves --dry-runYour installed source stays unchanged until you update it. How updates work
Latest changes · 2026-11-05
- New: a browse page.
<Shelves header={<Billboard … />} ambient="rgb(30 39 51)">overShelfs, filling the box its host gives it: one part holds the focus on a focus line under the host's bar. ↓ and ↑ (or a vertical wheel, a swipe, a click) move it: the page scrolls the next part up (slow to start, a long settle) while it opens at once and the part left fades away; the shelves below wait at 0.4 brightness. Leaving the billboard the page nudges, holds, then scrolls, and the billboard's colour (ambient) leaves the page at once.
the demo above, verbatim
"use client"
import { Info, Play, Search, ThumbsUp, UserRound } from "lucide-react"
import {
Art,
badgeOf,
CATEGORIES,
SHELVES,
TitleCaption,
TitleCard,
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 { CategoryTile } from "@/registry/base-nova/ui/category-tile"
import { MaterialProvider } from "@/registry/base-nova/ui/material"
import { TitleFacts } from "@/registry/base-nova/ui/media-spec"
import { Shelf } from "@/registry/base-nova/ui/shelf"
import { Shelves } from "@/registry/base-nova/ui/shelves"
// #region page
// A browse page: the billboard, a row of categories, then shelves of titles, under the
// host's own bar. With keys, ↓ and ↑ move between rows (the page scrolls the next one up
// as it opens, the one left fades), ← and → along one (the row slides under the frame).
// A click, a wheel or a swipe does the same.
const LEAD = title("salt-road")
const RANK = badgeOf(LEAD)
const LEAD_BADGES = [{ label: "New arrival" }, ...(RANK ? [RANK] : [])]
/** The host's bar: no ground of its own (what passes under it has faded), the
* profile at the content's edge, the sections centred, the current one in a pill. */
function BrowseBar() {
return (
<nav className="pointer-events-none absolute inset-x-0 top-0 flex h-[5.42cqw] items-center px-[3.542cqw] font-medium text-[1.27cqw] text-white">
<UserRound className="size-[2.4cqw]" />
<div className="absolute inset-x-0 flex items-center justify-center gap-[3.31cqw]">
<Search className="size-[1.38cqw]" />
<span className="flex h-[2.48cqw] items-center rounded-full bg-white/14 px-[1.38cqw]">
Home
</span>
<span>Series</span>
<span>Films</span>
<span>My list</span>
</div>
</nav>
)
}
function BrowsePage() {
return (
<MaterialProvider scheme="dark">
<div className="dark @container relative size-full bg-black text-white">
<Shelves
ambient="rgb(30 39 51)"
header={
<Billboard
label={LEAD.title}
art={<Art t={LEAD} sizes="100vw" />}
wordmark={<Wordmark t={LEAD} />}
facts={
<TitleFacts
facts={[
LEAD.kind,
LEAD.genre,
String(LEAD.year),
LEAD.length,
]}
age={LEAD.age}
/>
}
actions={
<>
<Button
size="xl"
corners="pill"
icon={<Play className="fill-current" />}
>
Play
</Button>
<Button
size="xl"
material="glass"
variant="secondary"
icon={<Info />}
>
More info
</Button>
</>
}
badges={LEAD_BADGES}
/>
}
>
<Shelf
label="Categories"
hideTitle
layout="tiles"
items={CATEGORIES}
itemKey={(c) => c.id}
itemLabel={(c) => c.label}
>
{(c, { open }) => (
<CategoryTile open={open}>{c.lockup}</CategoryTile>
)}
</Shelf>
{SHELVES.map((s, i) => (
<Shelf
key={s.label}
label={s.label}
title={
i === 1 ? (
<span className="inline-flex items-center gap-[0.5em]">
<ThumbsUp className="size-[1.1em] text-white/60" />
{s.label}
</span>
) : undefined
}
items={s.ids.map(title)}
itemKey={(t) => t.id}
itemLabel={(t) => t.title}
caption={(t) => <TitleCaption t={t} />}
>
{(t, { open }) => <TitleCard t={t} open={open} />}
</Shelf>
))}
</Shelves>
<BrowseBar />
</div>
</MaterialProvider>
)
}
// #endregion
export default function Demo() {
return (
<div className="space-y-12">
<Sample
name="browse"
glance="large"
with="page"
title="a browse page"
description="tab into it, then the arrows: down into the rows, along a row, back up to the billboard"
>
{/* A page sized by its box: 16:9 on the page, the window's rest in full view
(the frame's body is a column at least the window's height). */}
<div className="aspect-video overflow-hidden rounded-xl group-data-[full]/sample:aspect-auto group-data-[full]/sample:flex-1 group-data-[full]/sample:rounded-none">
<BrowsePage />
</div>
</Sample>
</div>
)
}the source, every file an install writes
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
import {
MEASURE,
MOTION,
type PageMotion,
pageAmbient,
pageAtRest,
pageLook,
pagePressed,
pageTops,
SHELF_PAGE_END,
} from "@/registry/base-nova/lib/shelf-motion"
import { ownedHere } from "@/registry/base-nova/ui/key-router"
import {
motionNow,
type ShelfSlot,
ShelfSlotContext,
useMotionClock,
useOwnedWheel,
} from "@/registry/base-nova/ui/shelf"
// A BROWSE PAGE: a header (the billboard) over shelves, exactly one part holding the
// focus, its top on the page's focus line under a host's bar. A step down scrolls the
// next part up to that line (slow to start, a long settle) while the part it reaches
// opens at once (its item grows into the card, it brightens, its caption shows) and the
// part it leaves fades away as it goes up; below the focus the shelves wait at 0.4
// brightness, posters only. Leaving the billboard the page nudges, holds, then scrolls,
// the billboard fading and its colour (`ambient`) leaving the page at once. Netflix's TV
// browse, measured frame by frame (`lib/shelf-motion`, the same numbers as swift-utils'
// `InkShelves`).
//
// The page never scrolls: it places every part by its span (shares of its width) and
// moves them all on one curve when the focus moves. ↓ and ↑ move the focus (a shelf
// takes it on the item it was on), a vertical wheel or a swipe by its travel, a click on
// a shelf takes it there. The page fills the box its host gives it.
const cqw = (share: number) => `${(share * 100).toFixed(4)}cqw`
export function Shelves({
header,
ambient,
children,
className,
}: {
/** What leads the page (a `Billboard`), the first part. */
header?: React.ReactNode
/** The header's art's colour, washing the page from the top while the header holds
* the focus, any CSS colour. */
ambient?: string
/** The shelves, in order (`Shelf`, each its own child). */
children: React.ReactNode
className?: string
}) {
const parts = React.Children.toArray(children)
const offset = header ? 1 : 0
const count = parts.length + offset
const nudge = header ? MOTION.headerNudge / MEASURE.billboardSpan : 0
const [motion, setMotion] = React.useState<PageMotion>(() => pageAtRest(0))
const [spans, setSpans] = React.useState<
readonly ({ rest: number; open: number } | undefined)[]
>([])
const now = useMotionClock(motion.start + SHELF_PAGE_END)
const root = React.useRef<HTMLDivElement>(null)
const headerBox = React.useRef<HTMLDivElement>(null)
const swiped = React.useRef(0)
const touch = React.useRef<number | null>(null)
const press = React.useCallback(
(index: number) => {
const next = Math.min(Math.max(index, 0), count - 1)
setMotion((m) =>
next === m.focus ? m : pagePressed(m, next, motionNow(), count, nudge),
)
},
[count, nudge],
)
const span = React.useCallback(
(index: number, rest: number, open: number) =>
setSpans((s) => {
const was = s[index]
if (was && was.rest === rest && Math.abs(was.open - open) < 1e-5)
return s
const next = [...s]
next[index] = { rest, open }
return next
}),
[],
)
// The header took the focus by the arrows or a click: its first action takes the
// keyboard, if the page has it.
const headerFocused = header !== undefined && motion.focus === 0
React.useEffect(() => {
const box = headerBox.current
if (!headerFocused || !box) return
if (!root.current?.contains(document.activeElement)) return
if (box.contains(document.activeElement)) return
box
.querySelector<HTMLElement>("button,a[href]")
?.focus({ preventScroll: true })
}, [headerFocused])
// Each part's span closed and open: the header's is fixed, a shelf's what it said.
const spanOf = (i: number) =>
i < offset
? { rest: MEASURE.billboardSpan, open: MEASURE.billboardSpan }
: (spans[i] ?? { rest: MEASURE.shelfPitch, open: MEASURE.shelfPitch })
const rest = Array.from({ length: count }, (_, i) => spanOf(i).rest)
const open = Array.from({ length: count }, (_, i) => spanOf(i).open)
const tops = pageTops(motion, now, nudge, rest, open)
const look = (i: number) => pageLook(motion, i, now)
const step = (dir: number) => press(motion.focus + dir)
// A vertical wheel steps the page while it has a part that way, by its travel; at
// the first or the last part it is the page's again.
useOwnedWheel(
root,
"y",
(dir) => (dir > 0 ? motion.focus < count - 1 : motion.focus > 0),
(along, el) => {
swiped.current += along
const threshold = el.clientWidth * MEASURE.shelfPitch * 0.5
if (Math.abs(swiped.current) > threshold) {
step(Math.sign(swiped.current))
swiped.current = 0
}
},
)
return (
// biome-ignore lint/a11y/noStaticElementInteractions: ↑ and ↓ move the focus between the parts it holds
<div
ref={root}
data-slot="shelves"
className={cn(
"@container relative size-full overflow-hidden bg-black",
className,
)}
onKeyDown={(e) => {
if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return
if (ownedHere(e.nativeEvent, e.currentTarget, ["button", "link"]))
return
e.preventDefault()
step(e.key === "ArrowDown" ? 1 : -1)
}}
onPointerDown={(e) => {
if (e.pointerType === "touch") touch.current = e.clientY
}}
onPointerUp={(e) => {
if (touch.current === null) return
const dy = touch.current - e.clientY
touch.current = null
const n = Math.round(
dy / (e.currentTarget.clientWidth * MEASURE.shelfPitch * 0.5),
)
if (n !== 0) step(Math.sign(n))
}}
>
{ambient && (
<div
aria-hidden
className="pointer-events-none absolute inset-0 bg-linear-to-b from-(--ag-shelves-ambient) to-black to-92% opacity-(--ag-shelves-a)"
style={
{
"--ag-shelves-ambient": ambient,
"--ag-shelves-a": header ? pageAmbient(motion, now) : 0,
} as React.CSSProperties
}
/>
)}
{header && (
// biome-ignore lint/a11y/noStaticElementInteractions: focus inside the header gives it the page's focus
<div
ref={headerBox}
data-slot="shelves-header"
className="absolute inset-x-0 top-0 translate-y-(--ag-shelves-y) px-(--ag-shelves-inset) opacity-(--ag-shelves-fade) brightness-(--ag-shelves-dim)"
style={
{
"--ag-shelves-y": cqw(tops[0] ?? 0),
"--ag-shelves-inset": cqw(MEASURE.inset),
"--ag-shelves-dim": look(0).dim,
"--ag-shelves-fade": look(0).fade,
} as React.CSSProperties
}
onFocus={() => press(0)}
>
{header}
</div>
)}
{parts.map((part, k) => {
const index = k + offset
const slot: ShelfSlot = {
index,
look: look(index),
focused: motion.focus === index,
press,
span,
}
return (
<div
key={React.isValidElement(part) && part.key !== null ? part.key : k}
className="absolute inset-x-0 top-0 translate-y-(--ag-shelves-y)"
style={
{ "--ag-shelves-y": cqw(tops[index] ?? 0) } as React.CSSProperties
}
>
<ShelfSlotContext value={slot}>{part}</ShelfSlotContext>
</div>
)
})}
</div>
)
}