Layout morph
AdvancedNewOne collection flies between grid, masonry, list, stack, carousel and a 3D helix.
layout-morph/layout-morph.tsxOne collection, six
ways to browse
The Kiln & Loom autumn edit: 14 pieces from 10 makers. Switch the view and every card travels to its new place.
Click a card to open it.
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 small collection (a dozen to a few dozen items) that people browse more than search: a shop edit, a portfolio, a moodboard.
- When the same items are worth seeing in more than one way: a dense grid to scan, a list to compare, a fan or a ring to wander.
- When keeping one’s place matters: every card travels to its new spot, so the eye follows it instead of starting over.
- Items with a short title and one value (a price, a craft), some with a photo and some without, mixed in one set.
When not to use
- Hundreds of items, or items people look up by name. Use a searchable, paginated grid or table.
- Data with many columns to sort and compare. Use a table; the List view shows two lines a card.
- A single fixed layout that never changes. Use a plain CSS grid: the morph’s cost is in the switching.
- Places where motion would distract (a checkout, a form). Under reduced motion the views still switch, but at once.
You can add this component with the shadcn CLI or by hand:
Into components/layout-morph of any React project, with no setup:
npx shadcn@latest add https://brunodelorence.com/r/layout-morph.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:
- Copy the media files
To the same paths under your project’s public folder:
/experiments/layout-morph/2.webp/experiments/layout-morph/3.webp/experiments/layout-morph/4.webp/experiments/layout-morph/6.webp/experiments/layout-morph/8.webp/experiments/layout-morph/9.webp/experiments/layout-morph/11.webp/experiments/layout-morph/12.webp/experiments/layout-morph/13.webp/experiments/layout-morph/15.webp/experiments/layout-morph/17.webp/experiments/layout-morph/18.webp/experiments/layout-morph/20.webp/experiments/layout-morph/22.webp/experiments/layout-morph/23.webp
- 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 { LayoutMorph } from './layout-morph';import { ITEMS } from './items';export function Collection() { return <LayoutMorph items={ITEMS} autoplay={false} />;}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.
Twenty-four persistent cards from the Kiln & Loom autumn edit share six layouts. Change the view and the cards travel to their new positions, staggered around the chosen card. Drag the ring or helix, pull a card out of the fan and open its details without losing its place.
- One data set supplies all 24 cards, including 14 pieces and 10 makers. Every card keeps its DOM identity and its image between layouts.
- The layout function projects each card into a position, width, height, angle and depth. CSS interpolates those poses with a delay based on the distance from the active card.
- Grid, masonry and list scroll inside the stage. Stack arranges two fans; carousel and helix project the cards around a 3D axis and rotate one shared camera when dragged.
- A drag is separated from a click by a movement threshold. Horizontal or Shift-wheel gestures rotate a ring; vertical wheel gestures continue to scroll the page. The ring settles to a neighbouring card after release.
- Opening a card measures its current projected rectangle and uses that rectangle as the first frame of the detail panel. Closing travels back to the original card and restores its keyboard focus. Saved cards are kept in local component state.
- Every interface colour is a light-dark() token on the root, so the gallery follows the page’s color-scheme, or the colorScheme prop; the photos, and the captions over them, stay as they are.
| Prop | Type | Default | Description |
|---|---|---|---|
items | readonly CollectionItem[] | ITEMS | Stable ids, titles, Piece or Maker type, subtitle, value and optional local image. |
defaultView | View | 'Grid' | The view to start in: Grid, Masonry, List, Stack, Carousel or Helix. Setting it turns the demo off. |
defaultOpen | number | null | null | The index of a card to start open in its detail dialog, drawn open with no flight and no focus taken. Setting it turns the demo off. |
defaultSaved | readonly string[] | — | The ids of the items that start saved to the collection. |
onViewChange | (view: View) => void | — | Called with the view picked on the switch or with the arrow keys. |
onOpenChange | (item: CollectionItem | null) => void | — | Called with the card’s item when one opens, and with null when it closes. |
onSavedChange | (id: string, saved: boolean) => void | — | Called when Save to collection is pressed, with the item’s id and whether it is saved now. |
autoplay | boolean | true | Cycle through the layouts while visible 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 | — | An additional class for the collection shell. |
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, useLayoutEffect, useRef, useState, type CSSProperties, type PointerEvent,} from 'react';import { useDemo } from './use-demo';import { Icon } from './icons';import { ITEMS, type CollectionItem } from './items';import styles from './layout-morph.module.css';export const VIEWS = ['Grid', 'Masonry', 'List', 'Stack', 'Carousel', 'Helix'] as const;export type View = (typeof VIEWS)[number];type Pose = { x: number; y: number; w: number; h: number;/* Layout morph (layout-morph.tsx): a light page with the gallery on a white stage, or at night a near-black page with the stage and its cards a step lighter each. The photos stay as they are. */.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 page and the switch; the stage; a card; a card in the list; the open card; the view that is on. */ --surface: #f8f8f8; --surface-2: #fff; --card: #f9f9f9; --card-list: #fafafa; --dialog: #fff; --chip: #fff; /* Text; the view that is on; a listed card's title; the intro; the other views; the hint, the open card's kind and its terms; a card's value; a listed card's maker and the open card's. */ --ink: #151515; --ink-chip: #333; --ink-list: #171717; --ink-muted: #7b7b7b; --ink-off: #8c8c8c; --ink-faint: #999; --ink-card: #898989; --ink-soft: #888;/** The sample catalogue: pieces and their makers, some with a photo. */export type CollectionItem = { id: string; title: string; kind: 'Piece' | 'Maker'; subtitle: string; value: string; image?: string;};export const ITEMS: CollectionItem[] = [ { id: '1', title: 'Travertine tray', kind: 'Piece', subtitle: 'By Jasmine Brooks', value: '$96', }, { id: '2', title: 'Emma Collins', kind: 'Maker', subtitle: 'Ceramicist in Portland', value: 'Ceramicist', image: '/experiments/layout-morph/2.webp','use client';import { useCallback, useEffect, useRef, useState } from 'react';/** A visible demo may play until someone takes over. Restarting the preview resets it. */export function useDemo<T extends HTMLElement>() { const root = useRef<T>(null); const [visible, setVisible] = useState(false); const [reduced, setReduced] = useState(true); const [manual, setManual] = useState(false); useEffect(() => { const element = root.current; if (!element) return; let intersecting = false; const update = () => setVisible(intersecting && !document.hidden); const observer = new IntersectionObserver( ([entry]) => { intersecting = entry.isIntersecting; update(); }, { threshold: 0.15 }, ); observer.observe(element); document.addEventListener('visibilitychange', update);/** The small line icons this component draws, on a 24-unit grid in the current colour. */export function Icon({ name, size = 16 }: { name: string; size?: number }) { const paths: Record<string, React.ReactNode> = { grid: ( <> <rect x="3" y="3" width="6" height="6" rx="1" /> <rect x="13" y="3" width="6" height="6" rx="1" /> <rect x="3" y="13" width="6" height="6" rx="1" /> <rect x="13" y="13" width="6" height="6" rx="1" /> </> ), masonry: ( <> <rect x="3" y="3" width="6" height="10" rx="1" /> <rect x="13" y="3" width="6" height="6" rx="1" /> <rect x="3" y="17" width="6" height="4" rx="1" /> <rect x="13" y="13" width="6" height="8" rx="1" /> </> ), list: ( <> <path d="M9 5h12M9 12h12M9 19h12" /> <path d="M3 5h1M3 12h1M3 19h1" /> </>- The view switch is a labelled radio group. Arrow keys, Home and End move between its six views.
- Cards have a single roving tab stop. Up, Down, Home and End move between cards; Left and Right change layout while keeping the focused card; Enter opens it.
- The detail panel is a modal dialog. The gallery becomes inert, focus stays inside, Escape closes it and focus returns to the original card.
- Reduced motion applies each new layout immediately and opens the detail panel without flying. The automatic demonstration pauses offscreen and stops when the reader takes over.