Skip to content

Backdrop List

A list of titles over full-bleed photos: pointing at a title makes its photo the backdrop, and it stays chosen.

$npx shadcn@latest add https://framebrick.co/r/backdrop-list.json

From the Catchup template, see it in context.

Examples

Hero

A headline on the backdrop, and a caption that follows the chosen title through value and onValueChange.

Now showing

Dusk House

A timber and glass house that opens onto its garden.

const [slug, setSlug] = React.useState(works[0].slug);

<BackdropList label="Selected work" value={slug} onValueChange={setSlug} items={items} className="sm:flex-row sm:items-end sm:justify-between" listClassName="sm:items-end">
  <div className="max-w-xs">
    <h3 className="text-3xl font-semibold">{current.title}</h3>
    <p className="text-sm">{current.summary}</p>
  </div>
</BackdropList>

Installation

$ npx shadcn@latest add https://framebrick.co/r/backdrop-list.json

Usage

import { BackdropList } from "@/components/ui/backdrop-list";

<BackdropList
  label="Selected work"
  className="h-screen"
  items={works.map((work) => ({
    value: work.slug,
    title: work.title,
    href: `/work/${work.slug}`,
    media: <img src={work.image} alt="" />,
  }))}
/>

// a headline on the backdrop, and the rest of the page following the chosen title
<BackdropList label="Selected work" items={items} value={slug} onValueChange={setSlug}>
  <h1 className="max-w-xl text-5xl font-semibold">We make brands people remember</h1>
</BackdropList>

Props

PropTypeDefaultDescription
items{ value: string; title: ReactNode; href?: string; media: ReactNode }[]—One per title. With an href the title is a link; without one it is a button that chooses it. media is the photo (or video) behind the block while the title is chosen.
labelstring—Accessible name of the list.
valuestring—The chosen title, when you control it.
defaultValuestring—The title that starts chosen; the first by default.
onValueChange(value: string) => void—Called when a title is chosen, by a pointer, by focus, by a click or by the timer.
intervalnumber6000Milliseconds between titles on a device without hover; 0 turns that off.
dimnumber0.65Opacity of the titles that are not chosen.
veilnumber0.55Opacity of the black layer over the media, which keeps the white titles readable. Catchup uses 0.3 on its dark photos; 0 removes it.
fadenumber0Seconds of cross-fade between photos; 0 is a cut. Reduced motion always cuts.
classNamestring—Classes for the block: its height, padding, and the layout of what is inside it (a flex column that ends at the bottom).
listClassNamestring—Classes for the list: where it sits in the block, its gap.
titleClassNamestring—Classes for every title: its size and weight. The chosen one has aria-current.
childrenReactNode—Anything else on the backdrop (a headline, buttons), before the list.
  • Every change is a cut, as in Catchup's hero: the new photo and the title's opacity change in one frame, with no fade and no zoom. fade={0.4} gives a cross-fade instead (the new photo fades in over the old one, so the screen never dips to black); reduced motion always cuts.
  • Pointing at a title with a mouse or a pen chooses it, and it stays chosen when the pointer leaves. Focus chooses too, which Catchup does not do, so a keyboard reaches every photo. A tap on a link follows the link; a tap on a title without an href chooses it.
  • A device without hover (a phone, a tablet) cannot point, so there the list moves on by itself, every 6 s, and loops. That is (hover: none), asked in an effect and never while rendering, because the server cannot know it. It waits while the block is out of view or a title has focus, starts over whenever a title is chosen, and never starts for a visitor who asked for reduced motion.
  • Every item's media is in the page at once, stacked behind the block: a cut shows the next photo at once, the photos load together, and a search engine sees them all. The media is decoration (give an image an empty alt); the title says what it is.
  • Contrast: the titles are white over the photos, under a black veil, 55% by default (Catchup's is 30%, on its dark photos). The step back (0.65) is the library's rule for a flat background; Catchup uses 0.7. Over a photograph nothing can be decided in advance, and axe cannot see through an image, so measure the pixels under the titles with the list hidden. On our demo photos a veil of 0.3 left some of the dimmed titles under 2:1 (1.9:1 at the lowest), and 0.55 brought every title to 3.3:1 or better, which is what large text needs. Lower veil for darker photos, raise it (or dim) for paler ones, and look at yours.
  • It is not Index List, whose panel sits beside the names and opens by height: here the media is a layer behind the whole block and the list sits on it. Index List suits a services list in a section; this suits a hero or a full-screen index of work.

AI prompt

Paste into v0, Lovable, Bolt or Claude to rebuild this component in your own project.

Create a React + Tailwind CSS component called BackdropList — a client component, no animation library.
- Props: items ({ value, title, href?, media }[]), value / defaultValue / onValueChange (controlled or not), label, interval (6000), dim (0.65), veil (0.55), fade (0), className, listClassName, titleClassName, children.
- The root is relative isolate flex min-h-80 flex-col justify-end gap-8 overflow-hidden bg-black p-6 text-white (sm:p-10). Behind everything an aria-hidden absolute inset-0 -z-10 layer holds every item's media stacked (each absolute inset-0, its child size-full object-cover) and, on top, a black veil at opacity `veil`. The chosen item's layer has opacity 1 and z-1, the others opacity 0: a change is a cut (transition-duration 0). With `fade` the chosen layer fades in over the others, which only switch off once it is done (delay = fade, duration 0): set --fade on the root and use motion-safe:duration-(--fade) and motion-safe:delay-(--fade), so reduced motion cuts.
- Then children, then a ul with aria-label: one li per item with a link (href) or a button, text-3xl font-semibold tracking-tight (sm:text-4xl), whose text span has opacity 1 when chosen and `dim` otherwise; aria-current="true" on the chosen one; a focus ring in white with a dark offset.
- Pointing at a title (pointerenter, pointerType not "touch") chooses it and it stays chosen on leave; focus chooses it too; a click chooses a button.
- Without hover — matchMedia("(hover: none)"), read with useSyncExternalStore with a server snapshot of false, never while rendering — advance to the next title (looping) every `interval` ms with a setTimeout keyed on the chosen value, so choosing restarts it. Do it only while the root is at least 30% in view (IntersectionObserver), no title has focus, and prefers-reduced-motion is not reduce. Keep the latest items and choose in a ref so a new items array each render does not restart the timer.

Be the first to know

Get new templates and components first

One short email when we release a template or a free component. No spam — unsubscribe anytime.

We use your email only for Framebrick updates. Privacy policy