a titled row of items sliding under a focus frame that never moves
npx shadcn add @ag/shelfAlready installed? Preview an update
npx shadcn add @ag/shelf --dry-runYour 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 putsCARD_LAYER.artandCARD_LAYER.texton its two layers so the step can move them apart (PosterCardandCategoryTiledo). ← 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,hrefmakes the frame a link,onOpenChangereports the open item. The curves and the page's measures arelib/shelf-motion(MOTION,MEASURE), the same numbers as swift-utils' Ink.
also called row, rail, slider row
shelf media
uses key-router
used by shelves, poster-card, category-tile
the demo above, verbatim
"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
"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>
)
}// 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