Pinned list
Pin a place and its row lifts off and flies to the pinned section, while the rows around it make room.
pinned-list/pinned-list.tsxPinned
- Juniper & Rye
All places
- Fernhill Library
- Linden Pharmacy
- Northgate Charging
- Pier Nine Marina
- Saltmarsh Kitchen
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 list people come back to, where a few items matter more than the rest: saved places, projects, channels.
- Up to a dozen items, few enough that each one fits as a row with a name and a line of detail.
- Pinning that should feel physical: the row flies to its new section, so the reader sees where it went.
- Where order matters at the top (pinned items stay in the order they were pinned) and at the bottom (the rest stay alphabetical).
When not to use
- Hundreds of items, or items that arrive while the reader looks. Use a searchable, virtualised list with a pinned filter.
- Ordering by hand, beyond pinned or not. Use a sortable list with drag handles.
- Choosing one item to act on now. Use a radio group, a menu or a select.
- Marking many items at once for a bulk action. Use checkboxes with an action bar.
- A favourite that changes nothing about the order. A star toggle on the row is enough.
You can add this component with the shadcn CLI or by hand:
Into components/pinned-list of any React project, with no setup:
npx shadcn@latest add https://brunodelorence.com/r/pinned-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 { PinnedList } from './pinned-list';import { PLACES } from './places';export function SavedPlaces() { return ( <PinnedList places={PLACES} defaultPinned={['juniper']} autoplay={false} onPinnedChange={(ids) => localStorage.setItem('pinned', JSON.stringify(ids))} /> );}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 places in two sections, Pinned and All places. Pin one and its row rises above the others and flies up to the end of the pinned places, its pin turning yellow and standing up, while the rows it leaves close the gap and the ones it joins make room. Unpin it and it flies back to its place in the alphabet.
- FLIP: before a pin changes, every row’s and section label’s place on screen is read. React then moves the row into its new section, and each row plays from its old place to its new one, so the gap closes and room opens.
- The flying row rides above the others on a softer spring, a touch larger and with a deeper shadow at mid-flight. Its pin changes colour and stands up with a little pop.
- Pinned places keep the order you pinned them in. Unpinned ones go back to their alphabetical place in All places.
- Positions are read as they are on screen, mid-flight included, so a change that interrupts another starts every row from where it is.
- When nothing is pinned, a dashed hint marks where the first place will land. It fades in once the last row has flown away.
- Every colour is a light-dark() token on the root, so it follows the page’s color-scheme, or the colorScheme prop; the pin’s pop animates between colours read from the computed style, so it follows the scheme too.
| Prop | Type | Default | Description |
|---|---|---|---|
places | Place[] | PLACES | The places, in any order: an id, a name, a category (“Café”), its hours (“Closes 6 PM”) and a 20 × 20 icon. |
defaultPinned | string[] | ['juniper'] | The ids of the places pinned at first, in order. |
defaultPointer | { id: string; pressed?: boolean } | — | Draws a place’s pin as under the pointer, or pressed, until the reader’s first press. Turns the demo off. |
onPinnedChange | (pinned: string[]) => void | — | Called with the pinned ids, in order, after each change. |
autoplay | boolean | true | Pins and unpins a few places with a demo cursor while on screen and untouched. |
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 root, a stage of its own. |
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, useId, useLayoutEffect, useRef, useState } from 'react';import { DemoCursor, glideTo, hideCursor, pressWith } from './demo-cursor';import { PinIcon } from './icons';import { PLACES, type Place } from './places';import { spring, timing } from './spring';import { useAutoplay } from './use-autoplay';import styles from './pinned-list.module.css';/* * Pinned list: places in two sections, Pinned and All places. Pin a place and its row lifts off * and flies up to the end of the pinned ones, while the rows it leaves close the gap and the rows * it joins make room; unpin it and it flies back to its place in the alphabet. * * - FLIP: before a pin changes, the place of every row and section label on screen is read; React * moves the row to its new section; then each one plays from its old place to its new one on a * spring (spring.ts). The moving row rises above the others, a little larger, with a deeper * shadow. A change that interrupts another starts from where each row is on screen. * - The pin turns yellow and stands up, with a pop. * - Keyboard: each pin is a toggle button, and focus stays on it while its row moves. Changes are * announced to screen readers./* Pinned list (pinned-list.tsx): a light stage with two sections of places, each place a white row, or at night a near-black stage with each place a dark grey row. Sizes are in em: the list's font-size follows the component's width (a container), from 14px on a phone to 16.5px. Colours are the tokens below, light-dark(light, dark): the component takes the color-scheme it inherits from the page (light unless the page says otherwise), or the one its colorScheme prop sets (data-color-scheme). */.root { /* The stage, then the rows a step up, and the tiles and unpinned pins on them. */ --surface: #eaeaed; --surface-2: #fff; --wash: #f1f1f4; /* Names; categories, hours and the tiles' glyphs; the section labels and the hint, on the stage. */ --ink: #1d1d1f; --ink-muted: #6e6e73; --stage-ink: #636366; /* The stage's edge, its dots, the rows' edges, and the hint's dashed outline. */ --line: rgb(0 0 0 / 0.06); --dots: rgb(0 0 0 / 0.05); --card-line: rgb(0 0 0 / 0.04); --dash: #cfcfd4; /* A row's shade, and the deeper one of the row in flight. */import type { ReactNode } from 'react';import { BoltIcon, BookIcon, CupIcon, CutleryIcon, PillIcon, SailIcon } from './icons';/* The sample places: made up, as are their hours. */export type Place = { id: string; name: string; /** What it is, shown before its hours: "Café". */ category: string; /** Its hours, as shown: "Closes 6 PM". */ hours: string; /** A 20 × 20 glyph in `currentColor`. */ icon: ReactNode;};export const PLACES: Place[] = [ { id: 'juniper', name: 'Juniper & Rye', category: 'Café', hours: 'Closes 6 PM', icon: <CupIcon />, },import type { SVGProps } from 'react';/* * The pinned list's icons, drawn on a 20 × 20 grid with 1.6px strokes in `currentColor`: one glyph * per kind of place, and the pin. */type IconProps = SVGProps<SVGSVGElement>;function Icon({ children, ...props }: IconProps) { return ( <svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" {...props} > {children} </svg> );/* * Springs for the Web Animations API. A damped spring released from 0 towards 1 is simulated * once, then sampled into a linear() easing: played over the time it takes to settle, it * overshoots and settles like the spring, and the browser runs it like any other curve. */export type Spring = { /** The spring's motion, as a CSS easing. */ easing: string; /** How long it takes to settle, in ms. */ duration: number;};/** For browsers without linear(): a curve that overshoots a touch, then settles. */const FALLBACK = 'cubic-bezier(0.25, 1.15, 0.4, 1)';/** * A spring of unit mass: `stiffness` pulls it towards its target, `damping` holds it back. The * lower the damping, the more it overshoots. */export function spring(stiffness: number, damping: number): Spring { const step = 1 / 1000; const positions = [0]; let position = 0;'use client';import type { Ref } from 'react';import styles from './demo-cursor.module.css';/* * The demo's pointer: while the demo plays on its own, an arrow glides to what it is about to * press and dips as it presses, so the demo reads like someone using the component. It sits in * the stage (its offset parent). Each glide is a CSS transition, the two axes on different * easings so that the path bends a little: nothing runs between moves. */const ARROW = 'M4 2.5 15 13h-4.6l2.4 5.2-2.4 1-2.4-5.2L4 17.2z';export function DemoCursor({ ref }: { ref: Ref<HTMLSpanElement> }) { return ( <span ref={ref} className={styles.cursor} data-state="hidden" aria-hidden="true"> <span className={styles.vertical}> <span className={styles.ring} /> <svg className={styles.arrow} viewBox="0 0 20 20"> {/* Its shadow: the arrow again, offset and faint (a CSS filter would cost a large GPU surface while the cursor moves). */} <path className={styles.shade} d={ARROW} transform="translate(0.3 1.1)" /> <path className={styles.body} d={ARROW} />/* The demo's cursor (demo-cursor.tsx): an arrow whose tip sits at (--x, --y) in the stage. The outer box carries the horizontal move and the inner one the vertical move, eased differently, which bends the path into a gentle arc. Its colours are the stage's tokens (pinned-list.module.css). */.cursor { --x: 0px; --y: 0px; --move: 0ms; position: absolute; top: 0; left: 0; z-index: 10; pointer-events: none; opacity: 0; transform: translateX(var(--x)); transition: opacity 0.25s ease, transform var(--move) cubic-bezier(0.45, 0, 0.25, 1);}.cursor[data-state='shown'],.cursor[data-state='pressed'] { opacity: 1;}'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 pin is a toggle button (aria-pressed) labelled with its place (“Pin Saltmarsh Kitchen”). The two sections are lists labelled by their headings.
- Focus stays on the pin while its row moves to the other section, so Tab carries on from there.
- Pins and unpins are announced in a polite live region. The pin glyph is dark on yellow, for contrast.
- Under reduced motion rows change places at once, and there is no demo.