Adaptive list
AdvancedA grouped list that lays itself out for the room it has: widen its panel and the amounts slide into columns.
adaptive-list/adaptive-list.tsxDeals
11- Tideline$450USD 45027 Aug
- Bramble$24kUSD 24k15 Oct
- Ashford$52kUSD 52k29 Oct
- Fielded$320USD 32012 Sept
- alvi.sh$10kUSD 10k3 Sept
- Vesper$18kUSD 18k22 Oct
- Holloway$180USD 18027 Sept
- Northlane$36kUSD 36k5 Nov
- Quietwork$12kUSD 12k–12 Nov
- Wovenpath$125USD 12510 Aug
- Halden$28kUSD 28k21 Sept
480px · compact
The live component, numbered part by part. Every measurement is read from it as it is drawn. Point at a row to outline its part, or at a part to find its row.
When to use
- A grouped list that lives in a container whose width changes: a side panel, a split view, a resizable dashboard card.
- Records with a name and three or four short fields (an amount, a date, an owner, people) that fit one line when narrow.
- Groups that read as stages or statuses, where folding one away or filtering out the finished ones helps.
- When the same list is placed in several widths of the product and should look designed in each, not squeezed.
When not to use
- Many columns to sort, resize or compare. Use a data table with real column headers.
- Records that need editing in place. The cells here are read-only text and faces.
- Long flat lists with no groups, or hundreds of rows. Use a virtualised list; this one animates every moving cell.
- When only the window’s width matters and the list always fills it. Plain media queries are simpler.
You can add this component with the shadcn CLI or by hand:
Into components/adaptive-list of any React project, with no setup:
npx shadcn@latest add https://brunodelorence.com/r/adaptive-list.json- Copy the component files
Copy these files into one folder of your project, keeping their relative imports together, or download them as a ZIP:
- Import it
As shown under Usage, below.
Either way it needs React 19 and a bundler that supports CSS Modules, such as Next.js or Vite. It has a dark colour scheme: it follows your page’s color-scheme, light unless the page sets one, or its colorScheme prop, with nothing to configure. Browsers without light-dark() show it light.
Usage
import { AdaptiveList } from './adaptive-list';import { DEALS } from './deals';export function DealsPanel() { return ( // The list fills its container and adapts to its width. <aside style={{ width: 'min(100%, 720px)', height: 640 }}> <AdaptiveList title="Deals" stages={DEALS} breakpoints={[616, 717]} /> </aside> );}Each state below is a live instance, locked so you can study it.
The pieces it is built from, each with its measurements read live.
A list of deals that watches its own width. Narrow, each deal reads as a line. Wider, the amount moves into a column of its own and spells its currency, and the dates line up. Wider still, the contacts join in. Between layouts every cell glides from its old place to the new one, row after row.
- It measures itself, not the window: a ResizeObserver reads the list’s own width, so it adapts in a side panel, a split view or a dashboard card.
- Three layouts share one markup. Compact, columns and wide differ only in a data attribute on the list, which sets the grid’s tracks and where each cell sits.
- When a breakpoint is crossed it plays FLIP: it records where the moving cells are, switches the layout, measures where they landed, and animates each one back from its old place with the Web Animations API, 22 ms after the row above.
- Both spellings of an amount stay in the cell, stacked. They cross-fade, with a touch of blur, while the cell travels, so “$450” turns into “USD 450” mid-flight.
- The contacts and the columns’ icons arrive with a short blur-in, row after row. A switch that interrupts another starts from where each cell is on screen.
- The list’s and the playground’s colours are light-dark() tokens, the SVG status icons included, so both follow the page’s color-scheme, or the colorScheme prop (the list takes the playground’s); the one monochrome logo with no tile of its own is reversed at night.
| Prop | Type | Default | Description |
|---|---|---|---|
stages | Stage[] | — | The groups, in order: an id, a label, a status (lead, demo, proposal or won) and their deals. A deal has a company, a 16 × 16 logo, an amount, a close date, an owner and its contacts. |
title | string | 'Deals' | The heading of the list. |
breakpoints | readonly [number, number] | [616, 717] | The list’s widths, in pixels, at which the columns and then the contacts appear. Sizes are in em: scale the list with its font-size and scale these with it. |
defaultOpenOnly | boolean | false | Opens with the filter on: only the open deals, the won stage folded away. |
defaultCollapsed | readonly string[] | — | The ids of the stages that open folded, their rows hidden under the header. |
onLayoutChange | (layout: 'compact' | 'columns' | 'wide') => void | — | Called with the new layout each time the list’s width crosses a breakpoint. |
onFilterChange | (openOnly: boolean) => void | — | Called when the filter is turned on or off. |
onStageToggle | (id: string, open: boolean) => void | — | Called when a stage’s header folds or unfolds its rows. |
colorScheme | 'light' | 'dark' | 'system' | — | Its colours: light, dark, or the reader’s system setting. Without it the component follows the page’s color-scheme, which is light unless the page sets one. |
className | string | — | Extra class for the list’s root. |
The complete source behind this demo. Open source under the MIT license, © 2026 Bruno Delorence: copy it, change it, ship it.
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react';import { Avatar, type Person } from './avatars';import { CalendarIcon, ContactIcon, DealsIcon, FilterIcon, OwnerIcon, StatusIcon, TagIcon, type Status,} from './icons';import styles from './adaptive-list.module.css';/* * Adaptive list: a grouped list that lays itself out for the room it has, and animates between * layouts as its container resizes. * * - compact: each row reads as a line, the amount after the name ("Tideline $450"), the date * and the owner on the right;/* Adaptive list (adaptive-list.tsx). Sizes in em: set a font-size on the list to scale it. The layout is a data attribute the component sets from its width: compact, columns or wide. It has no ground of its own: it sits on a light panel, or at night on a dark grey one. */.list { /* Its colours, light and dark: each light-dark() pair resolves by the color-scheme the list inherits from the page, or the one the colorScheme prop sets (below). */ /* The ground it sits on (the contacts' faces are cut out of it), then the stage headers a step up, and the counts on them. */ --surface: #fff; --surface-2: #f4f4f5; --chip: #e8e8ea; /* Names and titles; amounts, dates, icons and counts; the quietest marks. */ --ink: #1d1d1f; --ink-muted: #86868b; --ink-faint: #aeaeb2; --line: #e8e8ea; /* A row under the pointer. */ --hover: rgb(0 0 0 / 0.028); /* A logo drawn in one dark colour, with no tile of its own, reversed at night (logos.tsx). The other logos and the faces keep their colours. */ --logo-ink: #111; /* The status icons' green, the check on it once a deal is won, and a lead's paler ring. */ --accent: #1b8a3a;import type { SVGProps } from 'react';/* * The adaptive list's icons, drawn on a 16 × 16 grid with 1.25px strokes in `currentColor`, so they * take the colour of the text around them. */type IconProps = SVGProps<SVGSVGElement>;function Icon({ children, ...props }: IconProps) { return ( <svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" {...props} > {children}/* * Small illustrated people for the sample deals' owners and contacts: no photos, so the demo needs * no image files and shows no real faces. Use an <img> in their place for real people. */export type Face = { /** The backdrop, behind the head. */ bg: string; skin: string; hair: string; shirt: string; style: 'short' | 'long' | 'bun' | 'curly' | 'bald'; beard?: boolean; glasses?: boolean;};export type Person = { name: string; face: Face };export function Avatar({ person, className }: { person: Person; className?: string }) { const { bg, skin, hair, shirt, style, beard, glasses } = person.face; return ( <svg className={className} viewBox="0 0 24 24" role="img" aria-label={person.name}> <circle cx="12" cy="12" r="12" fill={bg} /> {style === 'long' && (/* * Logos for the sample deals: made-up companies, each a 16 × 16 mark. Swap them for your own * customers' logos (an <img> works as well as an <svg>). */type LogoProps = { className?: string };function Mark({ className, background, children,}: LogoProps & { background?: string; children: React.ReactNode }) { return ( <svg className={className} viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"> {background && <rect width="16" height="16" rx="4" fill={background} />} {children} </svg> );}export function TidelineLogo({ className }: LogoProps) { return ( <Mark className={className} background="#1d6ff2"> <path d="M8 3.4c1.7 2 2.7 3.6 2.7 5a2.7 2.7 0 0 1-5.4 0c0-1.4 1-3 2.7-5Z" fill="#fff" />import type { Person } from './avatars';import type { Stage } from './adaptive-list';import { AlviLogo, AshfordLogo, BrambleLogo, FieldedLogo, HaldenLogo, HollowayLogo, NorthlaneLogo, QuietworkLogo, TidelineLogo, VesperLogo, WovenpathLogo,} from './logos';/* Sample pipeline for the demo: made-up companies, people and amounts. */const people = { maya: { name: 'Maya', face: { bg: '#3a2a1f', skin: '#c58c63', hair: '#2b1a12', shirt: '#7a4a2a', style: 'curly' }, }, noah: {'use client';import { useCallback, useEffect, useMemo, useRef, useState } from 'react';import { AdaptiveList, type Layout } from './adaptive-list';import { DEALS } from './deals';import { useAutoplay } from './use-autoplay';import styles from './playground.module.css';/* * The adaptive list's playground: the list in a side panel docked to the right of a stage, with a * handle on the panel's edge to resize it (drag, or arrow keys), and three widths to jump to. While * on screen and untouched, the panel opens and closes on its own. Its colours, and the list's, * follow the page's color-scheme, or the one the colorScheme prop sets. *//** The list's breakpoints, shared with the read-out. */const BREAKPOINTS = [616, 717] as const;/** The panel's border, between its width and the list's. */const BORDER = 2;const MIN = 420;const MAX = 900;/** The stage's margin round the panel. */const INSET = 12;/* The adaptive list's playground (playground.tsx): a light stage with the list in a panel docked to its right edge, resized from the panel's left edge; at night, a near-black stage and a dark grey panel. */.root { /* Its colours, light and dark: each light-dark() pair resolves by the color-scheme the root inherits from the page, or the one the colorScheme prop sets (below). The list inside inherits the same scheme. */ /* The stage, then the panel a step up. */ --surface: #efeff1; --surface-2: #fcfcfc; /* The width shown and a width under the pointer; the other widths; the read-out's words. */ --ink: #111; --ink-muted: #6e6e76; --ink-faint: #8a8a92; /* The panel's edge, the stage's edge and dots; the widths' track and its edge. */ --line: #e3e3e6; --edge: rgb(0 0 0 / 0.04); --dots: rgb(0 0 0 / 0.045); --track: rgb(0 0 0 / 0.05); --track-line: rgb(0 0 0 / 0.06); /* The chosen width and its text. */ --accent: #111; --accent-ink: #fff;'use client';import { useCallback, useEffect, useRef, useState } from 'react';/* * When a demo may play on its own: while part of it is on screen and the tab is visible, never * under reduced motion, and not while the reader is using it. `take()` hands it to the reader; it * takes back over `idle` ms after their last input. */export function useAutoplay<T extends HTMLElement>(idle = 6000) { const ref = useRef<T>(null); const [visible, setVisible] = useState(false); const [reduced, setReduced] = useState(true); const [taken, setTaken] = useState(false); const timer = useRef(0); useEffect(() => { const element = ref.current; if (!element) return; let onScreen = false; const update = () => setVisible(onScreen && !document.hidden); const observer = new IntersectionObserver( ([entry]) => { onScreen = entry.isIntersecting;- Each stage folds from its header, a button with aria-expanded; the filter is a toggle button with aria-pressed.
- The demo’s resize handle is a separator: focus it and use the arrow keys (Shift for bigger steps), or Home and End.
- Under reduced motion the layouts switch at once, without movement, blur or stagger.