# Clothing rail

> A shop page with its garments on a clothes rail: the front one turns to face you, the rest hang side on.

A small shop’s page, phone-shaped. Its garments hang on a chrome rail: the one in the middle faces you, its neighbours hang side on. Swipe or press the arrows and the front one turns away, the rail carries them all along, and the next turns round to face you with a swing on its hanger. Tap it and it grows into a detail where Front and Back turn it round.

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

Category: Motion.

## Installation

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

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

Or copy all the source files below into one folder of your project. Either way it needs React 19.2 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/assets/6iL4gnPpdMJXOWFfkZvI0NoA0A.png
- https://brunodelorence.com/experiments/clothing-rail/fieldnote-front.webp
- https://brunodelorence.com/experiments/clothing-rail/fieldnote-side.webp
- https://brunodelorence.com/experiments/clothing-rail/fieldnote-back.webp
- https://brunodelorence.com/experiments/clothing-rail/low-tide-front.webp
- https://brunodelorence.com/experiments/clothing-rail/low-tide-side.webp
- https://brunodelorence.com/experiments/clothing-rail/low-tide-back.webp
- https://brunodelorence.com/experiments/clothing-rail/petal-front.webp
- https://brunodelorence.com/experiments/clothing-rail/petal-side.webp
- https://brunodelorence.com/experiments/clothing-rail/petal-back.webp
- https://brunodelorence.com/experiments/clothing-rail/orchard-front.webp
- https://brunodelorence.com/experiments/clothing-rail/orchard-side.webp
- https://brunodelorence.com/experiments/clothing-rail/orchard-back.webp
- https://brunodelorence.com/experiments/clothing-rail/fern-front.webp
- https://brunodelorence.com/experiments/clothing-rail/fern-side.webp
- https://brunodelorence.com/experiments/clothing-rail/fern-back.webp
- https://brunodelorence.com/experiments/clothing-rail/nightshift-front.webp
- https://brunodelorence.com/experiments/clothing-rail/nightshift-side.webp
- https://brunodelorence.com/experiments/clothing-rail/nightshift-back.webp
- https://brunodelorence.com/experiments/clothing-rail/rosehip-front.webp
- https://brunodelorence.com/experiments/clothing-rail/rosehip-side.webp
- https://brunodelorence.com/experiments/clothing-rail/rosehip-back.webp
- https://brunodelorence.com/experiments/clothing-rail/harbour-front.webp
- https://brunodelorence.com/experiments/clothing-rail/harbour-side.webp
- https://brunodelorence.com/experiments/clothing-rail/harbour-back.webp
- https://brunodelorence.com/experiments/clothing-rail/oat-front.webp
- https://brunodelorence.com/experiments/clothing-rail/oat-side.webp
- https://brunodelorence.com/experiments/clothing-rail/oat-back.webp
- https://brunodelorence.com/experiments/clothing-rail/moss-front.webp
- https://brunodelorence.com/experiments/clothing-rail/moss-side.webp
- https://brunodelorence.com/experiments/clothing-rail/moss-back.webp

## Usage

```tsx
import { ClothingRail } from './clothing-rail';
import { ITEMS } from './data';

const collection = ITEMS.filter((item) =>
  ['Fern', 'Nightshift', 'Rosehip'].includes(item.name),
);

export function Shop() {
  return (
    <ClothingRail
      items={collection}
      defaultIndex={1}
      brand="Delorence"
      place="North Quay"
      shopHref="/shop"
      autoplay={false}
      onOpen={(item) => console.log('Viewing', item.name)}
    />
  );
}
```

## How it works

- The page is a column 360 units wide and 640 tall, and every size is in units of its width (a container query), so it is the same page on a 320 px phone and at 960; wider than 520 px, it stands in the middle at 400 px.
- Each garment uses transparent 3D product renders with sculpted fleece, hanging folds, ribbed cuffs and a wooden hanger. The Delorence logo and a text wordmark are applied over the fabric; both can be customized with brand and logoSrc.
- A garment is a box of three images, front, side and back: the front and back stand 22 units off its axis, the side 60. Turning the box a quarter about the hook brings the side round, so the sleeves sweep away while the profile comes in. Each face fades only near edge on, so it never shows as a sliver.
- Moving along the rail plays in four steps, each picking up from where the last was if a new press comes in: the front garment turns side on (170 ms, easing in), the rail slides (260 ms, 110 ms more per extra garment), a 140 ms beat, and the new one turns to face you (320 ms, easing out).
- The script writes only transforms, one frame at a time and only while something moves. Two springs swing the hangers: one pulls every garment against the rail’s speed, so they trail and swing back when it stops; the other kicks the front one as it turns.
- The detail’s garment grows out of the rail’s: one 460 ms transform from the rail’s place and size to its own, while the sheet, which blurs the page behind it, fades in and the lines rise in turn. Front and Back turn the same box half round in 300 ms, and the underline slides with it.
- The red strip at the foot is one compositor animation that scrolls two copies of its lines, paused while off screen.
- Every colour is a light-dark() token on the root. At night the paper goes near-black, the chrome darkens and the garments keep their own colours. It follows the page’s color-scheme, or the colorScheme prop.

## API reference

- **items** (`RailItem[]`, default `ten garments`): The garments, in order: name, kind (hoodie, crewneck or zip), color, colorName and images (transparent front, side and back URLs). Optionally description, printColor (the logo and wordmark ink) and href. Align the three views to the same hook and hem.
- **defaultIndex** (`number`, default `3`): The garment in front to begin with.
- **onChange** (`(index: number) => void`): Called with the index of the garment the rail moves to.
- **onOpen** (`(item: RailItem, index: number) => void`): Called when a garment’s detail opens.
- **brand** (`string`, default `'Delorence'`): The shop’s name: the header, the garment prints, the caption, the button and the detail’s footer.
- **logoSrc** (`string`, default `Delorence logo`): A transparent logo URL, used as an ink mask in the header and on the garments.
- **place** (`string`, default `'North Quay'`): Where the shop is, after its name in the caption.
- **railLabel** (`string`, default `'On the rail'`): The caption’s first words, and the rail’s name for screen readers.
- **marquee** (`string[]`, default `two lines`): The lines that scroll along the red strip, between stars.
- **shopHref** (`string`): Where Shop, Explore and a garment without its own href lead.
- **autoplay** (`boolean`, default `true`): While on screen and untouched, a demo browses, opens a garment, turns it round and closes it.
- **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.

## Accessibility

- The rail is a region with the carousel role description. Only the front garment is in the tab order: a button named with its name, cut, colour and place (“Nightshift, Unisex hoodie · Charcoal. 6 of 10. Open the detail”). The product images and prints are decorative and aria-hidden.
- Left and Right arrows (and Home and End) move along the rail, as do the Previous and Next buttons, which are disabled at the ends. Each move the reader makes is announced in a polite live region.
- The detail is a modal dialog named by the garment: focus moves to Close, Tab stays inside it, Escape closes it, the page behind is inert, and focus returns to the garment. Front and Back are tabs with arrow-key movement, over a tabpanel.
- Under reduced motion the garments move and turn at once, the strip stands still and the demo never plays.

## Source

### clothing-rail/clothing-rail.tsx

```tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.

'use client';

import {
  useCallback,
  useEffect,
  useEffectEvent,
  useId,
  useLayoutEffect,
  useRef,
  useState,
  type KeyboardEvent as ReactKeyboardEvent,
  type PointerEvent as ReactPointerEvent,
} from 'react';
import { ITEMS, KIND_LABEL, type RailItem } from './data';
import { Garment } from './garments';
import { createMotion, type Motion } from './rail-motion';
import { useAutoplay } from './use-autoplay';
import styles from './clothing-rail.module.css';

export type { RailItem } from './data';

/*
 * Clothing rail: a shop's page with its garments on a clothes rail. The one in the middle faces
 * the reader; the others hang side on along the rail. Swipe, or press the arrows, and the front
 * one turns away, the rail carries them all along, and the next one turns round to face you,
 * swinging a little on its hanger. Tap it and it grows into a detail you can turn round, front and
 * back.
 *
 * - The page is a phone-shaped column, 360 units wide and 640 tall: every size is in units of its
 *   width (a container query), so it is the same page from a 320 px phone up; wider, it stays a
 *   column in the middle.
 * - The garments use transparent 3D product renders as three faces, front, side and back,
 *   turned in 3D about their hooks. rail-motion.ts moves them a frame at a time, writing only
 *   transforms, and only while something moves.
 * - The detail is a modal dialog over the page, which blurs behind it. Its garment grows out of
 *   the one on the rail (one transform, from the rail's place and size to its own), and its Front
 *   and Back are tabs that turn the same box half round.
 * - While on screen and untouched, a demo plays it: it browses, opens a garment, turns it round,
 *   and closes it. Any real input hands it to the reader.
 * - Light or dark: every colour is a light-dark() token in the CSS, so the scheme is the page's
 *   color-scheme, or the one the colorScheme prop sets.
 */

/** The marquee's lines, between stars. */
const MARQUEE = ['Sewn slowly, worn daily.', 'Cut & finished in small batches.'];

/** The detail's garment is this much larger than the rail's, and hangs this much higher. */
const GROW = 1.21;
const RISE = 79;

const pad = (n: number) => String(n).padStart(2, '0');

export function ClothingRail({
  items = ITEMS,
  defaultIndex = 3,
  onChange,
  onOpen,
  brand = 'Delorence',
  logoSrc = '/assets/6iL4gnPpdMJXOWFfkZvI0NoA0A.png',
  place = 'North Quay',
  railLabel = 'On the rail',
  marquee = MARQUEE,
  shopHref,
  autoplay = true,
  colorScheme,
  className = '',
}: {
  /** The garments, in their order on the rail. */
  items?: RailItem[];
  /** The garment in front to begin with. */
  defaultIndex?: number;
  /** Called with the index of the garment the rail moves to. */
  onChange?: (index: number) => void;
  /** Called when a garment's detail opens. */
  onOpen?: (item: RailItem, index: number) => void;
  /** The shop's name: the wordmark, the caption, the button and the detail's footer. */
  brand?: string;
  /** The transparent logo, used in the header and on each garment. */
  logoSrc?: string;
  /** Where the shop is, after its name in the caption. */
  place?: string;
  /** The caption's first words, and the rail's name for screen readers. */
  railLabel?: string;
  /** The lines that scroll along the strip at the foot, between stars. */
  marquee?: string[];
  /** Where Shop, Explore and a garment without its own href lead. */
  shopHref?: string;
  /** While on screen and untouched, a demo browses, opens a garment and turns it round. */
  autoplay?: boolean;
  /** Light or dark colours, or the reader's ('system'); without it, the page's color-scheme. */
  colorScheme?: 'light' | 'dark' | 'system';
  className?: string;
}) {
  const count = items.length;
  const start = Math.min(Math.max(0, defaultIndex), Math.max(0, count - 1));
  const uid = useId().replace(/:/g, '');
  const { ref: root, playing, take } = useAutoplay<HTMLDivElement>(6000);
  const phone = useRef<HTMLDivElement>(null);
  const slots = useRef<(HTMLDivElement | null)[]>([]);
  const swings = useRef<(HTMLDivElement | null)[]>([]);
  const turns = useRef<(HTMLDivElement | null)[]>([]);
  const motion = useRef<Motion | null>(null);
  const touch = useRef<HTMLSpanElement>(null);

  /** The garment the rail shows, or is heading for. */
  const [index, setIndex] = useState(start);
  /** The detail: shut, open, or fading out; and which side of the garment it shows. */
  const [detail, setDetail] = useState<'shut' | 'open' | 'closing'>('shut');
  const [side, setSide] = useState<'front' | 'back'>('front');
  const [message, setMessage] = useState('');
  const [marqueeOn, setMarqueeOn] = useState(false);

  const sheet = useRef<HTMLDivElement>(null);
  const bigArt = useRef<HTMLDivElement>(null);
  const closeButton = useRef<HTMLButtonElement>(null);
  const frontButton = useRef<HTMLButtonElement>(null);
  /** The detail was opened by the reader, so focus moves into it and back out after. */
  const byReader = useRef(false);
  /** A swipe that started on the front garment is not a tap on it. */
  const swiped = useRef(false);
  const drag = useRef<{ id: number; x: number; y: number } | null>(null);

  const item = items[index] ?? items[0];
  const isOpen = detail !== 'shut';
  const demo = autoplay && playing;

  /** Repeats are made to differ, so screen readers announce them again. */
  const announce = (text: string) => setMessage((now) => (now === text ? `${text} ` : text));

  const reportIndex = useEffectEvent((next: number) => {
    setIndex(next);
    onChange?.(next);
  });

  // The motion, and the column's unit (px per 1/360 of its width).
  useLayoutEffect(() => {
    const element = phone.current;
    if (!element) return;
    const engine = createMotion({
      count,
      start,
      nodes: () => ({ slots: slots.current, swings: swings.current, turns: turns.current }),
      onIndex: (next) => reportIndex(next),
    });
    motion.current = engine;
    const reduced = window.matchMedia('(prefers-reduced-motion: reduce)');
    const readMotion = () => engine.setInstant(reduced.matches);
    readMotion();
    reduced.addEventListener('change', readMotion);
    const measure = () => engine.resize(element.clientWidth / 360 || 1);
    measure();
    const observer = new ResizeObserver(measure);
    observer.observe(element);
    return () => {
      observer.disconnect();
      reduced.removeEventListener('change', readMotion);
      engine.stop();
      motion.current = null;
    };
    // The rail is built once per set of garments.
  }, [count]);

  // The marquee scrolls only while it can be seen.
  useEffect(() => {
    const element = phone.current;
    if (!element) return;
    let onScreen = false;
    const update = () => setMarqueeOn(onScreen && !document.hidden);
    const observer = new IntersectionObserver(([entry]) => {
      onScreen = entry.isIntersecting;
      update();
    });
    observer.observe(element);
    document.addEventListener('visibilitychange', update);
    return () => {
      observer.disconnect();
      document.removeEventListener('visibilitychange', update);
    };
  }, []);

  const go = useCallback(
    (step: number, reader = false) => {
      const engine = motion.current;
      if (!engine) return;
      const next = Math.min(count - 1, Math.max(0, engine.target() + step));
      engine.go(next);
      if (reader && items[next]) announce(`${items[next].name}, ${next + 1} of ${count}`);
    },
    [count, items],
  );

  const open = (reader: boolean) => {
    const engine = motion.current;
    if (!engine || isOpen || !engine.facing()) return;
    byReader.current = reader;
    setSide('front');
    setDetail('open');
    onOpen?.(items[engine.target()], engine.target());
  };

  const close = () => {
    if (detail !== 'open') return;
    setDetail('closing');
  };

  // Opening: the garment grows out of its place on the rail while the sheet fades in, and the
  // rail's own garment hides behind it.
  useLayoutEffect(() => {
    if (detail !== 'open') return;
    const panel = sheet.current;
    const art = bigArt.current;
    motion.current?.hide(index);
    if (byReader.current) closeButton.current?.focus({ preventScroll: true });
    if (!panel || !art || window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
    const unit = (phone.current?.clientWidth ?? 360) / 360;
    panel.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 240, easing: 'ease-out' });
    art.animate(
      [
        { transform: `translateY(${(RISE * unit).toFixed(1)}px) scale(${(1 / GROW).toFixed(4)})` },
        { transform: 'none' },
      ],
      { duration: 460, easing: 'cubic-bezier(0.2, 0.75, 0.25, 1)' },
    );
    panel.querySelectorAll<HTMLElement>('[data-rise]').forEach((element, i) => {
      element.animate(
        [
          { opacity: 0, transform: `translateY(${(10 * unit).toFixed(1)}px)` },
          { opacity: 1, transform: 'none' },
        ],
        {
          duration: 380,
          delay: 90 + i * 35,
          easing: 'cubic-bezier(0.2, 0.75, 0.25, 1)',
          fill: 'backwards',
        },
      );
    });
    // Only on opening.
  }, [detail]);

  // Closing: the sheet fades out, and the garment on the rail turns back round to face you.
  useEffect(() => {
    if (detail !== 'closing') return;
    const panel = sheet.current;
    const finish = () => {
      setDetail('shut');
      motion.current?.hide(null);
      motion.current?.turnIn();
    };
    if (!panel || window.matchMedia('(prefers-reduced-motion: reduce)').matches) return finish();
    const fade = panel.animate([{ opacity: 1 }, { opacity: 0 }], {
      duration: 200,
      easing: 'ease-in',
      fill: 'forwards',
    });
    fade.finished.then(finish, () => {});
    return () => fade.cancel();
  }, [detail]);

  // Back on the page (no longer inert), focus returns to the garment the detail opened from.
  useEffect(() => {
    if (detail !== 'shut' || !byReader.current) return;
    byReader.current = false;
    frontButton.current?.focus({ preventScroll: true });
  }, [detail]);

  // ---- Reader input -------------------------------------------------------------------------

  const onKeyDown = (event: ReactKeyboardEvent) => {
    if (isOpen) return;
    if (event.key === 'ArrowRight') go(1, true);
    else if (event.key === 'ArrowLeft') go(-1, true);
    else if (event.key === 'Home') go(-count, true);
    else if (event.key === 'End') go(count, true);
    else return;
    event.preventDefault();
  };

  const onPointerDown = (event: ReactPointerEvent) => {
    if (!event.isPrimary || event.button !== 0) return;
    swiped.current = false;
    drag.current = { id: event.pointerId, x: event.clientX, y: event.clientY };
  };
  const onPointerMove = (event: ReactPointerEvent<HTMLElement>) => {
    const start = drag.current;
    if (!start || start.id !== event.pointerId || swiped.current) return;
    const unit = (phone.current?.clientWidth ?? 360) / 360;
    const dx = event.clientX - start.x;
    const dy = event.clientY - start.y;
    if (Math.abs(dx) > 26 * unit && Math.abs(dx) > Math.abs(dy) * 1.2) {
      swiped.current = true;
      go(dx < 0 ? 1 : -1, true);
    }
  };
  const onPointerEnd = () => {
    drag.current = null;
  };

  const onDialogKey = (event: ReactKeyboardEvent) => {
    if (event.key === 'Escape') {
      event.preventDefault();
      close();
      return;
    }
    if (event.key !== 'Tab' || !sheet.current) return;
    // Keep focus inside the dialog.
    const focusables = [
      ...sheet.current.querySelectorAll<HTMLElement>('button:not([tabindex="-1"]), a[href]'),
    ];
    const first = focusables[0];
    const last = focusables[focusables.length - 1];
    if (event.shiftKey && document.activeElement === first) {
      event.preventDefault();
      last?.focus();
    } else if (!event.shiftKey && document.activeElement === last) {
      event.preventDefault();
      first?.focus();
    }
  };

  const onTabKey = (event: ReactKeyboardEvent) => {
    if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return;
    event.preventDefault();
    const next = side === 'front' ? 'back' : 'front';
    setSide(next);
    sheet.current?.querySelector<HTMLElement>(`[role="tab"][data-side="${next}"]`)?.focus();
  };

  // ---- The demo -----------------------------------------------------------------------------

  const demoStep = useEffectEvent(
    (action: 'next' | 'prev' | 'open' | 'back' | 'front' | 'close') => {
      if (action === 'next') go(1);
      else if (action === 'prev') go(-1);
      else if (action === 'open') open(false);
      else if (action === 'back') setSide('back');
      else if (action === 'front') setSide('front');
      else close();
    },
  );
  const demoAt = useEffectEvent(() => motion.current?.target() ?? start);

  useEffect(() => {
    const dot = touch.current;
    const element = phone.current;
    if (!demo || !dot || !element) return;
    let alive = true;
    const timers = new Set<number>();
    const wait = (ms: number) =>
      new Promise<void>((resolve, reject) => {
        const timer = window.setTimeout(() => {
          timers.delete(timer);
          if (alive) resolve();
          else reject(new Error('stopped'));
        }, ms);
        timers.add(timer);
      });
    const unit = () => element.clientWidth / 360;
    /** A fingertip: a soft disc that presses at (x, y), in units, or drags from there by dx. */
    const press = (x: number, y: number, dx = 0) => {
      const u = unit();
      const at = (ox: number) =>
        `translate(${((x + ox) * u).toFixed(1)}px, ${(y * u).toFixed(1)}px)`;
      dot.animate(
        dx
          ? [
              { opacity: 0, transform: `${at(0)} scale(0.7)` },
              { opacity: 1, transform: `${at(0)} scale(1)`, offset: 0.2 },
              { opacity: 1, transform: `${at(dx)} scale(1)`, offset: 0.8 },
              { opacity: 0, transform: `${at(dx)} scale(0.9)` },
            ]
          : [
              { opacity: 0, transform: `${at(0)} scale(1.3)` },
              { opacity: 1, transform: `${at(0)} scale(0.85)`, offset: 0.45 },
              { opacity: 0, transform: `${at(0)} scale(1.1)` },
            ],
        { duration: dx ? 520 : 420, easing: 'ease-out' },
      );
    };
    const run = async () => {
      const here = demoAt();
      const ahead = here + 3 < count ? 1 : -1;
      const step = ahead === 1 ? 'next' : 'prev';
      const swipe = async () => {
        press(ahead === 1 ? 250 : 110, 280, ahead === 1 ? -140 : 140);
        await wait(300);
        demoStep(step);
      };
      for (;;) {
        await wait(1100);
        await swipe();
        await wait(1500);
        await swipe();
        await wait(1700);
        press(180, 270);
        await wait(160);
        demoStep('open');
        await wait(1700);
        press(217, 491);
        await wait(140);
        demoStep('back');
        await wait(2100);
        press(142, 491);
        await wait(140);
        demoStep('front');
        await wait(1500);
        press(325, 36);
        await wait(140);
        demoStep('close');
        await wait(1700);
        await swipe();
        await wait(1700);
        // Back to where it began, a press of the arrow at a time.
        for (let i = 0; i < 3; i++) {
          press(ahead === 1 ? 108 : 252, 500);
          await wait(140);
          demoStep(ahead === 1 ? 'prev' : 'next');
          await wait(900);
        }
      }
    };
    run().catch(() => {});
    return () => {
      alive = false;
      timers.forEach((timer) => window.clearTimeout(timer));
      dot.getAnimations().forEach((animation) => animation.cancel());
    };
  }, [demo, count]);

  // ---- Render -------------------------------------------------------------------------------

  const subtitle = `${item.description ?? KIND_LABEL[item.kind]} · ${item.colorName}`;
  const link = item.href ?? shopHref;
  const lines = marquee.length ? marquee : MARQUEE;

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      data-color-scheme={colorScheme}
      onPointerDownCapture={take}
      onKeyDownCapture={take}
    >
      <div ref={phone} className={styles.phone}>
        <div className={styles.page} inert={isOpen} onKeyDown={onKeyDown}>
          <header className={styles.top}>
            <button type="button" className={styles.menu} aria-label="Menu">
              <span />
              <span />
              <span />
            </button>
            <span className={styles.wordmark} role="img" aria-label={brand}>
              <span
                className={styles.wordmarkLogo}
                style={{ maskImage: `url("${logoSrc}")` }}
                aria-hidden="true"
              />
              <span aria-hidden="true">{brand}</span>
            </span>
            <a className={styles.shop} href={shopHref ?? '#'}>
              Shop <Arrow className={styles.shopArrow} />
            </a>
          </header>

          <p className={styles.caption}>
            <span className={styles.captionMain}>{railLabel}</span>
            <span className={styles.captionBar} aria-hidden="true" />
            <span className={styles.captionSub}>
              {brand} / {place}
            </span>
          </p>

          <section
            className={styles.stage}
            aria-roledescription="carousel"
            aria-label={railLabel}
            onPointerDown={onPointerDown}
            onPointerMove={onPointerMove}
            onPointerUp={onPointerEnd}
            onPointerCancel={onPointerEnd}
          >
            <Rail id={uid} />
            <div className={styles.slots} aria-hidden="true">
              {items.map((garment, i) => (
                <div
                  key={`${garment.name}-${i}`}
                  ref={(element) => {
                    slots.current[i] = element;
                  }}
                  className={styles.slot}
                  data-front={i === index || undefined}
                >
                  <div
                    ref={(element) => {
                      swings.current[i] = element;
                    }}
                    className={styles.swing}
                  >
                    <span className={styles.wire} />
                    <Garment
                      item={garment}
                      brand={brand}
                      logoSrc={logoSrc}
                      turnRef={(element) => {
                        turns.current[i] = element;
                      }}
                    />
                  </div>
                </div>
              ))}
            </div>
            <button
              ref={frontButton}
              type="button"
              className={styles.frontButton}
              aria-label={`${item.name}, ${subtitle}. ${index + 1} of ${count}. Open the detail`}
              aria-haspopup="dialog"
              onClick={() => {
                if (swiped.current) {
                  swiped.current = false;
                  return;
                }
                open(true);
              }}
            />
          </section>

          <p className={styles.name}>{item.name}</p>
          <p className={styles.hint}>Swipe to browse. Tap to explore.</p>

          <div className={styles.counter}>
            <button
              type="button"
              className={styles.step}
              aria-label="Previous garment"
              disabled={index === 0}
              onClick={() => go(-1, true)}
            >
              <LongArrow flip />
            </button>
            <span className={styles.count}>
              {pad(index + 1)} <span className={styles.slash}>/</span> {pad(count)}
            </span>
            <button
              type="button"
              className={styles.step}
              aria-label="Next garment"
              disabled={index === count - 1}
              onClick={() => go(1, true)}
            >
              <LongArrow />
            </button>
          </div>

          <a className={styles.explore} href={shopHref ?? '#'}>
            <span>Explore {brand}</span>
            <Arrow className={styles.exploreArrow} />
          </a>

          <div className={styles.marquee} data-running={marqueeOn || undefined}>
            <p className={styles.hidden}>{lines.join(' ')}</p>
            <div className={styles.track} aria-hidden="true">
              {[0, 1].map((copy) => (
                <div key={copy} className={styles.run}>
                  {[0, 1].map((repeat) =>
                    lines.map((line, i) => (
                      <span key={`${repeat}-${i}`} className={styles.line}>
                        {line}
                        <svg viewBox="-7 -9 14 18" className={styles.spark}>
                          <path d="M0-9C.7-3 2-1 7 0C2 1 .7 3 0 9C-.7 3-2 1-7 0C-2-1-.7-3 0-9Z" />
                        </svg>
                      </span>
                    )),
                  )}
                </div>
              ))}
            </div>
          </div>
        </div>

        {isOpen && (
          <div
            ref={sheet}
            className={styles.sheet}
            role="dialog"
            aria-modal="true"
            aria-labelledby={`${uid}-name`}
            aria-describedby={`${uid}-sub`}
            onKeyDown={onDialogKey}
          >
            <button ref={closeButton} type="button" className={styles.close} onClick={close}>
              Close
              <svg viewBox="0 0 12 12" className={styles.closeIcon} aria-hidden="true">
                <path d="M1.5 1.5l9 9M10.5 1.5l-9 9" />
              </svg>
            </button>
            <div
              className={styles.bigStage}
              role="tabpanel"
              id={`${uid}-panel`}
              aria-labelledby={`${uid}-tab-${side}`}
            >
              <div ref={bigArt} className={styles.bigArt} data-side={side}>
                <div className={styles.bigSwing}>
                  <span className={styles.wire} />
                  <Garment item={item} brand={brand} logoSrc={logoSrc} className={styles.bigTurn} />
                </div>
              </div>
              <span className={styles.hidden}>
                {item.name}, {side === 'front' ? 'front' : 'back'}
              </span>
            </div>
            <p className={styles.bigCount} data-rise>
              {pad(index + 1)} <span className={styles.slash}>/</span> {pad(count)}
            </p>
            <h2 className={styles.bigName} id={`${uid}-name`} data-rise>
              {item.name}
            </h2>
            <p className={styles.bigSub} id={`${uid}-sub`} data-rise>
              {subtitle}
            </p>
            <div
              className={styles.tabs}
              role="tablist"
              aria-label="Side"
              data-rise
              onKeyDown={onTabKey}
            >
              {(['front', 'back'] as const).map((which) => (
                <button
                  key={which}
                  type="button"
                  role="tab"
                  id={`${uid}-tab-${which}`}
                  data-side={which}
                  aria-selected={side === which}
                  aria-controls={`${uid}-panel`}
                  tabIndex={side === which ? 0 : -1}
                  className={styles.tab}
                  onClick={() => setSide(which)}
                >
                  {which === 'front' ? 'Front' : 'Back'}
                </button>
              ))}
              <span className={styles.underline} data-side={side} aria-hidden="true" />
            </div>
            <div className={styles.foot} data-rise>
              <span>{brand}</span>
              <a className={styles.shopLink} href={link ?? '#'}>
                View in the shop <Arrow className={styles.linkArrow} />
              </a>
            </div>
          </div>
        )}

        <span ref={touch} className={styles.touch} aria-hidden="true" />
        <p className={styles.hidden} role="status">
          {message}
        </p>
      </div>
    </div>
  );
}

/** The chrome rail: a rod between two posts, each with two screws. */
function Rail({ id }: { id: string }) {
  return (
    <svg className={styles.rail} viewBox="0 128 360 36" aria-hidden="true" focusable="false">
      <defs>
        <linearGradient id={`${id}rod`} x1="0" x2="0" y1="0" y2="1">
          <stop offset="0" stopColor="var(--chrome-lo)" />
          <stop offset="0.3" stopColor="var(--chrome-hi)" />
          <stop offset="0.55" stopColor="var(--chrome)" />
          <stop offset="1" stopColor="var(--chrome-lo)" />
        </linearGradient>
        <linearGradient id={`${id}post`} x1="0" x2="1" y1="0" y2="0">
          <stop offset="0" stopColor="var(--chrome-lo)" />
          <stop offset="0.35" stopColor="var(--chrome-hi)" />
          <stop offset="0.7" stopColor="var(--chrome)" />
          <stop offset="1" stopColor="var(--chrome-lo)" />
        </linearGradient>
        <filter id={`${id}soft`} x="-10%" y="-200%" width="120%" height="500%">
          <feGaussianBlur stdDeviation="1.6" />
        </filter>
      </defs>
      <rect
        x="34"
        y="148"
        width="292"
        height="4"
        rx="2"
        className={styles.railShadow}
        filter={`url(#${id}soft)`}
      />
      <rect x="34" y="144" width="292" height="6" rx="3" fill={`url(#${id}rod)`} />
      {[25, 323].map((x) => (
        <g key={x}>
          <rect
            x={x + 1}
            y="138"
            width="12"
            height="25"
            rx="3.5"
            className={styles.railShadow}
            filter={`url(#${id}soft)`}
          />
          <rect
            x={x}
            y="136"
            width="12"
            height="24.5"
            rx="3.5"
            fill={`url(#${id}post)`}
            stroke="var(--chrome-lo)"
            strokeWidth="0.4"
          />
          <circle cx={x + 6} cy="141.5" r="1.3" className={styles.screw} />
          <circle cx={x + 6} cy="155" r="1.3" className={styles.screw} />
        </g>
      ))}
    </svg>
  );
}

function Arrow({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 10 10" className={className} aria-hidden="true" focusable="false">
      <path d="M1.5 8.5L8.5 1.5M3 1.5h5.5V7" />
    </svg>
  );
}

function LongArrow({ flip = false }: { flip?: boolean }) {
  return (
    <svg
      viewBox="0 0 18 8"
      className={styles.longArrow}
      style={flip ? { transform: 'scaleX(-1)' } : undefined}
      aria-hidden="true"
      focusable="false"
    >
      <path d="M0 4h16.5M13.5 1l3 3-3 3" />
    </svg>
  );
}
```

### clothing-rail/clothing-rail.module.css

```css
/* Clothing rail (clothing-rail.tsx): a shop's page in a phone-shaped column, 360 units wide and
   640 tall. Every size is in --u, a 360th of the column's width (a container query), so the page
   is the same at any width; the column fills a narrow stage, and on a wider one stands in its
   middle at 400 px.

   Colours are the tokens below, light-dark(light, dark) where the two differ: 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). The garments keep their own colours at night;
   the paper, the ink and the chrome dim around them.

   Memory. The column is one layout boundary. The garments move by transforms that the script
   writes one frame at a time, and only while something moves; each turns as a small 3D box of
   three product images. The marquee is one compositor animation, paused while off screen. */

.root {
  /* The page and the stage around the column. */
  --paper: #f3f4ed;
  --stage: #e6e7df;
  /* The words: ink, the softer grey of the hint, the faint one of the caption and counts. */
  --ink: #0b0c08;
  --soft: #2a2b27;
  --muted: #74766d;
  --faint: #9a9d91;
  --tab-off: #8c887f;
  /* Hairlines: the detail's rule, the pill's edge. */
  --line: #cfd0c8;
  --pill-line: #a2a39b;
  /* The red strip and the words on it; the shop link. */
  --red: #da3518;
  --on-red: #fbeee2;
  --link: #d9381c;
  /* The rail's chrome, its screws and the hangers' wires. */
  --chrome-hi: #ffffff;
  --chrome: #c9cac4;
  --chrome-lo: #8d8e88;
  --screw: #6d6e69;
  --wire: #3a3a38;
  /* The garments' shadows on the wall, and the rail's. */
  --g-shadow: rgb(40 40 30 / 0.22);
  --rail-shadow: rgb(0 0 0 / 0.18);
  /* The detail's sheet, the column's lift, the demo's fingertip. */
  --sheet: rgb(243 244 237 / 0.86);
  --lift: rgb(0 0 0 / 0.08);
  --touch: rgb(20 20 20 / 0.16);
  --touch-edge: rgb(255 255 255 / 0.85);
  --focus: #387bff;
  position: relative;
  container-type: inline-size;
  display: grid;
  justify-items: center;
  width: 100%;
  overflow: hidden;
  overflow: clip;
  border-radius: 18px;
  background: var(--stage);
  color: var(--ink);
  font-family: 'Helvetica Neue', Helvetica, Arial, system-ui, sans-serif;
  -webkit-font-smoothing: antialiased;
  -webkit-tap-highlight-color: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --paper: light-dark(#f3f4ed, #141512);
    --stage: light-dark(#e6e7df, #0b0c0a);
    --ink: light-dark(#0b0c08, #eeefe7);
    --soft: light-dark(#2a2b27, #d9dad2);
    --muted: light-dark(#74766d, #9d9e95);
    --faint: light-dark(#9a9d91, #7b7d74);
    --tab-off: light-dark(#8c887f, #7e7b73);
    --line: light-dark(#cfd0c8, #34352f);
    --pill-line: light-dark(#a2a39b, #575852);
    --red: light-dark(#da3518, #cf3418);
    --on-red: light-dark(#fbeee2, #fbeee2);
    --link: light-dark(#d9381c, #ff6b4d);
    --chrome-hi: light-dark(#ffffff, #b9bab4);
    --chrome: light-dark(#c9cac4, #7b7c77);
    --chrome-lo: light-dark(#8d8e88, #3f403c);
    --screw: light-dark(#6d6e69, #2b2c29);
    --wire: light-dark(#3a3a38, #9c9d97);
    --g-shadow: light-dark(rgb(40 40 30 / 0.22), rgb(0 0 0 / 0.75));
    --rail-shadow: light-dark(rgb(0 0 0 / 0.18), rgb(0 0 0 / 0.6));
    --sheet: light-dark(rgb(243 244 237 / 0.86), rgb(20 21 18 / 0.86));
    --lift: light-dark(rgb(0 0 0 / 0.08), rgb(0 0 0 / 0.5));
    --touch: light-dark(rgb(20 20 20 / 0.16), rgb(255 255 255 / 0.22));
    --touch-edge: light-dark(rgb(255 255 255 / 0.85), rgb(255 255 255 / 0.5));
  }
}
/* The colorScheme prop. Without it, nothing is set: color-scheme is inherited from the page. */
.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;
}

/* The column: as wide as the stage, or 400 px in the middle of a wider one. */
.phone {
  --u: calc(100cqi / 360);
  --gu: var(--u);
  position: relative;
  container-type: inline-size;
  contain: layout paint;
  width: 100%;
  aspect-ratio: 9 / 16;
  overflow: hidden;
  background: var(--paper);
}
@container (min-width: 520px) {
  .phone {
    width: 400px;
    margin: 32px 0;
    border-radius: 28px;
    box-shadow:
      0 1px 2px var(--lift),
      0 18px 48px var(--lift);
  }
}

.page {
  position: absolute;
  inset: 0;
}
.page p {
  margin: 0;
}

/* Every line of the page sits at its own height, centred on it. */
.top,
.caption,
.name,
.hint,
.counter,
.explore,
.marquee {
  position: absolute;
  left: 0;
  right: 0;
}

/* ---- The top bar: the menu, the wordmark, Shop. ---- */
.top {
  top: calc(14 * var(--u));
  height: calc(46 * var(--u));
}
.menu {
  position: absolute;
  top: 0;
  left: calc(13 * var(--u));
  display: grid;
  align-content: center;
  gap: calc(4.6 * var(--u));
  width: calc(40 * var(--u));
  height: calc(46 * var(--u));
  padding: 0 calc(10 * var(--u));
  border: 0;
  background: none;
  cursor: pointer;
}
.menu span {
  display: block;
  height: max(1px, calc(0.9 * var(--u)));
  background: var(--ink);
}
.wordmark {
  position: absolute;
  top: 0;
  left: 50%;
  display: flex;
  align-items: center;
  gap: calc(6 * var(--u));
  height: 100%;
  transform: translateX(-50%);
  color: var(--ink);
  font:
    900 calc(18.5 * var(--u)) / 1 'Arial Black',
    'Helvetica Neue',
    Arial,
    sans-serif;
  letter-spacing: 0.03em;
  text-transform: uppercase;
  white-space: nowrap;
}
.wordmark > span:last-child {
  padding-top: calc(5 * var(--u));
}
.wordmarkLogo {
  width: calc(22 * var(--u));
  height: calc(22 * var(--u));
  background: currentColor;
  mask-size: contain;
  mask-repeat: no-repeat;
  mask-position: center;
}
.shop {
  position: absolute;
  top: 0;
  right: calc(18 * var(--u));
  display: flex;
  align-items: center;
  gap: calc(14 * var(--u));
  height: 100%;
  padding: 0 calc(6 * var(--u));
  color: var(--ink);
  font-size: calc(12.6 * var(--u));
  text-decoration: none;
}
.shopArrow {
  width: calc(9 * var(--u));
  height: calc(9 * var(--u));
  margin-top: calc(-3 * var(--u));
}
.shopArrow,
.exploreArrow,
.linkArrow {
  fill: none;
  stroke: currentColor;
  stroke-width: 1;
  stroke-linecap: round;
  stroke-linejoin: round;
  vector-effect: non-scaling-stroke;
}

/* ---- The caption: ON THE RAIL | BRAND / PLACE ---- */
.caption {
  top: calc(96 * var(--u));
  display: flex;
  justify-content: center;
  align-items: center;
  gap: calc(15 * var(--u));
  font-size: calc(9.1 * var(--u));
  letter-spacing: 0.085em;
  line-height: calc(14 * var(--u));
  text-transform: uppercase;
}
.captionMain {
  color: var(--ink);
}
.captionBar {
  width: 1px;
  height: calc(10 * var(--u));
  background: var(--faint);
}
.captionSub {
  color: var(--faint);
}

/* ---- The rail, and the garments on it. ---- */
.stage {
  position: absolute;
  top: calc(120 * var(--u));
  left: 0;
  width: 100%;
  height: calc(270 * var(--u));
  touch-action: pan-y;
  user-select: none;
  -webkit-user-select: none;
}
.rail {
  position: absolute;
  top: calc(8 * var(--u));
  left: 0;
  width: calc(360 * var(--u));
  height: calc(36 * var(--u));
  overflow: visible;
}
.railShadow {
  fill: var(--rail-shadow);
}
.screw {
  fill: var(--screw);
}
.slots {
  position: absolute;
  inset: 0;
  pointer-events: none;
}
.slot {
  position: absolute;
  top: calc(27 * var(--u));
  left: 0;
  width: 0;
  height: 0;
  will-change: transform;
}
.slot[data-front] {
  z-index: 1;
}
/* The hanger's pivot is the top: the rail's point it hangs from. */
.swing,
.bigSwing {
  position: absolute;
  top: 0;
  left: 0;
  width: 0;
  height: calc(232 * var(--gu));
  transform-origin: 0 0;
  perspective: calc(900 * var(--gu));
  perspective-origin: 0 calc(132 * var(--gu));
}
.wire {
  position: absolute;
  top: 0;
  left: calc(-0.5 * max(1px, calc(0.7 * var(--gu))));
  width: max(1px, calc(0.7 * var(--gu)));
  height: calc(34 * var(--gu));
  background: var(--wire);
}

/* A garment: a box of three faces about its hook (garments.tsx). */
.turn {
  position: absolute;
  top: calc(32 * var(--gu));
  left: 0;
  width: 0;
  height: calc(200 * var(--gu));
  transform-style: preserve-3d;
}
.face {
  position: absolute;
  top: 0;
  height: 100%;
  backface-visibility: hidden;
  -webkit-backface-visibility: hidden;
}
.faceFront,
.faceBack {
  left: calc(-110 * var(--gu));
  width: calc(220 * var(--gu));
}
/* Each face stands off the axis, and is drawn a little smaller so that, seen square on through the
   perspective, it is its own size. */
.faceFront {
  transform: translateZ(calc(22 * var(--gu))) scale(0.976);
  opacity: var(--front, 1);
}
.faceBack {
  transform: rotateY(180deg) translateZ(calc(22 * var(--gu))) scale(0.976);
}
.faceSide {
  left: calc(-40 * var(--gu));
  width: calc(80 * var(--gu));
  transform: rotateY(-90deg) translateZ(calc(60 * var(--gu))) scale(0.933);
  opacity: var(--side, 1);
}
.garmentImage {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: contain;
  filter: drop-shadow(calc(3 * var(--gu)) calc(4 * var(--gu)) calc(4 * var(--gu)) var(--g-shadow));
}
.garmentPrint {
  position: absolute;
  top: 34%;
  left: 50%;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: calc(2 * var(--gu));
  transform: translateX(-50%);
  color: var(--print-ink);
  font-size: calc(9.5 * var(--gu));
  font-weight: 600;
  line-height: 1;
  letter-spacing: -0.04em;
  opacity: 0.94;
  pointer-events: none;
}
.printLogo {
  width: calc(20 * var(--gu));
  height: calc(18 * var(--gu));
  background: currentColor;
  mask-size: contain;
  mask-repeat: no-repeat;
  mask-position: center;
}
.backPrint {
  top: 33%;
  gap: calc(4 * var(--gu));
  font-size: calc(13 * var(--gu));
}
.backPrint .printLogo {
  width: calc(29 * var(--gu));
  height: calc(26 * var(--gu));
}
.turn[data-kind='hoodie'] .garmentPrint {
  top: 39%;
}
.turn[data-kind='hoodie'] .backPrint {
  top: 42%;
}
.turn[data-kind='zip'] .faceFront .garmentPrint {
  top: 35%;
  left: 65%;
  transform: translateX(-50%) scale(0.72);
  transform-origin: center top;
}

/* The front garment is a button: a tap opens its detail. */
.frontButton {
  position: absolute;
  top: calc(55 * var(--u));
  left: calc(70 * var(--u));
  width: calc(220 * var(--u));
  height: calc(205 * var(--u));
  padding: 0;
  border: 0;
  border-radius: calc(12 * var(--u));
  background: none;
  cursor: pointer;
}

/* ---- The garment's name, the hint, the counter and the button. ---- */
.name {
  top: calc(416 * var(--u));
  color: var(--ink);
  font-size: calc(13.6 * var(--u));
  line-height: calc(20 * var(--u));
  text-align: center;
}
.hint {
  top: calc(441 * var(--u));
  color: var(--muted);
  font-size: calc(10.8 * var(--u));
  line-height: calc(16 * var(--u));
  text-align: center;
}
.counter {
  top: calc(482 * var(--u));
  display: flex;
  justify-content: center;
  align-items: center;
  height: calc(36 * var(--u));
}
.count {
  width: calc(90 * var(--u));
  color: var(--faint);
  font-size: calc(10.4 * var(--u));
  letter-spacing: 0.07em;
  text-align: center;
  font-variant-numeric: tabular-nums;
}
.slash {
  margin: 0 calc(1 * var(--u));
}
.step {
  display: grid;
  place-items: center;
  width: calc(36 * var(--u));
  height: calc(36 * var(--u));
  margin: 0 calc(17 * var(--u));
  padding: 0;
  border: 0;
  background: none;
  color: var(--soft);
  cursor: pointer;
  transition: opacity 0.2s ease;
}
.step:disabled {
  opacity: 0.3;
  cursor: default;
}
.longArrow {
  width: calc(17 * var(--u));
  height: calc(8 * var(--u));
  fill: none;
  stroke: currentColor;
  stroke-width: 1;
  vector-effect: non-scaling-stroke;
}
.explore {
  top: calc(533 * var(--u));
  left: calc(105 * var(--u));
  right: auto;
  display: flex;
  align-items: center;
  justify-content: space-between;
  box-sizing: border-box;
  width: calc(150 * var(--u));
  height: calc(42 * var(--u));
  padding: 0 calc(19 * var(--u)) 0 calc(19 * var(--u));
  border: 1px solid var(--pill-line);
  border-radius: 999px;
  color: var(--ink);
  font-size: calc(12.2 * var(--u));
  text-decoration: none;
  transition: background-color 0.2s ease;
}
.explore:hover {
  background: color-mix(in srgb, var(--ink) 4%, transparent);
}
.exploreArrow {
  width: calc(7 * var(--u));
  height: calc(7 * var(--u));
  margin-top: calc(2 * var(--u));
}

/* ---- The red strip, scrolling. ---- */
.marquee {
  top: calc(605 * var(--u));
  height: calc(35 * var(--u));
  overflow: hidden;
  background: var(--red);
  color: var(--on-red);
}
.track {
  display: flex;
  width: max-content;
  height: 100%;
  padding-left: calc(23 * var(--u));
  animation: marquee 30s linear infinite paused;
  will-change: transform;
}
.marquee[data-running] .track {
  animation-play-state: running;
}
.run {
  display: flex;
  height: 100%;
}
.line {
  display: flex;
  align-items: center;
  gap: calc(26 * var(--u));
  padding-right: calc(26 * var(--u));
  font-size: calc(9.4 * var(--u));
  letter-spacing: 0.02em;
  white-space: nowrap;
}
.spark {
  width: calc(12 * var(--u));
  height: calc(14 * var(--u));
  fill: currentColor;
}
@keyframes marquee {
  to {
    transform: translateX(-50%);
  }
}

/* ---- The detail: a sheet over the page, which blurs behind it. ---- */
.sheet {
  position: absolute;
  inset: 0;
  z-index: 3;
  color: var(--ink);
}
.sheet::before {
  content: '';
  position: absolute;
  inset: 0 calc(3.5 * var(--u)) calc(13 * var(--u));
  background: var(--sheet);
  -webkit-backdrop-filter: blur(calc(9 * var(--u)));
  backdrop-filter: blur(calc(9 * var(--u)));
}
.sheet > * {
  position: absolute;
}
.sheet p,
.sheet h2 {
  margin: 0;
}
.close {
  top: calc(18 * var(--u));
  right: calc(18 * var(--u));
  display: flex;
  align-items: center;
  gap: calc(20 * var(--u));
  height: calc(36 * var(--u));
  padding: 0 calc(9 * var(--u));
  border: 0;
  background: none;
  color: var(--ink);
  font: inherit;
  font-size: calc(12.2 * var(--u));
  cursor: pointer;
}
.closeIcon {
  width: calc(10 * var(--u));
  height: calc(10 * var(--u));
  fill: none;
  stroke: currentColor;
  stroke-width: calc(1.6 * var(--u));
}
.bigStage {
  inset: calc(64 * var(--u)) 0 auto;
  height: calc(300 * var(--u));
}
.bigArt {
  --gu: calc(var(--u) * 1.21);
  position: absolute;
  top: calc(4 * var(--u));
  left: calc(180 * var(--u));
  width: 0;
  height: 0;
  transform-origin: 0 0;
}
.bigTurn {
  transition: transform 300ms cubic-bezier(0.45, 0, 0.55, 1);
}
.bigArt[data-side='back'] .bigTurn {
  transform: rotateY(180deg);
}
/* Half round, the side shows only around its middle, when it faces the reader. The easing is
   symmetric, so that middle is the middle of the time too. */
.bigTurn > :nth-child(2) {
  opacity: 0;
  animation: side-to-front 300ms linear;
}
.bigArt[data-side='back'] .bigTurn > :nth-child(2) {
  animation-name: side-to-back;
}
@keyframes side-to-back {
  25%,
  75% {
    opacity: 0;
  }
  50% {
    opacity: 1;
  }
}
@keyframes side-to-front {
  25%,
  75% {
    opacity: 0;
  }
  50% {
    opacity: 1;
  }
}
.bigCount {
  top: calc(394 * var(--u));
  left: 0;
  right: 0;
  color: var(--faint);
  font-size: calc(9.6 * var(--u));
  letter-spacing: 0.08em;
  line-height: calc(16 * var(--u));
  text-align: center;
  font-variant-numeric: tabular-nums;
}
.bigName {
  top: calc(415 * var(--u));
  left: 0;
  right: 0;
  font-size: calc(21.5 * var(--u));
  font-weight: 400;
  line-height: calc(30 * var(--u));
  text-align: center;
}
.bigSub {
  top: calc(449 * var(--u));
  left: 0;
  right: 0;
  color: var(--muted);
  font-size: calc(11.4 * var(--u));
  line-height: calc(16 * var(--u));
  text-align: center;
}
.tabs {
  top: calc(474 * var(--u));
  left: calc(105 * var(--u));
  display: flex;
  width: calc(150 * var(--u));
  height: calc(40 * var(--u));
}
.tab {
  width: 50%;
  height: calc(34 * var(--u));
  padding: 0;
  border: 0;
  background: none;
  color: var(--tab-off);
  font: inherit;
  font-size: calc(12.4 * var(--u));
  cursor: pointer;
  transition: color 0.25s ease;
}
.tab[aria-selected='true'] {
  color: var(--ink);
}
.underline {
  position: absolute;
  top: calc(38.5 * var(--u));
  left: calc(8 * var(--u));
  width: calc(59 * var(--u));
  height: max(1px, calc(0.9 * var(--u)));
  background: var(--ink);
  transition: transform 300ms cubic-bezier(0.45, 0.05, 0.3, 1);
}
.underline[data-side='back'] {
  transform: translateX(calc(75 * var(--u)));
}
.foot {
  top: calc(526 * var(--u));
  left: calc(48 * var(--u));
  right: calc(48 * var(--u));
  display: flex;
  justify-content: space-between;
  align-items: center;
  height: calc(42 * var(--u));
  border-top: 1px solid var(--line);
  font-size: calc(12.2 * var(--u));
}
.shopLink {
  display: flex;
  align-items: center;
  gap: calc(5 * var(--u));
  color: var(--link);
  text-decoration: none;
}
.linkArrow {
  width: calc(7 * var(--u));
  height: calc(7 * var(--u));
}

/* ---- Focus, the demo's fingertip, and text for screen readers only. ---- */
.root :is(button, a):focus {
  outline: none;
}
.root :is(button, a):focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
  border-radius: 6px;
}
.root .explore:focus-visible {
  border-radius: 999px;
}

.touch {
  position: absolute;
  top: calc(-13 * var(--u));
  left: calc(-13 * var(--u));
  z-index: 5;
  width: calc(26 * var(--u));
  height: calc(26 * var(--u));
  border: 1.5px solid var(--touch-edge);
  border-radius: 50%;
  background: var(--touch);
  box-shadow: 0 1px 6px rgb(0 0 0 / 0.15);
  opacity: 0;
  pointer-events: none;
  will-change: transform, opacity;
}

.hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

@media (prefers-reduced-motion: reduce) {
  .track,
  .bigTurn > :nth-child(2) {
    animation: none;
  }
  .bigTurn,
  .underline,
  .tab,
  .step {
    transition: none;
  }
}
```

### clothing-rail/garments.tsx

```tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.

import type { CSSProperties, Ref } from 'react';
import type { RailItem } from './data';
import styles from './clothing-rail.module.css';

/** Three transparent product renders share the same hook and rotate with the hanger. */
export function Garment({
  item,
  brand,
  logoSrc,
  turnRef,
  className = '',
}: {
  item: RailItem;
  brand: string;
  logoSrc: string;
  turnRef?: Ref<HTMLDivElement>;
  className?: string;
}) {
  return (
    <div
      ref={turnRef}
      className={`${styles.turn} ${className}`}
      data-kind={item.kind}
      aria-hidden="true"
      style={{ '--print-ink': item.printColor ?? '#213841' } as CSSProperties}
    >
      {(['front', 'side', 'back'] as const).map((view) => (
        <div
          key={view}
          className={`${styles.face} ${
            view === 'front'
              ? styles.faceFront
              : view === 'back'
                ? styles.faceBack
                : styles.faceSide
          }`}
        >
          <img
            src={item.images[view]}
            className={styles.garmentImage}
            alt=""
            width={view === 'side' ? 240 : 660}
            height={600}
            draggable={false}
            decoding="async"
          />
          {view !== 'side' && (
            <span className={`${styles.garmentPrint} ${view === 'back' ? styles.backPrint : ''}`}>
              <span className={styles.printLogo} style={{ maskImage: `url("${logoSrc}")` }} />
              <span>{brand}</span>
            </span>
          )}
        </div>
      ))}
    </div>
  );
}
```

### clothing-rail/rail-motion.ts

```tsx
/*
 * The rail's motion: where each garment hangs, which way it faces, and how it swings, written
 * straight into transforms one frame at a time, and only while something moves.
 *
 * Moving to another garment plays in four steps, each picking up from wherever the last one was
 * when a new press comes in:
 *   away    the garment in front turns side on (170 ms, easing in);
 *   slide   the rail carries every garment along (260 ms for one step, 110 ms more per extra one);
 *   hold    a beat, all of them side on (140 ms);
 *   toward  the new one turns to face the reader (320 ms, easing out), with a kick on its hanger.
 *
 * - Positions: the front garment hangs in the middle, its neighbours under the rail's ends 142
 *   units either side (the column is 360 units wide), and the rest 64 units apart beyond them.
 * - Garments off to the sides all face the same way, as on a real rail: turned a quarter, so the
 *   one that comes in turns the same way the one that leaves turned.
 * - The side face fades in from a third of the way round, so edge on it is never a bar.
 * - Swing: a spring per rail pulls every hanger towards an angle against the rail's speed, so the
 *   clothes trail behind as they move and swing back when it stops; the front one has a spring of
 *   its own, kicked as it turns.
 */

export type MotionNodes = {
  slots: (HTMLElement | null)[];
  swings: (HTMLElement | null)[];
  turns: (HTMLElement | null)[];
};

export type Motion = {
  /** Heads for garment `index` (clamped to the rail). */
  go: (index: number) => void;
  /** Where it is heading. */
  target: () => number;
  /** The garment in front is facing the reader and nothing moves along the rail. */
  facing: () => boolean;
  /** The front garment turns side on at once and turns back round (after the detail closes). */
  turnIn: () => void;
  /** Hides one garment (the one the detail is showing), or none. */
  hide: (index: number | null) => void;
  /** Px per unit of the 360-unit column. */
  resize: (unit: number) => void;
  /** Instant moves, for reduced motion. */
  setInstant: (instant: boolean) => void;
  stop: () => void;
};

const AWAY = 170;
const SLIDE = 260;
const STEP = 110;
const HOLD = 140;
const TOWARD = 320;
/** Where the neighbours hang, and how far apart the rest. */
const NEAR = 142;
const FAR = 64;
/** The column's middle, in units. */
const MIDDLE = 180;

const easeIn = (t: number) => t * t;
const easeInOut = (t: number) => (t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2);
const easeOut = (t: number) => 1 - (1 - t) ** 3;
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));

type Phase = 'idle' | 'away' | 'slide' | 'hold' | 'toward';

export function createMotion({
  count,
  start,
  nodes,
  onIndex,
}: {
  count: number;
  start: number;
  nodes: () => MotionNodes;
  /** The garment the rail heads for, each time it sets off. */
  onIndex: (index: number) => void;
}): Motion {
  let pos = start;
  let target = start;
  let phase: Phase = 'idle';
  let t0 = 0;
  let from = start;
  let duration = SLIDE;
  /** How much the front garment faces the reader: 1 front on, 0 side on. */
  let face = 1;
  let faceFrom = 1;
  let unit = 1;
  let hidden: number | null = null;
  let instant = false;
  // The rail's swing and the front garment's, in degrees, and their speeds.
  let swing = 0;
  let swingSpeed = 0;
  let kick = 0;
  let kickSpeed = 0;
  let last = 0;
  let frame = 0;

  const front = () => Math.round(pos);

  const apply = () => {
    const { slots, swings, turns } = nodes();
    const middle = front();
    for (let i = 0; i < count; i++) {
      const slot = slots[i];
      if (!slot) continue;
      const o = i - pos;
      const reach = Math.abs(o);
      const offset = reach <= 1 ? o * NEAR : Math.sign(o) * (NEAR + (reach - 1) * FAR);
      const away = Math.abs(offset) > 250 || i === hidden;
      slot.style.visibility = away ? 'hidden' : 'visible';
      if (away) continue;
      slot.style.transform = `translate3d(${((MIDDLE + offset) * unit).toFixed(2)}px, 0, 0)`;
      const isFront = i === middle && phase !== 'slide';
      const angle = isFront ? 90 * (1 - face) : 90;
      const tilt = isFront ? swing * 0.6 + kick : swing;
      const turn = turns[i];
      const hanger = swings[i];
      if (turn) {
        turn.style.transform = `rotateY(${angle.toFixed(2)}deg)`;
        // The side face shows only past a third of the turn: before then, edge on, it would be a
        // dark bar beside the body.
        const side = clamp((angle - 30) / 35, 0, 1).toFixed(2);
        if (turn.style.getPropertyValue('--side') !== side) turn.style.setProperty('--side', side);
        // Nor is the front, nearly edge on, a sliver beside the side.
        const face = clamp((86 - angle) / 22, 0, 1).toFixed(2);
        if (turn.style.getPropertyValue('--front') !== face)
          turn.style.setProperty('--front', face);
      }
      if (hanger) hanger.style.transform = `rotate(${tilt.toFixed(3)}deg)`;
    }
  };

  const slide = (now: number) => {
    from = pos;
    const steps = Math.abs(target - from);
    duration = SLIDE + STEP * Math.max(0, Math.ceil(steps) - 1);
    t0 = now;
    phase = 'slide';
    onIndex(target);
  };

  const tick = (now: number) => {
    frame = 0;
    const dt = Math.min(0.05, last ? (now - last) / 1000 : 1 / 60);
    last = now;
    const before = pos;

    if (phase === 'idle' && (target !== pos || face < 1)) {
      if (target !== pos && face > 0) {
        phase = 'away';
        t0 = now;
        faceFrom = face;
      } else if (target !== pos) slide(now);
      else {
        phase = 'toward';
        t0 = now;
        faceFrom = face;
      }
    }
    if (phase === 'away') {
      if (target === front() && Number.isInteger(pos)) {
        // Pressed back to where it was: turn round again from here.
        phase = 'toward';
        t0 = now;
        faceFrom = face;
      } else {
        const p = clamp((now - t0) / (AWAY * Math.max(0.2, faceFrom)), 0, 1);
        face = faceFrom * (1 - easeIn(p));
        if (p >= 1) {
          face = 0;
          slide(now);
        }
      }
    }
    if (phase === 'slide') {
      const p = clamp((now - t0) / duration, 0, 1);
      pos = from + (target - from) * easeInOut(p);
      if (p >= 1) {
        pos = target;
        phase = 'hold';
        t0 = now;
      }
    }
    if (phase === 'hold') {
      if (target !== pos) slide(now);
      else if (now - t0 >= HOLD) {
        phase = 'toward';
        t0 = now;
        faceFrom = face;
        kickSpeed += 26;
      }
    }
    if (phase === 'toward') {
      if (target !== pos) {
        phase = 'away';
        t0 = now;
        faceFrom = face;
      } else {
        const p = clamp((now - t0) / (TOWARD * Math.max(0.2, 1 - faceFrom)), 0, 1);
        face = faceFrom + (1 - faceFrom) * easeOut(p);
        if (p >= 1) {
          face = 1;
          phase = 'idle';
        }
      }
    }

    // The springs: the rail's pulls against its speed (in garments a second), the front one's
    // back to hanging straight.
    const speed = (pos - before) / dt;
    const goal = clamp(-speed * 0.55, -6, 6);
    swingSpeed += (140 * (goal - swing) - 8 * swingSpeed) * dt;
    swing += swingSpeed * dt;
    kickSpeed += (-110 * kick - 7 * kickSpeed) * dt;
    kick += kickSpeed * dt;

    const settled =
      Math.abs(swing) < 0.01 &&
      Math.abs(swingSpeed) < 0.05 &&
      Math.abs(kick) < 0.01 &&
      Math.abs(kickSpeed) < 0.05;
    if (phase === 'idle' && settled) {
      swing = swingSpeed = kick = kickSpeed = 0;
      last = 0;
      apply();
      return;
    }
    apply();
    frame = requestAnimationFrame(tick);
  };

  const run = () => {
    if (instant) {
      if (target !== pos) onIndex(target);
      pos = target;
      face = 1;
      phase = 'idle';
      swing = swingSpeed = kick = kickSpeed = 0;
      apply();
      return;
    }
    if (!frame) frame = requestAnimationFrame(tick);
  };

  return {
    go(index) {
      const next = clamp(Math.round(index), 0, count - 1);
      if (next === target) return;
      target = next;
      run();
    },
    target: () => target,
    facing: () => phase === 'idle' && face === 1 && pos === target,
    turnIn() {
      if (instant) return apply();
      face = 0;
      phase = 'hold';
      t0 = performance.now() - HOLD + 80;
      run();
    },
    hide(index) {
      hidden = index;
      apply();
    },
    resize(next) {
      unit = next;
      apply();
    },
    setInstant(next) {
      instant = next;
      if (instant) {
        cancelAnimationFrame(frame);
        frame = 0;
        run();
      }
    },
    stop() {
      cancelAnimationFrame(frame);
      frame = 0;
    },
  };
}
```

### clothing-rail/data.ts

```tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.

/** Transparent 3D product renders, aligned to the same hanger hook. */
export type GarmentKind = 'hoodie' | 'crewneck' | 'zip';
export type RailItem = {
  name: string;
  kind: GarmentKind;
  /** Fabric colour, for the catalogue metadata. */
  color: string;
  colorName: string;
  description?: string;
  /** Transparent front, side and back images with matching framing. */
  images: { front: string; side: string; back: string };
  /** Ink used for the logo and the brand wordmark. */
  printColor?: string;
  href?: string;
};

export const KIND_LABEL: Record<GarmentKind, string> = {
  hoodie: 'Hoodie',
  crewneck: 'Crewneck',
  zip: 'Zip jacket',
};

export const ITEMS: RailItem[] = [
  {
    name: 'Fieldnote',
    kind: 'crewneck',
    color: '#e9e1cf',
    colorName: 'Cream',
    images: {
      front: '/experiments/clothing-rail/fieldnote-front.webp',
      side: '/experiments/clothing-rail/fieldnote-side.webp',
      back: '/experiments/clothing-rail/fieldnote-back.webp',
    },
    printColor: '#213841',
  },
  {
    name: 'Low Tide',
    kind: 'zip',
    color: '#2a2f52',
    colorName: 'Navy',
    images: {
      front: '/experiments/clothing-rail/low-tide-front.webp',
      side: '/experiments/clothing-rail/low-tide-side.webp',
      back: '/experiments/clothing-rail/low-tide-back.webp',
    },
    printColor: '#f4f1e8',
  },
  {
    name: 'Petal',
    kind: 'hoodie',
    color: '#ead8d1',
    colorName: 'Pale pink',
    images: {
      front: '/experiments/clothing-rail/petal-front.webp',
      side: '/experiments/clothing-rail/petal-side.webp',
      back: '/experiments/clothing-rail/petal-back.webp',
    },
    printColor: '#213841',
  },
  {
    name: 'Orchard',
    kind: 'crewneck',
    color: '#e6d4cc',
    colorName: 'Pale pink',
    images: {
      front: '/experiments/clothing-rail/orchard-front.webp',
      side: '/experiments/clothing-rail/orchard-side.webp',
      back: '/experiments/clothing-rail/orchard-back.webp',
    },
    printColor: '#213841',
  },
  {
    name: 'Fern',
    kind: 'hoodie',
    color: '#14843f',
    colorName: 'Green',
    images: {
      front: '/experiments/clothing-rail/fern-front.webp',
      side: '/experiments/clothing-rail/fern-side.webp',
      back: '/experiments/clothing-rail/fern-back.webp',
    },
    printColor: '#f4f1e8',
  },
  {
    name: 'Nightshift',
    kind: 'hoodie',
    color: '#232327',
    colorName: 'Charcoal',
    description: 'Unisex hoodie',
    images: {
      front: '/experiments/clothing-rail/nightshift-front.webp',
      side: '/experiments/clothing-rail/nightshift-side.webp',
      back: '/experiments/clothing-rail/nightshift-back.webp',
    },
    printColor: '#f4f1e8',
  },
  {
    name: 'Rosehip',
    kind: 'crewneck',
    color: '#e8d2c9',
    colorName: 'Pale pink',
    images: {
      front: '/experiments/clothing-rail/rosehip-front.webp',
      side: '/experiments/clothing-rail/rosehip-side.webp',
      back: '/experiments/clothing-rail/rosehip-back.webp',
    },
    printColor: '#213841',
  },
  {
    name: 'Harbour',
    kind: 'zip',
    color: '#262b4d',
    colorName: 'Navy',
    images: {
      front: '/experiments/clothing-rail/harbour-front.webp',
      side: '/experiments/clothing-rail/harbour-side.webp',
      back: '/experiments/clothing-rail/harbour-back.webp',
    },
    printColor: '#f4f1e8',
  },
  {
    name: 'Oat',
    kind: 'hoodie',
    color: '#e7dcc6',
    colorName: 'Cream',
    images: {
      front: '/experiments/clothing-rail/oat-front.webp',
      side: '/experiments/clothing-rail/oat-side.webp',
      back: '/experiments/clothing-rail/oat-back.webp',
    },
    printColor: '#213841',
  },
  {
    name: 'Moss',
    kind: 'crewneck',
    color: '#1f6b3c',
    colorName: 'Green',
    images: {
      front: '/experiments/clothing-rail/moss-front.webp',
      side: '/experiments/clothing-rail/moss-side.webp',
      back: '/experiments/clothing-rail/moss-back.webp',
    },
    printColor: '#f4f1e8',
  },
];
```

### clothing-rail/use-autoplay.ts

```tsx
'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;
        update();
      },
      { threshold: 0.3 },
    );
    observer.observe(element);
    document.addEventListener('visibilitychange', update);
    const motion = window.matchMedia('(prefers-reduced-motion: reduce)');
    const readMotion = () => setReduced(motion.matches);
    readMotion();
    motion.addEventListener('change', readMotion);
    return () => {
      observer.disconnect();
      document.removeEventListener('visibilitychange', update);
      motion.removeEventListener('change', readMotion);
    };
  }, []);

  const take = useCallback(() => {
    setTaken(true);
    window.clearTimeout(timer.current);
    timer.current = window.setTimeout(() => setTaken(false), idle);
  }, [idle]);
  useEffect(() => () => window.clearTimeout(timer.current), []);

  return { ref, take, playing: visible && !reduced && !taken };
}
```