← ui

shelf

ui

a titled row of items sliding under a focus frame that never moves

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

Your installed source stays unchanged until you update it. How updates work

Latest changes · 2026-11-05
  • New: a titled row of any items with a focus frame at its leading edge that never moves, as a TV's browse row, every duration, curve and distance measured frame by frame from a recording of Netflix's. <Shelf items itemKey itemLabel label caption={(item) => …}>{(item, { open, index }) => …}</Shelf>: you draw an item's two faces (its poster, its card) and what is said under the open one; a step slides the posters one place under the frame, the leaving title's words go at once and its art fades while the next one's art fades in drifting from the side the posters come from, its wordmark and badge faster and its caption farther (from the right going forward, from the left going back: the forward step mirrored, as measured), and the title left behind slides out past the frame's edge as the card it was. A card's drawing puts CARD_LAYER.art and CARD_LAYER.text on its two layers so the step can move them apart (PosterCard and CategoryTile do). ← and → step, Enter or a click on the frame activates (onActivate), a click on a poster steps to it, a sideways wheel or a swipe steps by its travel; layout="tiles" for a row of tiles, href makes the frame a link, onOpenChange reports the open item. The curves and the page's measures are lib/shelf-motion (MOTION, MEASURE), the same numbers as swift-utils' Ink.
Updated 2026-11-05 · release notes

also called row, rail, slider row

shelf media

uses key-router

used by shelves, poster-card, category-tile

the demo above, verbatim

shelf.demo.tsx
"use client"

import { TitleCaption, TitleCard, title } from "@/app/browse-titles"
import { Sample } from "@/app/samples"
import { cn } from "@/lib/utils"
import { Shelf } from "@/registry/base-nova/ui/shelf"

const PICKS = [
  "quiet-hours",
  "blue-hour",
  "iron-orchard",
  "field-notes",
  "last-ferry",
  "paper-moons",
  "north-line",
]

// #region swatches
// Anything rides a shelf: here a swatch that only says which face it is.
const SWATCHES = [
  "#f97316",
  "#eab308",
  "#22c55e",
  "#06b6d4",
  "#6366f1",
  "#d946ef",
  "#f43f5e",
  "#a3a3a3",
]
// #endregion

export default function Demo() {
  return (
    <div className="space-y-12">
      <Sample
        name="titles"
        glance="wide"
        title="a shelf of titles"
        description="click it, then ← →: the posters slide one place under the frame, the card and its caption change in it"
      >
        <div className="dark @container -mx-4 h-[34cqw] bg-black text-white">
          <Shelf
            label="We think you will love these"
            items={PICKS.map(title)}
            itemKey={(t) => t.id}
            itemLabel={(t) => t.title}
            caption={(t) => <TitleCaption t={t} />}
          >
            {(t, { open }) => <TitleCard t={t} open={open} />}
          </Shelf>
        </div>
      </Sample>
      <Sample
        name="anything"
        with="swatches"
        title="anything on a shelf"
        description="the shelf owns the press and the motion; the item only draws its two faces"
      >
        <div className="@container -mx-4">
          <Shelf
            label="Swatches"
            layout="tiles"
            items={SWATCHES}
            itemKey={(c) => c}
            itemLabel={(c) => c}
            onActivate={(c) => navigator.clipboard?.writeText(c)}
          >
            {(c, { open }) => (
              <div
                className={cn("size-full", open && "saturate-150")}
                style={{ background: c }}
              />
            )}
          </Shelf>
        </div>
      </Sample>
    </div>
  )
}

the source, every file an install writes

shelf.tsx
"use client"

import * as React from "react"
import { cn } from "@/lib/utils"
import {
  type CardLayers,
  MEASURE,
  openSpan,
  passedLight,
  posterHeight,
  SHELF_STRIP_END,
  type ShelfLayout,
  type ShelfLook,
  type StripMotion,
  stripAtRest,
  stripFaces,
  stripItems,
  stripMeasures,
  stripPosition,
  stripPressed,
  stripSide,
} from "@/registry/base-nova/lib/shelf-motion"
import { ownedHere } from "@/registry/base-nova/ui/key-router"

// A SHELF: a titled row of any items with a focus frame at its leading edge that never
// moves. A step along the row slides the posters one place under the frame; the leaving
// title's words go at once and its art fades while the next one's art fades in a beat
// later drifting from the side the posters come from, its wordmark and badge faster and
// its caption farther (the right going forward, the left going back: the step back is
// the forward one mirrored, as measured). An item passing under the frame's edges fades
// by how much of it the frame covers; those passed keep the card's face cut to a
// poster's width, so the title left behind slides out as the card it was and dims once
// it has arrived. A card's layers read the shelf's `--ag-card-*` variables (`poster-card`
// and `category-tile` do, `cardLayer` gives any drawing the same). Netflix's TV
// browse row, measured frame by frame (`lib/shelf-motion`, the same numbers as
// swift-utils' `InkShelf`); not a carousel, nothing moves by itself.
//
// The shelf owns the press and the items are drawings: the render function draws an
// item's two faces, `open` false for its poster in the row and true for its card in the
// frame (`poster-card` draws both, `category-tile` lights). The frame is the shelf's one
// focus target, a button (or a link, `href`) named after the open item, so the keyboard
// is always on the open item and its ring is the focus mark (CLAUDE.md, "a control that
// shows focus differently"). ← and → step, Enter or a click on the frame activates, a
// click on a poster steps to it, a sideways wheel or a swipe steps by its travel.
//
// Inside `shelves` (the browse page) one shelf holds the page's focus: it is open, the
// others wait dimmed with posters only. Alone, it is open while it holds the focus. It
// remembers its open item when the focus leaves. Every length is a share of the page's
// width (`cqw` of the shelf, as wide as its page), so it is the same row at any size.

/** What `shelves` tells each shelf: where it stands and how it looks now, and how to
 *  take the page's focus or say its span. Absent, a shelf is alone. */
export type ShelfSlot = {
  index: number
  look: ShelfLook
  focused: boolean
  press: (index: number) => void
  /** The shelf's spans to the next part, closed and open, as shares of the page's
   *  width: the open one is the room its caption needs. */
  span: (index: number, rest: number, open: number) => void
}

export const ShelfSlotContext = React.createContext<ShelfSlot | null>(null)

/** Seconds, the clock every motion reads; Playwright's clock drives it in a still. */
export function motionNow() {
  return performance.now() / 1000
}

/** The clock while a motion runs: a frame at a time until `until` (seconds), then
 *  still. Reduced motion is the motion's end at once. */
export function useMotionClock(until: number): number {
  const [now, setNow] = React.useState(() =>
    typeof performance === "undefined" ? 0 : motionNow(),
  )
  const [reduced, setReduced] = React.useState(false)
  React.useEffect(() => {
    const q = window.matchMedia("(prefers-reduced-motion: reduce)")
    setReduced(q.matches)
    const on = () => setReduced(q.matches)
    q.addEventListener("change", on)
    return () => q.removeEventListener("change", on)
  }, [])
  React.useEffect(() => {
    let frame = 0
    const tick = () => {
      const t = motionNow()
      setNow(t)
      if (t < until) frame = requestAnimationFrame(tick)
    }
    frame = requestAnimationFrame(tick)
    return () => cancelAnimationFrame(frame)
  }, [until])
  return reduced ? Number.POSITIVE_INFINITY : now
}

/** A wheel gesture is one while its events come closer than this: a pause longer
 *  than a trackpad's momentum ever leaves between two of them begins another. */
const WHEEL_GESTURE_GAP_MS = 120

/** A wheel along `axis` that steps a part, taken from the page only while the part
 *  can still move that way (`can`): at its end the wheel is the page's, as a nested
 *  scroll's is (`overscroll-behavior`). A gesture stays with whoever took it, as the
 *  browser latches a scroll, so one reaching the end mid-flight never starts the page
 *  scrolling under it. Native and not passive: React's `onWheel` is passive and
 *  cannot keep the page still. `travel` gets the wheel's distance along the axis. */
export function useOwnedWheel(
  ref: React.RefObject<HTMLElement | null>,
  axis: "x" | "y",
  can: (dir: 1 | -1) => boolean,
  travel: (along: number, el: HTMLElement) => void,
) {
  const latest = React.useRef({ can, travel })
  latest.current = { can, travel }
  React.useEffect(() => {
    const el = ref.current
    if (!el) return
    let last = Number.NEGATIVE_INFINITY
    let ours = false
    const wheel = (e: WheelEvent) => {
      const along = axis === "y" ? e.deltaY : e.deltaX
      const across = axis === "y" ? e.deltaX : e.deltaY
      if (Math.abs(along) <= Math.abs(across)) return
      const now = performance.now()
      if (now - last > WHEEL_GESTURE_GAP_MS)
        ours = latest.current.can(along > 0 ? 1 : -1)
      last = now
      if (!ours) return
      e.preventDefault()
      latest.current.travel(along, el)
    }
    el.addEventListener("wheel", wheel, { passive: false })
    return () => el.removeEventListener("wheel", wheel)
  }, [ref, axis])
}

/** A share of the page's width as a length. */
const cqw = (share: number) => `${(share * 100).toFixed(4)}cqw`

/** A card's layers as the variables its drawing reads (`CARD_LAYER`): opacities, and
 *  shifts in hundredths of the card's own width, resolved inside the card. */
function cardLayerVars(l: CardLayers, openness: number): React.CSSProperties {
  return {
    "--ag-card-art-o": openness * l.artOpacity,
    "--ag-card-art-x": (l.artShift * 100).toFixed(4),
    "--ag-card-text-o": openness * l.textOpacity,
    "--ag-card-text-x": (l.textShift * 100).toFixed(4),
  } as React.CSSProperties
}

export type ShelfItemState = {
  /** The face to draw: the card in the frame, or the poster in the row. */
  open: boolean
  index: number
}

export type ShelfProps<T> = {
  items: readonly T[]
  /** Each item's stable key. */
  itemKey: (item: T) => string
  /** Each item's accessible name (its title). */
  itemLabel: (item: T) => string
  /** The shelf's title, said over the row and as its accessible name. */
  label: string
  /** Hide the title (a row of category tiles has none); it stays the row's name. */
  hideTitle?: boolean
  /** What the title shows, when it is more than words (an icon before them). */
  title?: React.ReactNode
  /** Portrait posters that open into a 16:9 card, or category tiles. */
  layout?: ShelfLayout
  /** Draws one item's face. */
  children: (item: T, state: ShelfItemState) => React.ReactNode
  /** Said under the open item while the shelf is open (`poster-caption`). */
  caption?: (item: T) => React.ReactNode
  /** A press on the open item: Enter, a click on the frame. */
  onActivate?: (item: T, index: number) => void
  /** Makes the frame a link to the open item. */
  href?: (item: T) => string
  /** The open item changed, for a page showing its details. */
  onOpenChange?: (item: T, index: number) => void
  /** The item the shelf starts on. */
  defaultIndex?: number
  className?: string
}

/** A titled row of items, a frame at its leading edge the row slides under. */
export function Shelf<T>({
  items,
  itemKey,
  itemLabel,
  label,
  hideTitle = false,
  title,
  layout = "posters",
  children,
  caption,
  onActivate,
  href,
  onOpenChange,
  defaultIndex = 0,
  className,
}: ShelfProps<T>) {
  if (items.length === 0) throw new Error(`shelf "${label}": no items`)
  if (defaultIndex < 0 || defaultIndex >= items.length)
    throw new Error(`shelf "${label}": no item at ${defaultIndex}`)
  const key = React.useId()
  const slot = React.useContext(ShelfSlotContext)
  const [strip, setStrip] = React.useState<StripMotion>(() =>
    stripAtRest(defaultIndex),
  )
  const [alone, setAlone] = React.useState(false)
  const now = useMotionClock(strip.start + SHELF_STRIP_END)
  const frame = React.useRef<HTMLElement>(null)
  const captionBox = React.useRef<HTMLDivElement>(null)
  const swiped = React.useRef(0)
  const touch = React.useRef<number | null>(null)

  const look: ShelfLook = slot
    ? slot.look
    : { dim: 1, fade: 1, openness: alone ? 1 : 0 }
  const active = slot ? slot.focused : alone
  const clamp = (i: number) => Math.min(Math.max(i, 0), items.length - 1)
  const open = clamp(strip.open)

  const report = React.useEffectEvent((index: number) => {
    const item = items[index]
    if (item !== undefined) onOpenChange?.(item, index)
  })
  React.useEffect(() => report(open), [open])

  // The page chose this shelf (an arrow from another, a click): the frame takes the
  // keyboard, if the page has it.
  const focused = slot?.focused ?? false
  React.useEffect(() => {
    const el = frame.current
    if (!focused || !el) return
    const page = el.closest("[data-slot=shelves]")
    if (page?.contains(document.activeElement) && document.activeElement !== el)
      el.focus({ preventScroll: true })
  }, [focused])

  // The shelf's spans, told to the page: the open one from its caption's room, read
  // as a share of the width.
  const span = slot?.span
  const index = slot?.index ?? 0
  React.useEffect(() => {
    if (!span) return
    const rest = layout === "tiles" ? MEASURE.tilesSpan : MEASURE.shelfPitch
    const box = captionBox.current
    const section = box?.closest<HTMLElement>("[data-slot=shelf]")
    if (!box || !section) {
      span(index, rest, rest)
      return
    }
    const measure = () => {
      const w = section.clientWidth
      if (w > 0) span(index, rest, openSpan(layout, box.offsetHeight / w))
    }
    measure()
    const observer = new ResizeObserver(measure)
    observer.observe(box)
    observer.observe(section)
    return () => observer.disconnect()
  }, [span, index, layout])

  const go = (next: number) => {
    const to = clamp(next)
    if (to === strip.open) return
    setStrip((s) => stripPressed(s, to, motionNow()))
  }

  // A sideways wheel steps the row while it has a title that way, by its travel.
  const row = React.useRef<HTMLElement>(null)
  useOwnedWheel(
    row,
    "x",
    (dir) => clamp(strip.open + dir) !== strip.open,
    (along, el) => {
      swiped.current += along
      const threshold = el.clientWidth * stripMeasures(layout).rest * 0.5
      if (Math.abs(swiped.current) > threshold) {
        go(strip.open + Math.sign(swiped.current))
        swiped.current = 0
      }
    },
  )

  const onKeyDown = (e: React.KeyboardEvent<HTMLElement>) => {
    if (ownedHere(e.nativeEvent, e.currentTarget, ["button", "link"])) return
    if (e.key === "ArrowRight" || e.key === "ArrowLeft") {
      e.preventDefault()
      go(strip.open + (e.key === "ArrowRight" ? 1 : -1))
    } else if (e.key === "Home" || e.key === "End") {
      e.preventDefault()
      go(e.key === "Home" ? 0 : items.length - 1)
    }
  }

  const m = stripMeasures(layout)
  const s = stripPosition(strip, now)
  const placed = stripItems(layout, items.length, s, look.openness)
  const elapsed = now - strip.start
  const changed = strip.previous !== null && strip.previous !== open
  const faces = stripFaces(elapsed, stripSide(strip), m.wide)
  const width = m.rest + (m.wide - m.rest) * look.openness
  const previous =
    changed && (faces.leaving.artOpacity > 0 || faces.leaving.textOpacity > 0)
      ? strip.previous
      : null
  // Only a title that replaced another arrives; the first open card stands where it
  // is.
  const arriving: CardLayers = changed
    ? faces.arriving
    : { artOpacity: 1, artShift: 0, textOpacity: 1, textShift: 0 }
  const itemsTop = hideTitle ? MEASURE.tilesTop : MEASURE.itemsTop
  const titled = !hideTitle
  const Tag = href ? "a" : "button"
  const openItem = items[open] as T

  return (
    <section
      ref={row}
      data-slot="shelf"
      data-shelf-key={key}
      data-state={active ? "open" : "rest"}
      aria-labelledby={`${key}-title`}
      className={cn(
        "@container relative w-full brightness-(--ag-shelf-dim) opacity-(--ag-shelf-fade)",
        className,
      )}
      style={
        {
          "--ag-shelf-dim": look.dim,
          "--ag-shelf-fade": look.fade,
          height: cqw(
            layout === "tiles" ? MEASURE.tilesSpan : MEASURE.shelfPitch,
          ),
        } as React.CSSProperties
      }
      onPointerDown={(e) => {
        if (e.pointerType === "touch") touch.current = e.clientX
      }}
      onPointerUp={(e) => {
        if (touch.current === null) return
        const dx = touch.current - e.clientX
        touch.current = null
        const step = e.currentTarget.clientWidth * m.pitch
        const n = Math.round(dx / step)
        if (n !== 0) go(strip.open + n)
      }}
    >
      {/* The title on its line, the line's top the shelf's top. */}
      <h3
        id={`${key}-title`}
        className={cn(
          "absolute top-0 left-(--ag-shelf-inset) h-(--ag-shelf-title-line) font-semibold text-(length:--ag-shelf-title) text-white leading-(--ag-shelf-title-line)",
          !titled && "sr-only",
        )}
        style={
          {
            "--ag-shelf-inset": cqw(MEASURE.inset),
            "--ag-shelf-title": cqw(MEASURE.titleSize),
            "--ag-shelf-title-line": cqw(0.01865),
          } as React.CSSProperties
        }
      >
        {title ?? label}
      </h3>
      <div
        className="absolute inset-x-0 top-(--ag-shelf-items) h-(--ag-shelf-h)"
        style={
          {
            "--ag-shelf-items": cqw(itemsTop),
            "--ag-shelf-h": cqw(m.height),
          } as React.CSSProperties
        }
      >
        {placed.map(({ index: i, x, alpha, passed, card }) => {
          const item = items[i] as T
          return (
            <button
              key={itemKey(item)}
              type="button"
              tabIndex={-1}
              aria-label={itemLabel(item)}
              data-slot="shelf-item"
              className="absolute top-0 left-0 h-(--ag-shelf-h) w-(--ag-shelf-w) translate-x-(--ag-shelf-x) cursor-pointer overflow-hidden rounded-(--ag-shelf-r) text-left opacity-(--ag-shelf-a) outline-none brightness-(--ag-shelf-passed)"
              style={
                {
                  "--ag-shelf-x": cqw(x),
                  "--ag-shelf-w": cqw(m.rest),
                  "--ag-shelf-r": cqw(m.radius),
                  "--ag-shelf-a": alpha,
                  "--ag-shelf-passed": passed
                    ? passedLight(strip, i, elapsed)
                    : 1,
                } as React.CSSProperties
              }
              onClick={() => {
                if (slot && !slot.focused) slot.press(slot.index)
                go(i)
                frame.current?.focus({ preventScroll: true })
              }}
            >
              {card ? (
                // The card's face, cut to a poster's width from its leading edge.
                <div
                  className="absolute inset-y-0 left-0 w-(--ag-shelf-cw)"
                  style={
                    { "--ag-shelf-cw": cqw(m.wide) } as React.CSSProperties
                  }
                >
                  {children(item, { open: true, index: i })}
                </div>
              ) : (
                children(item, { open: false, index: i })
              )}
            </button>
          )
        })}
        <Tag
          ref={frame as React.Ref<HTMLButtonElement & HTMLAnchorElement>}
          {...(href ? { href: href(openItem) } : { type: "button" as const })}
          aria-label={itemLabel(openItem)}
          aria-current={active || undefined}
          aria-roledescription={`${label}: ${open + 1} of ${items.length}`}
          data-slot="shelf-frame"
          data-open={active || undefined}
          className="absolute top-0 left-(--ag-shelf-inset) block h-(--ag-shelf-h) w-(--ag-shelf-fw) cursor-pointer overflow-hidden rounded-(--ag-shelf-r) bg-black text-left outline-none"
          style={
            {
              "--ag-shelf-inset": cqw(MEASURE.inset),
              "--ag-shelf-fw": cqw(width),
              "--ag-shelf-r": cqw(m.radius),
              "--ag-shelf-cw": cqw(m.wide),
              "--ag-shelf-pw": cqw(m.rest),
            } as React.CSSProperties
          }
          onKeyDown={onKeyDown}
          onFocus={() => {
            if (slot && !slot.focused) slot.press(slot.index)
            if (!slot) setAlone(true)
          }}
          onBlur={() => {
            if (!slot) setAlone(false)
          }}
          onClick={(e) => {
            if (!active) {
              e.preventDefault()
              if (slot) slot.press(slot.index)
              else setAlone(true)
              return
            }
            onActivate?.(openItem, open)
          }}
        >
          {look.openness < 1 && (
            <div
              className="absolute inset-y-0 left-0 w-(--ag-shelf-pw) opacity-(--ag-shelf-pa)"
              style={
                { "--ag-shelf-pa": 1 - look.openness } as React.CSSProperties
              }
            >
              {children(openItem, { open: false, index: open })}
            </div>
          )}
          {look.openness > 0 && previous !== null && (
            <div
              className="absolute inset-y-0 left-0 w-(--ag-shelf-cw)"
              style={cardLayerVars(faces.leaving, look.openness)}
            >
              {children(items[previous] as T, { open: true, index: previous })}
            </div>
          )}
          {look.openness > 0 && (
            <div
              className="absolute inset-y-0 left-0 w-(--ag-shelf-cw)"
              style={cardLayerVars(arriving, look.openness)}
            >
              {children(openItem, { open: true, index: open })}
            </div>
          )}
          {/* The ring, over the card: the focus. */}
          <span
            aria-hidden
            className="pointer-events-none absolute inset-0 rounded-[inherit] border-(length:--ag-shelf-ring) border-white/75 opacity-(--ag-shelf-o)"
            style={
              {
                "--ag-shelf-ring": `max(1px, ${cqw(MEASURE.ring)})`,
                "--ag-shelf-o": look.openness,
              } as React.CSSProperties
            }
          />
        </Tag>
      </div>
      {caption && (
        <div
          aria-hidden={!active}
          className="pointer-events-none absolute top-(--ag-shelf-caption) left-(--ag-shelf-inset) w-(--ag-shelf-caption-w)"
          style={
            {
              "--ag-shelf-inset": cqw(MEASURE.inset),
              "--ag-shelf-caption": cqw(
                itemsTop + posterHeight + MEASURE.captionTop,
              ),
              "--ag-shelf-caption-w": cqw(MEASURE.card * 1.08),
            } as React.CSSProperties
          }
        >
          {strip.previous !== null && changed && faces.captionOut > 0 && (
            <div
              className="absolute inset-x-0 top-0 opacity-(--ag-shelf-co)"
              style={
                {
                  "--ag-shelf-co": look.openness * faces.captionOut,
                } as React.CSSProperties
              }
            >
              {caption(items[strip.previous] as T)}
            </div>
          )}
          <div
            ref={captionBox}
            className="translate-x-(--ag-shelf-cx) opacity-(--ag-shelf-ci)"
            style={
              {
                "--ag-shelf-ci":
                  look.openness * (changed ? faces.captionIn : 1),
                "--ag-shelf-cx": cqw(changed ? faces.caption : 0),
              } as React.CSSProperties
            }
          >
            {caption(openItem)}
          </div>
        </div>
      )}
    </section>
  )
}
shelf-motion.ts
// THE BROWSE PAGE'S MOTION AND MEASURES, framework-free: every number here was
// measured frame by frame from a screen recording of Netflix's 2025 TV browse (the
// picture 1087 px wide; the pixels are in the comments), and swift-utils' Ink runs the
// same numbers (`InkShelfMotion`, `InkShelfMetrics`). Lengths are shares of the page's
// width; times are seconds after the press. The motion is a function of time: given a
// press and an instant, every position and opacity is known, so any instant can be
// drawn and nothing is tuned by eye.

/** A cubic Bézier (CSS's `cubic-bezier`) run for `duration` seconds, `delay` after the
 *  press. */
export type ShelfCurve = {
  x1: number
  y1: number
  x2: number
  y2: number
  duration: number
  delay: number
}

function curve(
  x1: number,
  y1: number,
  x2: number,
  y2: number,
  duration: number,
  delay = 0,
): ShelfCurve {
  if (!(x1 >= 0 && x1 <= 1 && x2 >= 0 && x2 <= 1))
    throw new Error(`shelf-motion: x outside 0..1 (${x1}, ${x2})`)
  if (!(duration > 0))
    throw new Error(`shelf-motion: a duration of ${duration}`)
  return { x1, y1, x2, y2, duration, delay }
}

/** When a curve has finished, after the press. */
export function curveEnd(c: ShelfCurve): number {
  return c.delay + c.duration
}

/** How far along `c` is `elapsed` seconds after the press: 0 before its delay, 1 from
 *  its end on. */
export function progress(c: ShelfCurve, elapsed: number): number {
  const u = (elapsed - c.delay) / c.duration
  if (!(u > 0)) return 0
  if (u >= 1) return 1
  const cx = 3 * c.x1
  const bx = 3 * (c.x2 - c.x1) - cx
  const ax = 1 - cx - bx
  const cy = 3 * c.y1
  const by = 3 * (c.y2 - c.y1) - cy
  const ay = 1 - cy - by
  const x = (s: number) => ((ax * s + bx) * s + cx) * s
  // Newton's steps, then halving where the slope is too flat to trust (x1 = 0).
  let s = u
  for (let i = 0; i < 8; i++) {
    const err = x(s) - u
    if (Math.abs(err) < 1e-7) return ((ay * s + by) * s + cy) * s
    const d = (3 * ax * s + 2 * bx) * s + cx
    if (Math.abs(d) < 1e-6) break
    s -= err / d
  }
  let lo = 0
  let hi = 1
  s = u
  for (let i = 0; i < 40; i++) {
    const v = x(s)
    if (Math.abs(v - u) < 1e-7) break
    if (v < u) lo = s
    else hi = s
    s = (lo + hi) / 2
  }
  return ((ay * s + by) * s + cy) * s
}

/** The same curve as a CSS easing. */
export function cssEasing(c: ShelfCurve): string {
  return `cubic-bezier(${c.x1},${c.y1},${c.x2},${c.y2})`
}

/** The page's motion. A step along a shelf was fitted on twelve, ten forward and one
 *  back across three recordings at three sizes, each layer cropped and tracked on its
 *  own, the press placed per step by the slide; the rest on the step from the
 *  billboard to the tiles, from the tiles to the first shelf, from one shelf to the
 *  next. Going back is the step forward mirrored, and the measures say so: the forward
 *  curves read against the step back leave under a pixel on the art and the caption.
 *  Whatever arrives comes from the side the posters come from, the right going forward
 *  and the left going back. Distances along a shelf are shares of the open card's
 *  width, the slide's of the pitch. */
export const MOTION = {
  /** The posters sliding one place under the open card, which does not move (the
   *  pitch, 186 to 324 px; within 4.8% of it at every frame of the twelve steps). */
  slide: curve(0.1497, 0.3492, 0.69, 1, 0.3818),
  /** The leaving title's art fading out, and the next one's fading in over it a beat
   *  later: the card dips (to 0.29 of a still's brightness between two of 0.55 and
   *  0.36) and is the next one's by 0.22 s. */
  artOut: curve(1, 1, 0.0723, 0.5825, 0.2211, 0.0102),
  artIn: curve(0.55, 0.0628, 0.3613, 0.7825, 0.192, 0.0285),
  /** The next title's art (its line with it) arriving `artTravel` of the card's width
   *  off its place and settling, slowing, while the leaving art holds still. */
  artDrift: curve(0.6154, 0.9371, 0.4684, 0.7716, 0.4267),
  artTravel: 0.0189,
  /** The leaving title's words (wordmark, badge) and its caption: gone at the first
   *  frame after the press. */
  textOut: curve(0, 0, 1, 1, 0.0108),
  /** The next title's wordmark and badge, one layer: fading in faster than its art
   *  while it arrives `textTravel` of the card's width off its place. */
  textIn: curve(0.5757, 0.3213, 0.5583, 1, 0.2478),
  textSlide: curve(0.2014, 0.6923, 0.323, 1, 0.3236),
  textTravel: 0.0265,
  /** The next caption fading in while it arrives `captionTravel` of the card's width
   *  off its place, farther and slower than the wordmark. */
  captionIn: curve(0.5976, 0.9225, 0.7698, 0.9316, 0.348),
  captionSlide: curve(0.3852, 1, 0.7054, 0.9172, 0.339),
  captionTravel: 0.0441,
  /** The title a step forward leaves keeps its card's face as it slides out past the
   *  frame's leading edge, and dims to a passed poster's brightness only once it has
   *  nearly arrived; going back, the title returning from there is lit at once. */
  passDim: curve(0, 0, 1, 1, 0.17, 0.21),
  /** The page bringing a shelf to the focus line: slow to start, a long settle (403
   *  px of travel; 2.4 px from the fit). */
  scroll: curve(0.2383, 0.0746, 0, 0.9986, 0.8408),
  /** The shelf the focus arrives on growing its item into the card, brightening,
   *  showing its caption: caught once, 0.878 done 0.046 s after the page starts. */
  arrive: curve(0, 0.2227, 0.701, 1, 0.063),
  /** What the focus leaves going down fading away as the page carries it up, evenly
   *  over all of it. */
  depart: curve(0.3414, 0.4866, 0.1655, 0.7683, 0.4748),
  /** Leaving the billboard the page nudges up (34 px) as fast as a shelf arrives,
   *  holds, then scrolls the rest from `headerHold` on. */
  headerNudge: 0.0313,
  headerHold: 0.136,
} as const

const STRIP_END = Math.max(
  ...[
    MOTION.slide,
    MOTION.artOut,
    MOTION.artIn,
    MOTION.artDrift,
    MOTION.textOut,
    MOTION.textIn,
    MOTION.textSlide,
    MOTION.captionIn,
    MOTION.captionSlide,
    MOTION.passDim,
  ].map(curveEnd),
)
const PAGE_END = Math.max(
  curveEnd(MOTION.scroll) + MOTION.headerHold,
  curveEnd(MOTION.depart),
  curveEnd(MOTION.arrive),
)

/** The page's measures as shares of its width (a TV's, 16:9). */
export const MEASURE = {
  /** The content's leading edge, and the billboard's trailing one (38.5). */
  inset: 0.03542,
  /** Between two items (11). */
  gap: 0.01012,
  /** The open card (510.5); a poster has its height (286.5) and is 175 wide. */
  card: 0.46964,
  posterAspect: 0.6108,
  /** A category tile (163 by 82), its gap (13) and its corner (9). */
  tile: 0.14995,
  tileAspect: 0.5031,
  tileGap: 0.01196,
  tileRadius: 0.00828,
  /** A card's and a poster's corner (4). */
  radius: 0.0037,
  /** The ring: about a point and a half of white, drawn as two at three quarters. */
  ring: 0.00104,
  /** The line of a started title (7): red #e60712 on a track #646467. */
  line: 0.00644,
  /** Where the focus stands: the top of the focused shelf's title line and of the
   *  billboard (62.5). */
  top: 0.0575,
  /** From a shelf's top to its items (29.5); tiles stand 12 below theirs. */
  itemsTop: 0.02714,
  tilesTop: 0.01104,
  /** From a shelf's top to the next one's, its caption closed (403.5), and a row of
   *  tiles' (108.8). */
  shelfPitch: 0.3712,
  tilesSpan: 0.1001,
  /** The billboard (1011 by 461, its corner 10) and its span to the tiles (475.8). */
  billboardHeight: 0.4241,
  billboardRadius: 0.0092,
  billboardSpan: 0.4377,
  /** The type, by cap height: a shelf's title (12.5), the caption's lead line and
   *  facts (11), its quiet line (10), the synopsis (11.5, a line every 19.5), a badge
   *  (9). */
  titleSize: 0.0163,
  captionSize: 0.0138,
  eyebrowSize: 0.0126,
  synopsisSize: 0.0145,
  synopsisLine: 0.01794,
  badgeSize: 0.0117,
  /** The caption: its first line box under the items' foot, a line box, the gap to
   *  the synopsis, and from its foot to the next shelf's top (a three-line synopsis
   *  pushes the next shelf down; the room is the caption's whole, so a shorter one
   *  leaves it where it is). */
  captionTop: 0.0124,
  captionLine: 0.0193,
  synopsisGap: 0.0065,
  captionFoot: 0.0151,
  /** How bright a shelf below the focus is (titles 101 of 254, posters 0.39), and a
   *  poster passed, left of the open card. */
  restingDim: 0.4,
  passedDim: 0.49,
} as const

/** A poster's width and height. */
export const posterHeight = (MEASURE.card * 9) / 16
export const posterWidth = posterHeight * MEASURE.posterAspect
export const tileHeight = MEASURE.tile * MEASURE.tileAspect

export type ShelfLayout = "posters" | "tiles"

/** One place along a shelf, an item at rest, the open one, the gap after it, the
 *  height. */
export function stripMeasures(layout: ShelfLayout) {
  return layout === "posters"
    ? {
        pitch: posterWidth + MEASURE.gap,
        rest: posterWidth,
        wide: MEASURE.card,
        gap: MEASURE.gap,
        height: posterHeight,
        radius: MEASURE.radius,
      }
    : {
        pitch: MEASURE.tile + MEASURE.tileGap,
        rest: MEASURE.tile,
        wide: MEASURE.tile,
        gap: MEASURE.tileGap,
        height: tileHeight,
        radius: MEASURE.tileRadius,
      }
}

/** How tall a caption is: its lines, the synopsis always three. */
export function captionHeight(lines: {
  eyebrow?: boolean
  headline?: boolean
  facts?: boolean
  synopsis?: boolean
}): number {
  const leads =
    (lines.eyebrow ? 1 : 0) + (lines.headline ? 1 : 0) + (lines.facts ? 1 : 0)
  return (
    leads * MEASURE.captionLine +
    (lines.synopsis
      ? (leads ? MEASURE.synopsisGap : 0) + 3 * MEASURE.synopsisLine
      : 0)
  )
}

/** A poster shelf's span while open: the room its caption needs, never less than a
 *  closed shelf's. */
export function openSpan(layout: ShelfLayout, caption: number): number {
  if (layout === "tiles") return MEASURE.tilesSpan
  const foot = MEASURE.itemsTop + posterHeight + MEASURE.captionTop + caption
  return Math.max(MEASURE.shelfPitch, foot + MEASURE.captionFoot)
}

/** Where a shelf's posters stand and which card its frame shows, as of the last
 *  press. Positions are in items: 2.5 is halfway from the third item open to the
 *  fourth. `start` is seconds; minus infinity for a shelf never moved. */
export type StripMotion = {
  open: number
  from: number
  previous: number | null
  start: number
}

export function stripAtRest(open: number): StripMotion {
  return { open, from: open, previous: null, start: -Infinity }
}

export function stripPosition(m: StripMotion, now: number): number {
  return m.from + (m.open - m.from) * progress(MOTION.slide, now - m.start)
}

export function stripMoving(m: StripMotion, now: number): boolean {
  return now - m.start < STRIP_END
}

/** A press opening `index`: the posters go on from where they stand. */
export function stripPressed(
  m: StripMotion,
  index: number,
  now: number,
): StripMotion {
  return {
    open: index,
    from: stripPosition(m, now),
    previous: m.open,
    start: now,
  }
}

/** The side the arriving title comes from: 1 the right (a step forward), -1 the left
 *  (a step back). */
export function stripSide(m: StripMotion): 1 | -1 {
  return m.open >= m.from ? 1 : -1
}

/** How lit an item before the frame is, `elapsed` after the last press: a passed
 *  poster's brightness, but the title a step forward leaves keeps its light until it
 *  has nearly arrived (`passDim`), and the one a step back returns is lit at once. */
export function passedLight(
  m: StripMotion,
  index: number,
  elapsed: number,
): number {
  const dim = MEASURE.passedDim
  if (m.previous === null || m.previous === m.open) return dim
  const side = stripSide(m)
  if (side > 0 && index === m.previous)
    return 1 - (1 - dim) * progress(MOTION.passDim, elapsed)
  if (side < 0 && index === m.open) return 1
  return dim
}

/** Each item's place along the strip at `now`, the frame `openness` wide (0 a poster,
 *  1 the card): those before the open item end at the frame's leading edge, those
 *  after start past its trailing edge; an item passing under the frame shows by how
 *  much of it the frame does not cover (measured both ways: a poster 35% out shows at
 *  0.39 of its light, 77% out at 0.78). After the frame they are posters; before it,
 *  while the shelf is open, they keep the card's face cut to a poster's width from its
 *  leading edge (`card`), so the title the focus leaves slides out as the card it was.
 *  Only those on screen and two either side. */
export function stripItems(
  layout: ShelfLayout,
  count: number,
  position: number,
  openness: number,
): {
  index: number
  x: number
  alpha: number
  passed: boolean
  card: boolean
}[] {
  const s =
    Math.abs(position - Math.round(position)) < 1e-6
      ? Math.round(position)
      : position
  const { pitch, rest, wide, gap } = stripMeasures(layout)
  const edge = MEASURE.inset
  const frame = rest + (wide - rest) * openness
  const lead = Math.floor(s)
  const first = Math.max(0, lead - Math.ceil(edge / pitch) - 2)
  const last = Math.min(
    count - 1,
    lead + Math.ceil((1 - edge - frame) / pitch) + 2,
  )
  const out: {
    index: number
    x: number
    alpha: number
    passed: boolean
    card: boolean
  }[] = []
  for (let i = first; i <= last; i++) {
    const passed = i <= s
    const x = passed
      ? edge + (i - s) * pitch
      : edge + frame + gap + (i - 1 - s) * pitch
    const outside = passed
      ? (edge - x) / pitch
      : (x - (edge + frame + gap - pitch)) / pitch
    const alpha = Math.min(Math.max(outside, 0), 1)
    if (alpha > 0)
      out.push({ index: i, x, alpha, passed, card: passed && openness >= 0.5 })
  }
  return out
}

/** The classes a card's drawing puts on each of its layers, so the shelf showing it
 *  fades and moves them apart while a step changes the title (`poster-card` and
 *  `category-tile` wear them). The shift is read inside the card, in its own width
 *  (`cqw` of a card that is a size container); outside a shelf both stay still. */
export const CARD_LAYER = {
  art: "opacity-(--ag-card-art-o,1) translate-x-[calc(var(--ag-card-art-x,0)*1cqw)]",
  text: "opacity-(--ag-card-text-o,1) translate-x-[calc(var(--ag-card-text-x,0)*1cqw)]",
} as const

/** How a card's two layers stand: the picture (art, shade, line) and the words
 *  (wordmark, badge), each an opacity and a shift off its place, a share of the card's
 *  own width (positive to the right). */
export type CardLayers = {
  artOpacity: number
  artShift: number
  textOpacity: number
  textShift: number
}

/** How the frame's cards and the captions stand `elapsed` after a press coming from
 *  `side`: the leaving card's words gone at once and its art fading; the arriving
 *  card's art fading in drifting from that side, its words faster and farther, its
 *  caption farther still. The cards' shifts are shares of the card, the caption's of
 *  the page; `wide` is the open item's width. */
export function stripFaces(elapsed: number, side: 1 | -1, wide: number) {
  const leaving: CardLayers = {
    artOpacity: 1 - progress(MOTION.artOut, elapsed),
    artShift: 0,
    textOpacity: 1 - progress(MOTION.textOut, elapsed),
    textShift: 0,
  }
  const arriving: CardLayers = {
    artOpacity: progress(MOTION.artIn, elapsed),
    artShift:
      side * MOTION.artTravel * (1 - progress(MOTION.artDrift, elapsed)),
    textOpacity: progress(MOTION.textIn, elapsed),
    textShift:
      side * MOTION.textTravel * (1 - progress(MOTION.textSlide, elapsed)),
  }
  return {
    leaving,
    arriving,
    captionOut: 1 - progress(MOTION.textOut, elapsed),
    captionIn: progress(MOTION.captionIn, elapsed),
    caption:
      side *
      MOTION.captionTravel *
      wide *
      (1 - progress(MOTION.captionSlide, elapsed)),
  }
}

/** How a part of the page stands: how bright (`dim`, a multiply), how present
 *  (`fade`) and how open its item is (0 a poster, 1 the card with its caption). */
export type ShelfLook = { dim: number; fade: number; openness: number }

const FOCUSED: ShelfLook = { dim: 1, fade: 1, openness: 1 }
const BELOW: ShelfLook = { dim: MEASURE.restingDim, fade: 1, openness: 0 }
const ABOVE: ShelfLook = { dim: 1, fade: 0, openness: 1 }

export function restingLook(index: number, focus: number): ShelfLook {
  return index === focus ? FOCUSED : index > focus ? BELOW : ABOVE
}

/** The page's focus as of the last press: where it went, from where (a position in
 *  parts), how every part looked then, and whether it left the header at rest. */
export type PageMotion = {
  focus: number
  from: number
  start: number
  looks: readonly (ShelfLook | undefined)[]
  leftHeader: boolean
}

export function pageAtRest(focus: number): PageMotion {
  return { focus, from: focus, start: -Infinity, looks: [], leftHeader: false }
}

export function pageMoving(m: PageMotion, now: number): boolean {
  return now - m.start < PAGE_END
}

/** The page's position at `now`; `nudge` is the header's first nudge as a share of
 *  its travel. */
export function pagePosition(
  m: PageMotion,
  now: number,
  nudge: number,
): number {
  const e = now - m.start
  const p = m.leftHeader
    ? nudge * progress(MOTION.arrive, e) +
      (1 - nudge) * progress(MOTION.scroll, e - MOTION.headerHold)
    : progress(MOTION.scroll, e)
  return m.from + (m.focus - m.from) * p
}

function mix(a: number, b: number, t: number) {
  return a + (b - a) * t
}

/** How part `index` looks at `now`: the focused one arrives, one left above departs,
 *  one below settles back to rest. */
export function pageLook(m: PageMotion, index: number, now: number): ShelfLook {
  const target = restingLook(index, m.focus)
  const from = m.looks[index] ?? restingLook(index, Math.round(m.from))
  if (
    from.dim === target.dim &&
    from.fade === target.fade &&
    from.openness === target.openness
  )
    return target
  const e = now - m.start
  if (index < m.focus)
    return {
      dim: from.dim,
      fade: mix(from.fade, target.fade, progress(MOTION.depart, e)),
      openness: from.openness,
    }
  const a = progress(MOTION.arrive, e)
  return {
    dim: mix(from.dim, target.dim, a),
    fade: mix(from.fade, target.fade, a),
    openness: mix(from.openness, target.openness, a),
  }
}

/** The header's colour washing the page: there while it holds the focus, cut as fast
 *  as a shelf arrives when it leaves. */
export function pageAmbient(m: PageMotion, now: number): number {
  const was = Math.round(m.from) === 0 ? 1 : 0
  const target = m.focus === 0 ? 1 : 0
  return mix(was, target, progress(MOTION.arrive, now - m.start))
}

/** A press taking the focus to `index`: positions and looks go on from where they
 *  stand. */
export function pagePressed(
  m: PageMotion,
  index: number,
  now: number,
  count: number,
  nudge: number,
): PageMotion {
  const at = pagePosition(m, now, nudge)
  return {
    focus: index,
    from: at,
    start: now,
    looks: Array.from({ length: count }, (_, i) => pageLook(m, i, now)),
    leftHeader: nudge > 0 && at === 0 && index > 0,
  }
}

/** Every part's top at `now`, one under the other, the focused one on the focus
 *  line; between two focuses each part stands as far between where it would be for
 *  each, so one curve moves them all. `rest` and `open` are each part's span closed
 *  and open. */
export function pageTops(
  m: PageMotion,
  now: number,
  nudge: number,
  rest: readonly number[],
  open: readonly number[],
): number[] {
  const n = rest.length
  if (n === 0) return []
  if (open.length !== n)
    throw new Error(`shelf-motion: ${n} rest spans, ${open.length} open`)
  const at = (list: readonly number[], i: number) => {
    const v = list[i]
    if (v === undefined) throw new Error(`shelf-motion: no span ${i}`)
    return v
  }
  const tops = (focus: number) => {
    const t = new Array<number>(n).fill(0)
    t[focus] = MEASURE.top
    for (let j = focus + 1; j < n; j++)
      t[j] = at(t, j - 1) + at(j - 1 === focus ? open : rest, j - 1)
    for (let j = focus - 1; j >= 0; j--) t[j] = at(t, j + 1) - at(rest, j)
    return t
  }
  const f = Math.min(Math.max(pagePosition(m, now, nudge), 0), n - 1)
  const a = Math.floor(f)
  const b = Math.min(a + 1, n - 1)
  const ta = tops(a)
  const tb = tops(b)
  return ta.map((y, i) => mix(y, at(tb, i), f - a))
}

/** When everything a press started has finished, seconds after it. */
export const SHELF_STRIP_END = STRIP_END
export const SHELF_PAGE_END = PAGE_END