# Layout morph

> One collection flies between grid, masonry, list, stack, carousel and a 3D helix.

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.

A React component by Bruno Delorence: https://brunodelorence.com/components/layout-morph. MIT License, Copyright (c) 2026 Bruno Delorence.

Category: Motion.

## Anatomy

1. **Header**: A title and a line that counts the pieces and the makers in `items`, so it stays true to the set it is given.
2. **View switch**: A radio group of six views, Grid to Helix. Left and right arrows move through it; on a narrow root only the icons show.
3. **View on**: The checked view, lifted on a chip with a soft shadow; its fill and shadow ease in 250 ms.
4. **Hint**: One line that says what the current view takes: click, hover the fan, or drag to rotate.
5. **Stage**: The window the cards move in, with depth for the 3D views. Grid, Masonry and List scroll inside it; the rest fit it.
6. **Text card**: A card with no photo shows its title at the top and its value at the bottom. Switching views moves it in 850 ms, staggered from the card last touched.
7. **Card words**: Title and value; in the List view the value gives way to the subtitle.
8. **Photo card**: The photo fills the card; its words rise over a dark gradient on hover or focus.

## 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.

## Installation

Install it with the shadcn CLI, into components/layout-morph of any React project:

```sh
npx shadcn@latest add https://brunodelorence.com/r/layout-morph.json
```

Or copy all the source files below into one folder of your project. Either way it needs React 19 and a bundler with CSS Modules (Next.js, Vite).

It has a dark colour scheme. Its colours are CSS light-dark() pairs, kept inside @supports so that builds which rewrite light-dark() for older browsers leave them alone: 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.

Copy these media files to the same paths under your public folder:

- https://brunodelorence.com/experiments/layout-morph/2.webp
- https://brunodelorence.com/experiments/layout-morph/3.webp
- https://brunodelorence.com/experiments/layout-morph/4.webp
- https://brunodelorence.com/experiments/layout-morph/6.webp
- https://brunodelorence.com/experiments/layout-morph/8.webp
- https://brunodelorence.com/experiments/layout-morph/9.webp
- https://brunodelorence.com/experiments/layout-morph/11.webp
- https://brunodelorence.com/experiments/layout-morph/12.webp
- https://brunodelorence.com/experiments/layout-morph/13.webp
- https://brunodelorence.com/experiments/layout-morph/15.webp
- https://brunodelorence.com/experiments/layout-morph/17.webp
- https://brunodelorence.com/experiments/layout-morph/18.webp
- https://brunodelorence.com/experiments/layout-morph/20.webp
- https://brunodelorence.com/experiments/layout-morph/22.webp
- https://brunodelorence.com/experiments/layout-morph/23.webp

## Usage

```tsx
import { LayoutMorph } from './layout-morph';
import { ITEMS } from './items';

export function Collection() {
  return <LayoutMorph items={ITEMS} autoplay={false} />;
}
```

## States

- **Grid**: Equal cards, four across (three under 600 px, two under 360). The stage scrolls.
- **Masonry**: Photo cards tall, text cards short, each placed in the shortest column.
- **List**: One row a card: a square photo, the title and the subtitle.
- **Stack**: A fan of twelve a row; hovering a card pulls it up out of the fan.
- **Carousel**: Every card on one ring, turned by dragging or a sideways scroll, settling on the nearest card.
- **Helix**: Eight cards a turn, climbing as they go; drags and settles like the carousel.
- **Card open**: The card grows into a dialog in 520 ms; Escape or the close button flies it back to its place.
- **Saved**: `Save to collection` turns to a check and `Saved to collection`; pressing again takes it back.

## How it works

- 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.

## API reference

- **items** (`readonly CollectionItem[]`, default `ITEMS`): Stable ids, titles, Piece or Maker type, subtitle, value and optional local image.
- **defaultView** (`View`, default `'Grid'`): The view to start in: Grid, Masonry, List, Stack, Carousel or Helix. Setting it turns the demo off.
- **defaultOpen** (`number | null`, default `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`, default `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.

## Accessibility

- 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.

## Source

### layout-morph/layout-morph.tsx

```tsx
// 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;
  rotate?: number;
  yaw?: number;
  z?: number;
};
const isRing = (view: View) => view === 'Carousel' || view === 'Helix';

/** Every layout projects the same persistent cards; switching never remounts an image. */
function layout(
  view: View,
  width: number,
  height: number,
  items: readonly CollectionItem[],
): { poses: Pose[]; total: number } {
  const cols = width >= 600 ? 4 : width >= 360 ? 3 : 2;
  const gap = 12,
    w = (width - 36 - gap * (cols - 1)) / cols;
  const bottoms = Array(cols).fill(18) as number[];
  const poses = items.map((item, i): Pose => {
    if (view === 'Grid')
      return {
        x: 18 + (i % cols) * (w + gap),
        y: 18 + Math.floor(i / cols) * (w * 1.12 + gap),
        w,
        h: w * 1.12,
      };
    if (view === 'Masonry') {
      const col = bottoms.indexOf(Math.min(...bottoms)),
        h = w * (item.image ? 1.32 : 0.85);
      const pose = { x: 18 + col * (w + gap), y: bottoms[col], w, h };
      bottoms[col] += h + gap;
      return pose;
    }
    if (view === 'List') return { x: 18, y: 18 + i * 78, w: width - 36, h: 66 };
    if (view === 'Stack') {
      const angle = ((i % 12) - 5.5) * 6.5,
        a = (angle * Math.PI) / 180,
        cardW = Math.min(145, width * 0.24);
      return {
        x: width / 2 - cardW / 2 + Math.sin(a) * width * 0.36,
        y: height * 0.27 + Math.floor(i / 12) * height * 0.28 + (1 - Math.cos(a)) * 100,
        w: cardW,
        h: cardW * 1.18,
        rotate: angle,
        z: i % 12,
      };
    }
    const cardW = Math.min(145, width * 0.27),
      yaw = i * (view === 'Carousel' ? 360 / items.length : 360 / 8);
    return {
      x: width / 2 - cardW / 2,
      y:
        view === 'Carousel'
          ? height / 2 - cardW * 0.63
          : height / 2 + (i - (items.length - 1) / 2) * 16 - cardW * 0.6,
      w: cardW,
      h: cardW * 1.23,
      yaw,
      z: view === 'Carousel' ? Math.max(190, width * 0.47) : Math.min(200, width * 0.35),
    };
  });
  return { poses, total: Math.max(height, ...poses.map((p) => p.y + p.h + 18)) };
}

export function LayoutMorph({
  items = ITEMS,
  autoplay = true,
  colorScheme,
  className = '',
  defaultView,
  defaultOpen,
  defaultSaved,
  onViewChange,
  onOpenChange,
  onSavedChange,
}: {
  items?: readonly CollectionItem[];
  autoplay?: boolean;
  /** The view to start in, instead of Grid. Setting it turns the demo off. */
  defaultView?: View;
  /** The index of a card to start open, in its detail dialog. Setting it turns the demo off. */
  defaultOpen?: number | null;
  /** The ids of the items that start saved to the collection. */
  defaultSaved?: readonly string[];
  /** A view picked on the switch or with the arrow keys. */
  onViewChange?: (view: View) => void;
  /** A card opened (its item), or the open card closed (null). */
  onOpenChange?: (item: CollectionItem | null) => void;
  /** Save to collection pressed: the item's id and whether it is saved now. */
  onSavedChange?: (id: string, saved: boolean) => void;
  /** Light or dark colours, or the reader's ('system'); without it, the page's color-scheme. */
  colorScheme?: 'light' | 'dark' | 'system';
  className?: string;
}) {
  const { root, playing, reduced, take } = useDemo<HTMLDivElement>();
  const [view, setView] = useState<View>(defaultView ?? 'Grid');
  const [size, setSize] = useState({ width: 640, height: 550 });
  const [origin, setOrigin] = useState(0);
  const [focused, setFocused] = useState(0);
  const [opened, setOpened] = useState<number | null>(defaultOpen ?? null);
  const [closing, setClosing] = useState(false);
  const [saved, setSaved] = useState<ReadonlySet<string>>(() => new Set(defaultSaved));
  const stage = useRef<HTMLDivElement>(null),
    camera = useRef<HTMLDivElement>(null),
    dialog = useRef<HTMLDivElement>(null),
    closeButton = useRef<HTMLButtonElement>(null);
  const cards = useRef<Array<HTMLButtonElement | null>>([]),
    tabs = useRef<Array<HTMLButtonElement | null>>([]);
  const startBox = useRef<DOMRect | null>(null);
  /** Opened by defaultOpen: drawn open as it mounts, with no flight and no focus taken. */
  const openedAtMount = useRef(defaultOpen != null);
  const held = defaultView !== undefined || defaultOpen != null;
  const flight = useRef<Animation | null>(null);
  const rotation = useRef(0),
    settle = useRef(0),
    dragged = useRef(false);
  const gesture = useRef<{
    id: number;
    x: number;
    y: number;
    rotation: number;
    scroll: number;
  } | null>(null);
  const liveView = useRef(view);
  liveView.current = view;
  useEffect(() => {
    const el = stage.current;
    if (!el) return;
    const resize = new ResizeObserver(() =>
      setSize({ width: el.clientWidth, height: el.clientHeight }),
    );
    resize.observe(el);
    return () => resize.disconnect();
  }, []);
  useEffect(
    () => () => {
      cancelAnimationFrame(settle.current);
      flight.current?.cancel();
    },
    [],
  );
  const spin = (value: number) => {
    rotation.current = value;
    camera.current?.style.setProperty('--orbit', `${value}deg`);
  };
  const switchView = (next: View, index = focused) => {
    setOrigin(index);
    setView(next);
    onViewChange?.(next);
    spin(0);
    if (stage.current) stage.current.scrollTop = 0;
  };
  useEffect(() => {
    if (!autoplay || held || !playing || opened !== null) return;
    const timer = setInterval(
      () => setView((v) => VIEWS[(VIEWS.indexOf(v) + 1) % VIEWS.length]),
      4200,
    );
    return () => clearInterval(timer);
  }, [autoplay, held, playing, opened]);
  useEffect(() => {
    const el = stage.current;
    if (!el) return;
    const wheel = (e: WheelEvent) => {
      if (!isRing(liveView.current) || (!e.shiftKey && Math.abs(e.deltaX) < Math.abs(e.deltaY)))
        return;
      e.preventDefault();
      take();
      spin(rotation.current - (e.deltaX || e.deltaY) * 0.3);
    };
    el.addEventListener('wheel', wheel, { passive: false });
    return () => el.removeEventListener('wheel', wheel);
  }, [take]);
  const open = (i: number) => {
    take();
    startBox.current = cards.current[i]?.getBoundingClientRect() ?? null;
    setClosing(false);
    setOpened(i);
    onOpenChange?.(items[i]);
  };
  useLayoutEffect(() => {
    if (opened === null || !dialog.current) return;
    if (openedAtMount.current) {
      openedAtMount.current = false;
      return;
    }
    closeButton.current?.focus();
    if (reduced || !startBox.current) return;
    const box = dialog.current.getBoundingClientRect(),
      from = startBox.current;
    flight.current = dialog.current.animate(
      [
        {
          transform: `translate(${from.x - box.x}px,${from.y - box.y}px) scale(${from.width / box.width},${from.height / box.height})`,
          borderRadius: '16px',
          opacity: 0.65,
        },
        { transform: 'none', borderRadius: '24px', opacity: 1 },
      ],
      { duration: 520, easing: 'cubic-bezier(.16,1,.3,1)' },
    );
  }, [opened, reduced]);
  const close = () => {
    if (opened === null || closing) return;
    const index = opened,
      element = dialog.current,
      target = cards.current[index];
    const finish = () => {
      setOpened(null);
      setClosing(false);
      onOpenChange?.(null);
      // Wait until React removes inert from the gallery before restoring its focus.
      requestAnimationFrame(() => target?.focus());
    };
    if (reduced || !element || !target) {
      finish();
      return;
    }
    flight.current?.cancel();
    setClosing(true);
    const from = element.getBoundingClientRect(),
      to = target.getBoundingClientRect();
    flight.current = element.animate(
      [
        { transform: 'none', opacity: 1 },
        {
          transform: `translate(${to.x - from.x}px,${to.y - from.y}px) scale(${to.width / from.width},${to.height / from.height})`,
          opacity: 0,
        },
      ],
      { duration: 330, easing: 'cubic-bezier(.4,0,.3,1)', fill: 'forwards' },
    );
    flight.current.finished.then(finish).catch(() => {});
  };
  const poses = layout(view, size.width, size.height, items);
  const pointerDown = (e: PointerEvent<HTMLDivElement>) => {
    if (e.button !== 0) return;
    cancelAnimationFrame(settle.current);
    dragged.current = false;
    gesture.current = {
      id: e.pointerId,
      x: e.clientX,
      y: e.clientY,
      rotation: rotation.current,
      scroll: e.currentTarget.scrollTop,
    };
    const target = (e.target as HTMLElement).closest('button') ?? e.currentTarget;
    target.setPointerCapture(e.pointerId);
  };
  const pointerMove = (e: PointerEvent<HTMLDivElement>) => {
    const g = gesture.current;
    if (!g || g.id !== e.pointerId) return;
    const dx = e.clientX - g.x,
      dy = e.clientY - g.y;
    if (Math.hypot(dx, dy) < 5 && !dragged.current) return;
    dragged.current = true;
    if (isRing(view)) spin(g.rotation + dx * 0.45);
    else if (view !== 'Stack') e.currentTarget.scrollTop = g.scroll - dy;
  };
  const pointerEnd = (e: PointerEvent<HTMLDivElement>) => {
    const g = gesture.current;
    if (!g || g.id !== e.pointerId) return;
    gesture.current = null;
    if (!dragged.current || !isRing(view) || reduced) return;
    const step = view === 'Carousel' ? 360 / items.length : 45,
      from = rotation.current,
      to = Math.round(from / step) * step,
      start = performance.now();
    const tick = (now: number) => {
      const p = Math.min(1, (now - start) / 420);
      spin(from + (to - from) * (1 - (1 - p) ** 3));
      if (p < 1) settle.current = requestAnimationFrame(tick);
    };
    settle.current = requestAnimationFrame(tick);
  };
  const active = opened === null ? null : items[opened];
  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      data-color-scheme={colorScheme}
      data-layout-morph
      data-reduced={reduced || undefined}
      onPointerDownCapture={take}
      onKeyDownCapture={take}
    >
      <div inert={opened !== null || undefined}>
        <header className={styles.header} data-part="header">
          <h2>
            One collection, six
            <br />
            ways to browse
          </h2>
          <p>
            The Kiln &amp; Loom autumn edit: {items.filter((item) => item.kind === 'Piece').length}{' '}
            pieces from {items.filter((item) => item.kind === 'Maker').length} makers. Switch the
            view and every card travels to its new place.
          </p>
        </header>
        <div
          className={styles.switch}
          role="radiogroup"
          aria-label="Gallery view"
          data-part="switch"
        >
          {VIEWS.map((v, i) => (
            <button
              ref={(el) => {
                tabs.current[i] = el;
              }}
              key={v}
              type="button"
              role="radio"
              data-part="view"
              aria-label={v}
              aria-checked={view === v}
              tabIndex={view === v ? 0 : -1}
              onClick={() => switchView(v)}
              onKeyDown={(e) => {
                if (['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(e.key)) {
                  e.preventDefault();
                  const n =
                    e.key === 'Home'
                      ? 0
                      : e.key === 'End'
                        ? 5
                        : (i + (e.key === 'ArrowRight' ? 1 : 5)) % 6;
                  switchView(VIEWS[n]);
                  tabs.current[n]?.focus();
                }
              }}
            >
              <Icon name={v.toLowerCase()} />
              <span>{v}</span>
            </button>
          ))}
        </div>
        <p className={styles.hint} data-part="hint">
          {isRing(view)
            ? 'Drag to rotate. Click a card to open it.'
            : view === 'Stack'
              ? 'Hover the fan to pull a card up.'
              : 'Click a card to open it.'}
        </p>
        <div
          ref={stage}
          className={styles.stage}
          data-part="stage"
          data-view={view}
          role="group"
          aria-roledescription="gallery"
          aria-label="Collection. Arrow up or down changes card; left or right changes layout; Enter opens a card."
          onPointerDown={pointerDown}
          onPointerMove={pointerMove}
          onPointerUp={pointerEnd}
          onPointerCancel={() => {
            gesture.current = null;
          }}
          onClickCapture={(e) => {
            if (dragged.current && e.detail) {
              e.preventDefault();
              e.stopPropagation();
              dragged.current = false;
            }
          }}
        >
          <div
            style={{ height: isRing(view) || view === 'Stack' ? size.height : poses.total }}
            aria-hidden="true"
          />
          <div ref={camera} className={styles.camera}>
            {items.map((item, i) => {
              const p = poses.poses[i];
              return (
                <button
                  key={item.id}
                  ref={(el) => {
                    cards.current[i] = el;
                  }}
                  type="button"
                  className={styles.card}
                  data-part="card"
                  data-kind={item.kind}
                  data-photo={item.image ? true : undefined}
                  aria-label={`${item.title}, ${item.subtitle}, ${item.value}`}
                  tabIndex={focused === i ? 0 : -1}
                  style={
                    {
                      width: p.w,
                      height: p.h,
                      '--pose': `translate3d(${p.x}px,${p.y}px,0) rotate(${p.rotate ?? 0}deg) rotateY(${p.yaw ?? 0}deg) translateZ(${p.z ?? 0}px)`,
                      '--delay': reduced ? '0ms' : `${Math.min(Math.abs(i - origin) * 13, 200)}ms`,
                      zIndex: view === 'Stack' ? 24 - Math.abs((i % 12) - 5) : undefined,
                    } as CSSProperties
                  }
                  onPointerEnter={() => setOrigin(i)}
                  onFocus={() => {
                    setFocused(i);
                    setOrigin(i);
                  }}
                  onClick={() => open(i)}
                  onKeyDown={(e) => {
                    if (['ArrowLeft', 'ArrowRight'].includes(e.key)) {
                      e.preventDefault();
                      switchView(
                        VIEWS[(VIEWS.indexOf(view) + (e.key === 'ArrowRight' ? 1 : 5)) % 6],
                        i,
                      );
                    } else if (['ArrowUp', 'ArrowDown', 'Home', 'End'].includes(e.key)) {
                      e.preventDefault();
                      const n =
                        e.key === 'Home'
                          ? 0
                          : e.key === 'End'
                            ? items.length - 1
                            : (i + (e.key === 'ArrowDown' ? 1 : items.length - 1)) % items.length;
                      setFocused(n);
                      cards.current[n]?.focus();
                      if (!isRing(view) && view !== 'Stack')
                        cards.current[n]?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
                    }
                  }}
                >
                  {item.image && <img src={item.image} alt="" draggable={false} />}
                  <div className={styles.cardCopy} data-part="copy">
                    <strong>{item.title}</strong>
                    <span>{view === 'List' ? item.subtitle : item.value}</span>
                  </div>
                </button>
              );
            })}
          </div>
        </div>
      </div>
      {active && (
        <div
          className={styles.overlay}
          data-part="overlay"
          onKeyDown={(e) => {
            if (e.key === 'Escape') {
              e.preventDefault();
              close();
            }
            if (e.key === 'Tab') {
              const controls = [...dialog.current!.querySelectorAll<HTMLButtonElement>('button,a')];
              const first = controls[0],
                last = controls.at(-1);
              if (e.shiftKey && document.activeElement === first) {
                e.preventDefault();
                last?.focus();
              } else if (!e.shiftKey && document.activeElement === last) {
                e.preventDefault();
                first.focus();
              }
            }
          }}
        >
          <div
            ref={dialog}
            className={styles.dialog}
            data-part="dialog"
            role="dialog"
            aria-modal="true"
            aria-label={active.title}
          >
            <button
              ref={closeButton}
              className={styles.close}
              data-part="close"
              type="button"
              aria-label={`Close ${active.title}`}
              onClick={close}
            >
              <Icon name="close" />
            </button>
            {active.image && (
              <img
                className={styles.detailImage}
                data-part="photo"
                src={active.image}
                alt={active.title}
              />
            )}
            <div className={styles.detailCopy} data-part="detail">
              <small>{active.kind}</small>
              <h3>{active.title}</h3>
              <p>{active.subtitle}</p>
              <strong className={styles.value}>{active.value}</strong>
              <p className={styles.note}>
                {active.kind === 'Piece'
                  ? 'Part of the Kiln & Loom autumn collection. A small edit of objects made with care.'
                  : 'Meet the makers behind this collection. Small studios, thoughtful materials and a personal approach.'}
              </p>
              <dl>
                <div>
                  <dt>Collection</dt>
                  <dd>Autumn edit</dd>
                </div>
                <div>
                  <dt>Type</dt>
                  <dd>{active.kind === 'Piece' ? 'Made by hand' : 'Independent maker'}</dd>
                </div>
              </dl>
              <button
                className={styles.save}
                data-part="save"
                type="button"
                aria-pressed={saved.has(active.id)}
                onClick={() => {
                  const now = !saved.has(active.id);
                  onSavedChange?.(active.id, now);
                  setSaved((before) => {
                    const next = new Set(before);
                    if (now) next.add(active.id);
                    else next.delete(active.id);
                    return next;
                  });
                }}
              >
                <Icon name={saved.has(active.id) ? 'check' : 'heart'} />
                {saved.has(active.id) ? 'Saved to collection' : 'Save to collection'}
              </button>
            </div>
          </div>
        </div>
      )}
    </div>
  );
}
```

### layout-morph/layout-morph.module.css

```css
/* 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 switch's, the stage's and the cards' edges; the open card's; the close button's; the
     rule over the open card's terms; the stage's scrollbar. */
  --line: #e9e9e9;
  --dialog-line: #e8e8e8;
  --close-line: #e4e4e4;
  --rule: #eee;
  --scrollbar: #e6e6e6;
  /* The veil behind the open card, and its close button. */
  --overlay: #f8f8f8ec;
  --close: #ffffffeb;
  /* The save button and its text; the focus ring. */
  --accent: #1a1a1a;
  --accent-ink: #fff;
  --focus: #6d9ded;
  /* A card lifted and the open card; a card at rest; the view that is on. */
  --shadow: #0000000c;
  --shadow-near: #00000004;
  --chip-shadow: #00000012;
  position: relative;
  width: 100%;
  padding: 26px 22px 20px;
  background: var(--surface);
  color: var(--ink);
  border-radius: 20px;
  font:
    13px/1.4 'Library Inter',
    Arial,
    sans-serif;
  container-type: inline-size;
  isolation: isolate;
  box-sizing: border-box;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --surface: light-dark(#f8f8f8, #121214);
    --surface-2: light-dark(#fff, #19191b);
    --card: light-dark(#f9f9f9, #232325);
    --card-list: light-dark(#fafafa, #232325);
    --dialog: light-dark(#fff, #1c1c1e);
    --chip: light-dark(#fff, #2c2c2f);
    --ink: light-dark(#151515, #f0f0f0);
    --ink-chip: light-dark(#333, #f0f0f0);
    --ink-list: light-dark(#171717, #f0f0f0);
    --ink-muted: light-dark(#7b7b7b, #9a9a9f);
    --ink-off: light-dark(#8c8c8c, #8e8e93);
    --ink-faint: light-dark(#999, #8a8a8f);
    --ink-card: light-dark(#898989, #9a9a9f);
    --ink-soft: light-dark(#888, #9a9a9f);
    --line: light-dark(#e9e9e9, #2a2a2d);
    --dialog-line: light-dark(#e8e8e8, #2e2e31);
    --close-line: light-dark(#e4e4e4, #3a3a3d);
    --rule: light-dark(#eee, #2e2e31);
    --scrollbar: light-dark(#e6e6e6, #3a3a3d);
    --overlay: light-dark(#f8f8f8ec, rgb(18 18 20 / 0.925));
    --close: light-dark(#ffffffeb, rgb(44 44 46 / 0.92));
    --accent: light-dark(#1a1a1a, #f2f2f2);
    --accent-ink: light-dark(#fff, #111);
    --focus: light-dark(#6d9ded, #7aa7f0);
    --shadow: light-dark(#0000000c, rgb(0 0 0 / 0.5));
    --shadow-near: light-dark(#00000004, rgb(0 0 0 / 0.25));
    --chip-shadow: light-dark(#00000012, rgb(0 0 0 / 0.4));
  }
}
/* The colorScheme prop. Without it the root sets nothing and inherits the page's color-scheme. */
.root[data-color-scheme='light'] {
  color-scheme: light;
}
.root[data-color-scheme='dark'] {
  color-scheme: dark;
}
.root[data-color-scheme='system'] {
  color-scheme: light dark;
}
.root * {
  box-sizing: border-box;
}
.root button {
  font: inherit;
  color: inherit;
  cursor: pointer;
}
.root button:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 3px;
}
.header h2 {
  font-size: clamp(26px, 4.7cqw, 43px);
  font-weight: 500;
  line-height: 1.05;
  letter-spacing: -0.055em;
  margin: 0 0 12px;
}
.header p {
  max-width: 560px;
  color: var(--ink-muted);
  font-size: 12px;
  line-height: 1.5;
  margin: 0 0 25px;
}
.switch {
  display: flex;
  align-items: center;
  gap: 2px;
  padding: 4px;
  border: 1px solid var(--line);
  border-radius: 22px;
  background: var(--surface);
}
.switch button {
  display: flex;
  gap: 6px;
  align-items: center;
  justify-content: center;
  flex: 1;
  min-width: 0;
  padding: 8px 6px;
  border: 0;
  background: transparent;
  border-radius: 20px;
  color: var(--ink-off);
  font-size: 11px;
  white-space: nowrap;
  transition:
    background 0.25s,
    box-shadow 0.25s;
}
.switch button[aria-checked='true'] {
  background: var(--chip);
  color: var(--ink-chip);
  box-shadow: 0 1px 4px var(--chip-shadow);
}
.hint {
  font-size: 10px;
  color: var(--ink-faint);
  margin: 10px 0 12px;
  min-height: 14px;
}
.stage {
  height: var(--morph-height, 550px);
  position: relative;
  overflow: auto hidden;
  border: 1px solid var(--line);
  background: var(--surface-2);
  border-radius: 28px;
  perspective: 1050px;
  overscroll-behavior: contain;
  scrollbar-width: thin;
  scrollbar-color: var(--scrollbar) transparent;
  touch-action: pan-y;
}
.stage[data-view='Carousel'],
.stage[data-view='Helix'],
.stage[data-view='Stack'] {
  overflow: hidden;
  touch-action: pan-y;
}
.camera {
  position: absolute;
  inset: 0;
  transform-style: preserve-3d;
  pointer-events: none;
}
.stage[data-view='Carousel'] .camera {
  transform: translateZ(-260px) rotateY(var(--orbit, 0deg));
}
.stage[data-view='Helix'] .camera {
  transform: translateZ(-130px) rotateX(-8deg) rotateY(var(--orbit, 0deg));
}
.card {
  position: absolute;
  top: 0;
  left: 0;
  border: 1px solid var(--line);
  border-radius: 15px;
  background: var(--card);
  text-align: left;
  overflow: hidden;
  padding: 0;
  transform: var(--pose) translateY(var(--lift, 0px));
  transform-style: preserve-3d;
  backface-visibility: hidden;
  pointer-events: auto;
  transition:
    transform 0.85s cubic-bezier(0.2, 0.8, 0.2, 1),
    width 0.65s cubic-bezier(0.2, 0.8, 0.2, 1),
    height 0.65s cubic-bezier(0.2, 0.8, 0.2, 1),
    box-shadow 0.25s;
  transition-delay: var(--delay, 0ms);
  box-shadow: 0 1px 2px var(--shadow-near);
  user-select: none;
  will-change: auto;
}
.card:hover,
.card:focus-visible {
  --lift: -7px;
  box-shadow: 0 9px 20px var(--shadow);
}
.stage[data-view='Stack'] .card:hover,
.stage[data-view='Stack'] .card:focus-visible {
  --lift: -28px;
  z-index: 50 !important;
}
.card img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
  pointer-events: none;
}
.cardCopy {
  position: absolute;
  inset: 12px;
  display: flex;
  flex-direction: column;
  justify-content: space-between;
  gap: 8px;
  font-size: 12px;
}
.cardCopy strong {
  font-weight: 500;
  letter-spacing: -0.02em;
  max-width: 94%;
  line-height: 1.15;
}
.cardCopy span {
  font-size: 10px;
  color: var(--ink-card);
}
.card:has(img) .cardCopy {
  inset: auto 0 0;
  padding: 30px 11px 12px;
  background: linear-gradient(transparent, #00000060);
  color: white;
  opacity: 0;
  transform: translateY(8px);
  transition:
    opacity 0.2s,
    transform 0.2s;
}
.card:has(img) .cardCopy span {
  color: #fffd;
}
.card:hover .cardCopy,
.card:focus-visible .cardCopy {
  opacity: 1;
  transform: none;
}
.stage[data-view='List'] .card {
  background: var(--card-list);
  display: flex;
  border-radius: 13px;
}
.stage[data-view='List'] .card img {
  width: 66px;
  height: 66px;
  flex-shrink: 0;
}
.stage[data-view='List'] .cardCopy {
  position: static !important;
  inset: unset !important;
  display: flex;
  flex-direction: column;
  justify-content: center;
  gap: 4px;
  background: none !important;
  opacity: 1 !important;
  transform: none !important;
  color: var(--ink-list) !important;
  padding: 10px 15px !important;
  font-size: 12px;
}
.stage[data-view='List'] .cardCopy span {
  color: var(--ink-soft) !important;
}
.overlay {
  position: absolute;
  inset: 0;
  z-index: 10;
  background: var(--overlay);
  backdrop-filter: blur(4px);
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 22px;
  border-radius: 20px;
  overflow: hidden;
}
.dialog {
  position: relative;
  transform-origin: 0 0;
  width: 100%;
  max-width: 540px;
  max-height: calc(100% - 16px);
  overflow: auto;
  border: 1px solid var(--dialog-line);
  background: var(--dialog);
  border-radius: 24px;
  box-shadow: 0 16px 55px var(--shadow);
  display: grid;
  grid-template-columns: 1fr 1fr;
}
.dialog:not(:has(.detailImage)) {
  grid-template-columns: 1fr;
}
.close {
  position: absolute;
  top: 12px;
  right: 12px;
  z-index: 1;
  border: 1px solid var(--close-line);
  background: var(--close);
  width: 31px;
  height: 31px;
  border-radius: 50%;
  display: grid;
  place-items: center;
}
.detailImage {
  width: 100%;
  height: 100%;
  min-height: 380px;
  object-fit: cover;
}
.detailCopy {
  padding: 38px 24px 24px;
}
.detailCopy small {
  font-size: 10px;
  text-transform: uppercase;
  letter-spacing: 0.08em;
  color: var(--ink-faint);
}
.detailCopy h3 {
  font-weight: 500;
  font-size: 28px;
  line-height: 1.05;
  letter-spacing: -0.05em;
  margin: 12px 0;
}
.detailCopy p {
  color: var(--ink-soft);
  font-size: 11px;
  line-height: 1.6;
}
.value {
  font-size: 17px;
  font-weight: 500;
}
.note {
  margin: 23px 0;
}
.detailCopy dl {
  font-size: 10px;
  border-top: 1px solid var(--rule);
  padding-top: 12px;
  margin: 20px 0;
}
.detailCopy dl > div {
  display: flex;
  justify-content: space-between;
  margin: 7px 0;
  gap: 10px;
}
.detailCopy dt {
  color: var(--ink-faint);
}
.detailCopy dd {
  margin: 0;
  text-align: right;
}
.save {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 7px;
  padding: 11px 9px;
  min-width: 100%;
  background: var(--accent);
  color: var(--accent-ink) !important;
  font-size: 11px !important;
  border: 0;
  border-radius: 10px;
}
@container (max-width:650px) {
  .switch button span {
    display: none;
  }
  .switch button {
    padding: 8px 5px;
  }
  .header p {
    font-size: 11px;
  }
  .dialog {
    grid-template-columns: 1fr 1fr;
  }
  .detailCopy {
    padding: 38px 16px 18px;
  }
  .detailCopy h3 {
    font-size: 24px;
  }
}
@container (max-width:390px) {
  .header p {
    font-size: 10px;
    margin-bottom: 15px;
  }
  .stage {
    height: 460px;
  }
  .root {
    padding: 20px 14px;
  }
  .dialog {
    grid-template-columns: 1fr;
  }
  .detailImage {
    height: 170px;
    min-height: 170px;
  }
  .detailCopy {
    padding: 25px 18px 18px;
  }
  .detailCopy h3 {
    font-size: 25px;
    margin: 7px 0;
  }
  .detailCopy .note {
    margin: 12px 0;
  }
  .overlay {
    padding: 10px;
  }
  .detailCopy dl {
    margin: 12px 0;
  }
}
@media (prefers-reduced-motion: reduce) {
  .root * {
    animation: none !important;
    transition: none !important;
    scroll-behavior: auto !important;
  }
  .overlay {
    backdrop-filter: none;
  }
}
```

### layout-morph/items.ts

```tsx
/** 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',
  },
  {
    id: '3',
    title: 'Opal table lamp',
    kind: 'Piece',
    subtitle: 'By Daniel Kim',
    value: '$248',
    image: '/experiments/layout-morph/3.webp',
  },
  {
    id: '4',
    title: 'Sofia Ramirez',
    kind: 'Maker',
    subtitle: 'Weaver in Oaxaca',
    value: 'Weaver',
    image: '/experiments/layout-morph/4.webp',
  },
  {
    id: '5',
    title: 'Stoneware mug pair',
    kind: 'Piece',
    subtitle: 'By Emma Collins',
    value: '$58',
  },
  {
    id: '6',
    title: 'Ava Mitchell',
    kind: 'Maker',
    subtitle: 'Glassblower in Leeds',
    value: 'Glassblower',
    image: '/experiments/layout-morph/6.webp',
  },
  {
    id: '7',
    title: 'Wool floor cushion',
    kind: 'Piece',
    subtitle: 'By Olivia Bennett',
    value: '$134',
  },
  {
    id: '8',
    title: 'Oat linen throw',
    kind: 'Piece',
    subtitle: 'By Sofia Ramirez',
    value: '$186',
    image: '/experiments/layout-morph/8.webp',
  },
  {
    id: '9',
    title: 'Marcus Johnson',
    kind: 'Maker',
    subtitle: 'Woodworker in Asheville',
    value: 'Woodworker',
    image: '/experiments/layout-morph/9.webp',
  },
  {
    id: '10',
    title: 'Oak serving board',
    kind: 'Piece',
    subtitle: 'By Marcus Johnson',
    value: '$72',
  },
  {
    id: '11',
    title: 'Jasmine Brooks',
    kind: 'Maker',
    subtitle: 'Stonemason in Glasgow',
    value: 'Stonemason',
    image: '/experiments/layout-morph/11.webp',
  },
  {
    id: '12',
    title: 'Oak lounge chair',
    kind: 'Piece',
    subtitle: 'By Ryan Sullivan',
    value: '$1,240',
    image: '/experiments/layout-morph/12.webp',
  },
  {
    id: '13',
    title: 'Ryan Sullivan',
    kind: 'Maker',
    subtitle: 'Upholsterer in Copenhagen',
    value: 'Upholsterer',
    image: '/experiments/layout-morph/13.webp',
  },
  {
    id: '14',
    title: 'Beeswax taper pair',
    kind: 'Piece',
    subtitle: 'By Hannah Walsh',
    value: '$24',
  },
  {
    id: '15',
    title: 'Daniel Kim',
    kind: 'Maker',
    subtitle: 'Metalworker in Milan',
    value: 'Metalworker',
    image: '/experiments/layout-morph/15.webp',
  },
  {
    id: '16',
    title: 'Linen napkin set',
    kind: 'Piece',
    subtitle: 'By Sofia Ramirez',
    value: '$42',
  },
  {
    id: '17',
    title: 'Night carafe set',
    kind: 'Piece',
    subtitle: 'By Ava Mitchell',
    value: '$64',
    image: '/experiments/layout-morph/17.webp',
  },
  {
    id: '18',
    title: 'Chloe Nguyen',
    kind: 'Maker',
    subtitle: 'Photographer in Lisbon',
    value: 'Photographer',
    image: '/experiments/layout-morph/18.webp',
  },
  {
    id: '19',
    title: 'Brass bookends',
    kind: 'Piece',
    subtitle: 'By Daniel Kim',
    value: '$110',
  },
  {
    id: '20',
    title: 'Hannah Walsh',
    kind: 'Maker',
    subtitle: 'Chandler in Lagos',
    value: 'Chandler',
    image: '/experiments/layout-morph/20.webp',
  },
  {
    id: '21',
    title: 'Walnut stool',
    kind: 'Piece',
    subtitle: 'By Marcus Johnson',
    value: '$390',
  },
  {
    id: '22',
    title: 'Dusk at Berlenga',
    kind: 'Piece',
    subtitle: 'By Chloe Nguyen',
    value: '$120',
    image: '/experiments/layout-morph/22.webp',
  },
  {
    id: '23',
    title: 'Olivia Bennett',
    kind: 'Maker',
    subtitle: 'Felter in Mexico City',
    value: 'Felter',
    image: '/experiments/layout-morph/23.webp',
  },
  {
    id: '24',
    title: 'Ash glaze vase',
    kind: 'Piece',
    subtitle: 'By Emma Collins',
    value: '$84',
  },
];
```

### layout-morph/use-demo.ts

```tsx
'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);
    const media = window.matchMedia('(prefers-reduced-motion: reduce)');
    const motion = () => setReduced(media.matches);
    motion();
    media.addEventListener('change', motion);
    return () => {
      observer.disconnect();
      document.removeEventListener('visibilitychange', update);
      media.removeEventListener('change', motion);
    };
  }, []);
  const take = useCallback(() => setManual(true), []);
  return { root, visible, reduced, take, playing: visible && !reduced && !manual };
}
```

### layout-morph/icons.tsx

```tsx
/** 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" />
      </>
    ),
    stack: (
      <>
        <path d="m2 8 10-5 10 5-10 5Z" />
        <path d="m2 12 10 5 10-5M2 16l10 5 10-5" />
      </>
    ),
    carousel: (
      <>
        <rect x="7" y="3" width="10" height="18" rx="2" />
        <path d="M3 6v12M21 6v12" />
      </>
    ),
    helix: (
      <>
        <path d="M5 3c14 6 0 12 14 18M19 3C5 9 19 15 5 21M5 6h14M5 18h14" />
      </>
    ),
    close: <path d="m6 6 12 12M18 6 6 18" />,
    check: <path d="m5 12 4 4L19 6" />,
    heart: <path d="M12 20S2 14 2 7a5 5 0 0 1 10-1A5 5 0 0 1 22 7c0 7-10 13-10 13Z" />,
  };
  return (
    <svg
      aria-hidden="true"
      viewBox="0 0 24 24"
      style={{ width: size, height: size, flexShrink: 0 }}
      fill="none"
      stroke="currentColor"
      strokeWidth="1.5"
      strokeLinecap="round"
      strokeLinejoin="round"
    >
      {paths[name]}
    </svg>
  );
}
```