← ui

shelves

ui

a browse page: a billboard over shelves, the focused one on a focus line

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

Your 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)"> over Shelfs, 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.
Updated 2026-11-05 · release notes

also called browse, lolomo

shelf media

uses shelf, key-router

the demo above, verbatim

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

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