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.jsonFrom the Catchup template, see it in context.
"use client";
import { BackdropList } from "@/components/ui/backdrop-list";
// Demo photos from Unsplash (free under the Unsplash License), served by this site so no request leaves it.
const works = [
{ value: "dusk-house", title: "Dusk House", image: "/demo/step-sequence-tour.webp" },
{ value: "misty-pines", title: "Misty Pines", image: "/demo/label-cursor-forest.webp" },
{ value: "open-plan", title: "Open Plan", image: "/demo/media-accordion-build.webp" },
{ value: "round-table", title: "Round Table", image: "/demo/showreel-meeting.webp" },
];
/*
* Point at a title, or tab to it: its photo takes over the backdrop, in one frame. Titles without an href are buttons.
* The padding and the title size are fixed (the block's own change with the window), so it is the same in a card.
*/
export default function BackdropListDemo() {
return (
<BackdropList
label="Recent work"
className="h-72 w-full max-w-lg rounded-lg p-5 sm:p-5"
titleClassName="text-2xl sm:text-2xl"
items={works.map((work) => ({
value: work.value,
title: work.title,
media: <img src={work.image} alt="" draggable={false} />,
}))}
/>
);
}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.
"use client";
import * as React from "react";
import { BackdropList } from "@/components/ui/backdrop-list";
// Demo photos from Unsplash (free under the Unsplash License), served by this site so no request leaves it.
const works = [
{ value: "dusk-house", title: "Dusk House", summary: "A timber and glass house that opens onto its garden.", image: "/demo/step-sequence-tour.webp" },
{ value: "misty-pines", title: "Misty Pines", summary: "A forest retreat, planned around the fog.", image: "/demo/label-cursor-forest.webp" },
{ value: "open-plan", title: "Open Plan", summary: "A workplace for three hundred people that still feels quiet.", image: "/demo/media-accordion-build.webp" },
{ value: "round-table", title: "Round Table", summary: "A studio table built for the whole team to gather around.", image: "/demo/showreel-meeting.webp" },
];
/*
* The caller's own content follows the chosen title: value and onValueChange, as on any controlled input.
* The layout follows the width of the box (a container query), not of the window, so it is right in a card too: stacked
* when narrow, the caption left and the list right from 576px. The sizes are fixed for the same reason, and the height is a
* minimum (the component's min-h-80), so a narrow box that wraps the caption grows instead of cutting its first line.
*/
export default function BackdropListHeroDemo() {
const [value, setValue] = React.useState(works[0].value);
const current = works.find((work) => work.value === value) ?? works[0];
return (
<div className="@container w-full max-w-2xl">
<BackdropList
label="Selected work"
value={value}
onValueChange={setValue}
className="rounded-lg p-5 sm:p-5 @xl:flex-row @xl:items-end @xl:justify-between"
listClassName="@xl:items-end"
titleClassName="text-2xl sm:text-2xl"
items={works.map((work) => ({
value: work.value,
title: work.title,
media: <img src={work.image} alt="" draggable={false} />,
}))}
>
<div className="max-w-xs">
<p className="text-xs font-medium tracking-widest text-white uppercase">Now showing</p>
<h3 className="mt-1 text-3xl font-semibold tracking-tight">{current.title}</h3>
<p className="mt-2 text-sm text-white">{current.summary}</p>
</div>
</BackdropList>
</div>
);
}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.json1. Install the dependencies.
npm install motion clsx tailwind-merge2. Add
cn()tolib/utils.ts(skip if shadcn already created it).import { clsx, type ClassValue } from "clsx"; import { extendTailwindMerge } from "tailwind-merge"; /* tailwind-merge must know the site's type scale (globals.css) is a font size, not a text color — otherwise cn("text-h6 text-muted-foreground") drops text-h6. */ const twMerge = extendTailwindMerge({ extend: { classGroups: { "font-size": [{ text: ["display", "h1", "h2", "h3", "h4", "h5", "h6"] }] } }, }); export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); }3. Copy the component into
components/ui/backdrop-list.tsx."use client"; import * as React from "react"; import { cn } from "@/lib/utils"; /* * Backdrop List — a list of titles over full-bleed photos (from the Catchup Home hero). Pointing at a title makes its * photo the backdrop, and it stays chosen when the pointer leaves; the chosen title is at full opacity and the others * step back. Every change is a cut — no fade, no zoom, as in Catchup — and `fade` gives a cross-fade instead. * * 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, and it never starts for a visitor who asked for reduced motion. Focus * chooses a title the way a pointer does, so a keyboard reaches every photo (Catchup has no keyboard path). * * 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. Every item's media is in the page at once, stacked, so a cut shows the next photo * at once and a search engine sees them all. * * Contrast: the titles are white over the photos, under a black veil, 55% by default (Catchup's is 30%, on dark photos). The * step back (0.65) is the library's rule for a flat background. Over a photograph nothing can be decided in advance (axe * cannot see through an image): measure the pixels under the titles with the list hidden, on your own photos. On four * ordinary photographs the dimmed titles needed a veil of about 0.55 to reach 3:1, which large text asks for; 0.3 left * some of them under 2:1. Lower `veil` for darker photos, raise it for paler ones. */ export type BackdropListItem = { value: string; /** The title in the list: large text. */ title: React.ReactNode; /** Where the title goes. Without it the title is a button that chooses it. */ href?: string; /** The photo (or video) that fills the backdrop while this title is chosen. It is stretched and cropped to fit, and it is decoration: give an image an empty alt. */ media: React.ReactNode; }; export type BackdropListProps = { items: BackdropListItem[]; value?: string; /** The title that starts chosen; the first by default. */ defaultValue?: string; onValueChange?: (value: string) => void; /** Accessible name of the list. */ label: string; /** Milliseconds between titles on a device without hover; 0 turns that off. */ interval?: number; /** Opacity of the titles that are not chosen. */ dim?: number; /** Opacity of the black layer over the media, which keeps the white titles readable (Catchup: 0.3 on dark photos). 0 removes it. */ veil?: number; /** Seconds of cross-fade between photos; 0 is a cut. Reduced motion always cuts. */ fade?: number; /** Classes for the block: its height, padding, and the layout of what is inside it (it is a flex column that ends at the bottom). */ className?: string; /** Classes for the list: where it sits in the block, its gap. */ listClassName?: string; /** Classes for every title: its size, weight. The chosen one has aria-current. */ titleClassName?: string; /** Anything else on the backdrop (a headline, buttons), before the list. */ children?: React.ReactNode; }; function useMedia(query: string) { const subscribe = React.useCallback( (onChange: () => void) => { const list = window.matchMedia(query); list.addEventListener("change", onChange); return () => list.removeEventListener("change", onChange); }, [query], ); return React.useSyncExternalStore( subscribe, () => window.matchMedia(query).matches, () => false, ); } export function BackdropList({ items, value, defaultValue, onValueChange, label, interval = 6000, dim = 0.65, veil = 0.55, fade = 0, className, listClassName, titleClassName, children, }: BackdropListProps) { const [internal, setInternal] = React.useState(defaultValue ?? items[0]?.value); const active = value ?? internal; const root = React.useRef<HTMLDivElement>(null); const [inView, setInView] = React.useState(false); const [focused, setFocused] = React.useState(false); const touch = useMedia("(hover: none)"); const calm = useMedia("(prefers-reduced-motion: reduce)"); function choose(next: string) { if (next === active) return; if (value === undefined) setInternal(next); onValueChange?.(next); } /* The timer reads the latest items and choose when it fires, so it is not rebuilt every time the caller renders with a new array. */ const latest = React.useRef({ items, choose }); React.useEffect(() => { latest.current = { items, choose }; }); const empty = items.length === 0; React.useEffect(() => { const element = root.current; if (!element) return; const observer = new IntersectionObserver(([entry]) => setInView(entry.isIntersecting), { threshold: 0.3 }); observer.observe(element); return () => observer.disconnect(); }, [empty]); /* Without hover the list moves on by itself, one title every `interval`, and the timer starts over whenever a title is chosen. */ const count = items.length; React.useEffect(() => { if (!touch || calm || !inView || focused || interval <= 0 || count < 2) return; const timer = window.setTimeout(() => { const { items, choose } = latest.current; const current = Math.max(0, items.findIndex((item) => item.value === active)); choose(items[(current + 1) % items.length].value); }, interval); return () => window.clearTimeout(timer); }, [active, touch, calm, inView, focused, interval, count]); if (empty) return null; return ( <div ref={root} style={{ ["--fade" as string]: `${fade}s` }} className={cn("relative isolate flex min-h-80 flex-col justify-end gap-8 overflow-hidden bg-black p-6 text-white sm:p-10", className)} > {/* Behind everything: every photo stacked, the chosen one on top (the others only leave once it has faded in), then the veil. */} <div aria-hidden className="absolute inset-0 -z-10"> {items.map((item) => { const chosen = item.value === active; return ( <div key={item.value} className={cn( "absolute inset-0 transition-opacity duration-0 [&>*]:size-full [&>*]:object-cover", chosen ? "z-1 opacity-100 motion-safe:duration-(--fade)" : "z-0 opacity-0 motion-safe:delay-(--fade)", )} > {item.media} </div> ); })} <div className="absolute inset-0 z-2 bg-black" style={{ opacity: veil }} /> </div> {children} <ul aria-label={label} onFocus={() => setFocused(true)} onBlur={(event) => { if (!event.currentTarget.contains(event.relatedTarget)) setFocused(false); }} className={cn("flex flex-col items-start gap-2.5 sm:gap-4", listClassName)} > {items.map((item) => { const chosen = item.value === active; const shared = { "aria-current": chosen ? ("true" as const) : undefined, onPointerEnter: (event: React.PointerEvent) => { if (event.pointerType !== "touch") choose(item.value); }, onFocus: () => choose(item.value), className: cn( "cursor-pointer rounded-md text-left text-3xl font-semibold tracking-tight outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-offset-2 focus-visible:ring-offset-black/70 sm:text-4xl", titleClassName, ), }; const title = ( <span style={{ opacity: chosen ? 1 : dim }} className="block motion-safe:transition-opacity motion-safe:duration-(--fade)"> {item.title} </span> ); return ( <li key={item.value} className="flex"> {item.href ? ( <a href={item.href} {...shared}> {title} </a> ) : ( <button type="button" onClick={() => choose(item.value)} {...shared}> {title} </button> )} </li> ); })} </ul> </div> ); }
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
| Prop | Type | Default | Description |
|---|---|---|---|
| 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. |
| label | string | — | Accessible name of the list. |
| value | string | — | The chosen title, when you control it. |
| defaultValue | string | — | 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. |
| interval | number | 6000 | Milliseconds between titles on a device without hover; 0 turns that off. |
| dim | number | 0.65 | Opacity of the titles that are not chosen. |
| veil | number | 0.55 | Opacity of the black layer over the media, which keeps the white titles readable. Catchup uses 0.3 on its dark photos; 0 removes it. |
| fade | number | 0 | Seconds of cross-fade between photos; 0 is a cut. Reduced motion always cuts. |
| className | string | — | Classes for the block: its height, padding, and the layout of what is inside it (a flex column that ends at the bottom). |
| listClassName | string | — | Classes for the list: where it sits in the block, its gap. |
| titleClassName | string | — | Classes for every title: its size and weight. The chosen one has aria-current. |
| children | ReactNode | — | 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.














