# A garment rail in React Native where each piece turns on its hook: perspective, bunching, swing

> How Turnrail turns a garment from its front photo to its side photo in perspective, bunches the rail's ends, swings each piece as a pendulum and keeps a drag at 1:1, with Reanimated on the web too.

Behind the build, by [Ramin Valadan](https://upfork.dev/developers/ramin-valadan), about [Turnrail — Fashion Shop Product Carousel & Detail Sheet for React Native and Expo](https://upfork.dev/products/fashion-shop-product-carousel-react-native) (Mobile component, Free). Published 2026-11-14. 32 min read. Written with AI: Claude Opus 5.5, through Claude.

## What it is

Turnrail is a clothing rail for shop apps, written in React Native for Expo. Garments hang on hangers along a short rail. The piece in front faces you and can be bought right under the rail; its neighbours hang side-on, and the rest bunch up at the brackets like hangers pushed aside. Drag the rail and each piece turns as it passes the front, the pieces swing a little as the rail stops, and they settle.

[Live preview of Turnrail — Fashion Shop Product Carousel & Detail Sheet for React Native and Expo](https://upfork.dev/products/fashion-shop-product-carousel-react-native)

This article is about the rail itself: the one number that places every piece, the pictures a turn needs, how a flat front photo turns in perspective and hands over to a side photo, how the ends bunch, how a drag stays under the finger and catches a moving rail, the pendulum, and what each frame costs on the web, with what helped and what did not. The tags, the flight into the detail sheet and into the bag, and React Native Web's traps are in a second article.

The demo's fourteen garments are AI-generated pictures (front, side, back, worn and close views of each piece), made for this product. None is a photograph of a real garment, and none shows a brand, a label or a person; the product's `PHOTOS.md` lists every one.

## One number moves the rail

Everything on the rail is a function of one float, the rail's position `p`. `p = 2` means piece 2 hangs in front; `p = 2.5` means the rail is half way to piece 3. A drag writes `p`, a flick or a tap aims a spring at a whole number, and every piece's angle, place, stacking and bunching follow from `p` in worklets (on a phone, on the UI thread). React renders only when a move ends.

For piece `i` the signed distance to the front is `d = i − p`. Its turn angle is

`θ = rest × smoothstep(|d|)`, with `smoothstep(x) = x²(3 − 2x)` on `x` clamped to 0..1,

where `rest` is 90° for a piece with a side photo and 70° for one without. So the front piece faces you (θ = 0), a piece one step or more away is side-on, and the turn happens while a piece crosses the middle. The ease is clamped before it is applied, because on the web a Reanimated timing's first frame can arrive at a slightly negative time (L-78XY).

Each piece's place comes from the pieces between it and the front. Two neighbouring hooks are held apart by what actually faces between them: piece `a`'s reach towards `b`, a gap, and `b`'s reach towards `a`, each at its present angle:

`D(a, b) = reach_a,right(θ_a) + gap + reach_b,left(θ_b)`

That is why the neighbours make room in the same motion as the turn: a piece turning to face you grows wider, and the spacing to both its neighbours grows with it in the same frame.

`components/turnrail/geometry.ts`, lines 241–303:

```ts
/**
 * Every piece's frame for rail position p. `settle` (model B only) is 0 while the rail moves and
 * runs to 1 as the piece in front turns to face you; model A ignores it. `dir` is 1, or -1 for
 * right-to-left (the order runs the other way).
 */
export function railFrames(sizes: PieceSize[], shape: RailShape, p: number, model: number, settle: number, dir: number): RailFrame[] {
  'worklet';
  const n = sizes.length;
  const out: RailFrame[] = [];
  if (n === 0) return out;
  const pc = Math.min(n - 1, Math.max(0, p));
  const angle = (i: number): number => {
    const d = i - pc;
    if (model === 0) return middleAngle(sizes[i], d);
    // Model B: side-on while moving; the piece nearest the front turns as the rail settles.
    const nearest = Math.round(pc);
    return i === nearest ? sizes[i].rest * (1 - smooth(settle)) : sizes[i].rest;
  };
  const thetas: number[] = [];
  for (let i = 0; i < n; i++) thetas.push(angle(i));
  const xs: number[] = new Array(n).fill(0);
  const f = Math.min(n - 1, Math.floor(pc));
  const t = pc - f;
  const pair = (a: number, b: number): number => {
    // From hook a to hook b (the next piece): a's reach towards b, the gap, b's reach towards a.
    // The pictures are never mirrored, so right to left the next piece hangs to the left and the
    // reaches that meet are a's left and b's right (the first RTL screens put a tag off the edge).
    const ea = extents(sizes[a], thetas[a]);
    const eb = extents(sizes[b], thetas[b]);
    return dir > 0 ? ea[1] + shape.gap + eb[0] : ea[0] + shape.gap + eb[1];
  };
  if (f + 1 < n) {
    const D = pair(f, f + 1);
    xs[f] = -t * D;
    xs[f + 1] = xs[f] + D;
  } else {
    xs[f] = 0;
  }
  for (let i = f + 2; i < n; i++) xs[i] = xs[i - 1] + pair(i - 1, i);
  for (let i = f - 1; i >= 0; i--) xs[i] = xs[i + 1] - pair(i, i + 1);
  // Past an end (the rubber band) the whole rail slides on, at the end pair's spacing, and the far
  // bunch takes it up: the rail follows the finger a little instead of stopping dead.
  const over = p - pc;
  if (over !== 0 && n > 1) {
    const D = over > 0 ? pair(n - 2, n - 1) : pair(0, 1);
    for (let i = 0; i < n; i++) xs[i] -= over * D;
  }
  // Stacking by rank: the piece nearest the front on top. A z from the distance itself changed on
  // nearly every frame for every piece, and each change made Chrome repaint and re-layer the rail
  //; a rank changes only when two pieces swap order, at the half-way points.
  const order: number[] = [];
  for (let i = 0; i < n; i++) order.push(i);
  order.sort((a, b) => Math.abs(a - pc) - Math.abs(b - pc) || a - b);
  const rank: number[] = new Array(n).fill(0);
  for (let k = 0; k < n; k++) rank[order[k]] = k;
  for (let i = 0; i < n; i++) {
    const e = extents(sizes[i], thetas[i]);
    const [x, bunch] = bunchPlace(shape, shape.centre + dir * xs[i], e[0], e[1]);
    const d = i - pc;
    out.push({ x, y: sagAt(shape, x), theta: thetas[i], z: 1000 - rank[i], bunch, d });
  }
  return out;
}
```

The front pair is placed at `−t × D` and `(1 − t) × D` around the middle, where `t` is the fraction of `p`, and the rest are chained outwards from them. Past either end (`p < 0` or `p > n − 1`) the whole rail slides on at the end pair's spacing and the far bunch takes it up: the rubber band. The last part of the function stacks the pieces, which the performance section comes back to.

## Pictures made for a turn

A turn needs two pictures of the same garment on its hanger: one from the front and one from the side, at one camera height, one light and one scale. No such pair turned up among free photographs. In a search of a large free media library, every matched front, side and back series of one garment was a museum record shot on a dress form, and a garment on a form stands with its shoulders filled out, where on a hanger it falls from two points. Of 432 photographs that a search for clothes on hangers returned, about 60 showed a garment at all, 4 were front views on a hanger, and none had a side view to go with it. So the demo's pictures were made with an AI image tool, from one set of prompts, and then cut out, levelled and sized for phones.

Three things decide whether such pictures can turn at all.

### The narrowest frame of the turn

> **Lesson: A turn between a front and a side photo is thinnest at an end: a width rule is about the pictures, not the easing** (My own mistake; learned by the AI (Claude Opus 5.5))
> **Rule:** If a turn drawn from a front and a side photo must never look thinner than a share of the front, check the side photo's width against that share; no easing changes the widths a turn shows.
> **Snippet:**
> ```ts
> // before: the width rule treated as a matter of easing the turn
> const visible = (W: number, D: number, theta: number) => W * Math.cos(theta) + D * Math.sin(theta);
> // after: visible = sqrt(W² + D²) · cos(theta − atan2(D, W)), smallest at an end of 0..90°,
> // so the rule is a check on the two pictures before anything is drawn
> const thickEnough = (W: number, D: number, share = 1 / 3) => D >= share * W;
> ```
> **Check:** evaluate `visible(W, D, θ)` at 1° steps from 0 to 90° for each piece: the smallest value is `min(W, D)` every time, and the largest is up to `√(W² + D²)` in between.
> **Applies to:** other: any turn drawn from two photographs of one object (seen in React Native 0.86 with Reanimated 4.5)
> **I assumed:** "never thinner than about a third of its front width" was a rule about the animation, met by easing the turn well.
> **What happened:** drawn with the front revealed by cos θ and the side by sin θ, the visible width is `W cos θ + D sin θ` for a front width W and a side width D. That is `√(W² + D²) · cos(θ − φ)` with `φ = atan2(D, W)`, which over 0–90° is smallest at one of its ends: W facing, D side-on. A hoodie side-on measured about 0.4 of its front (62 px against 136 px on a 2× crop) because the hood and the hanging sleeve give it depth; a flat shirt in true profile is far thinner.
> **Why:** easing changes when each width is shown, never which widths are shown, so the minimum is fixed by the pictures.
> **The fix:** settle the side view's angle and the garments' bulk before any picture is made. The side views were made with depth, and the turn hands over to the side photo at no less than 0.34 of the front's width (`Math.max(0.88 * s.ws, 0.34 * s.wf)` in `perspectiveTurn`).
> **Evidence:** the turn strips at 24 angles (commit ac5146e); in every film since, the thinnest piece in any frame is 0.338 to 0.401 of its front.

### Real lengths, not the picture's height

Catalogue pictures, real or generated, fill their frame the same way, so a cropped cardigan and a long dress arrive the same height. The rail draws every piece from its length in centimetres instead, from the hanger's top to the hem. The layout picks one scale, points per centimetre, so that the longest piece in the data fits under the rail, and each picture's height follows from its length and where its hook sits in it:

`hf = length × ptPerCm / (1 − hookY)`

`components/turnrail/rail-view.tsx`, lines 25–60:

```tsx
/** The rail's shape and scale for a box: the rail across the top, the longest piece fitting below. */
export function railLayout(width: number, top: number, bottom: number, longest: number): RailLayout {
  const bracket = 14;
  const left = bracket + 6;
  const right = width - bracket - 6;
  const y = top + 24;
  const sag = 0.025 * (right - left);
  const drop0 = 16;
  const room = bottom - (y + sag + drop0) - 8;
  // At most 3 pt per cm: larger, and the three pieces with room no longer leave the bunches any
  // (and the rail pictures are made for 3 pt per cm at 2x, so nothing is enlarged).
  const cap = Math.min(5, RAIL_PICTURE_PT_PER_CM * Math.max(1, width / 430));
  const ptPerCm = Math.max(0.8, Math.min(cap, room / Math.max(1, longest)));
  const k = ptPerCm / 2.6;
  return {
    shape: { left, right, centre: width / 2, y, sag, gap: Math.max(8, 15 * k), bunchDepth: Math.max(30, 0.1 * width) },
    ptPerCm,
    drop: Math.max(11, drop0 * Math.min(1, k * 1.15)),
    used: y + sag + Math.max(11, drop0 * Math.min(1, k * 1.15)) + longest * ptPerCm + 26,
  };
}

/** Each piece's drawn sizes at the rail's scale, from its photos and its real length. */
export function pieceGeometry(g: Garment, colourway: number, layout: RailLayout, frontOnly = false, draw = 0): PieceGeometry {
  const cw = g.colourways[Math.min(colourway, g.colourways.length - 1)];
  const F = cw.front;
  const S = frontOnly ? undefined : cw.side;
  const L = g.length ?? DEFAULT_LENGTH;
  const hf = (L * layout.ptPerCm) / Math.max(0.5, 1 - F.hook[1]);
  const wf = (F.width * hf) / F.height;
  if (S) {
    const hfSide = (L * layout.ptPerCm) / Math.max(0.5, 1 - S.hook[1]);
    return { wf, hf, ws: (S.width * hfSide) / S.height, hx: F.hook[0], hs: S.hook[0], rest: SIDE_ANGLE, draw, hfSide, hyFront: F.hook[1], hySide: S.hook[1], drop: layout.drop };
  }
  return { wf, hf, ws: 0.3 * wf, hx: F.hook[0], hs: 0, rest: FRONT_ONLY_ANGLE, draw: 0, hfSide: hf, hyFront: F.hook[1], hySide: F.hook[1], drop: layout.drop };
}
```

The front and the side of one piece get the same scale from the same length, which is also what makes them join at the hook and the hem.

> **Lesson: Generated product pictures that all fill 85% of their frame erase real sizes: draw each item from its length in cm** (My own mistake; learned by the AI (Claude Opus 5.5))
> **Rule:** If generated or catalogue pictures of garments all fill their frame alike, store each item's real length and scale its picture from it; never take an item's size from its picture.
> **Snippet:**
> ```ts
> // before: each picture drawn at a share of the rail's height
> const hf = railRoom * 0.85;
> // after: one scale for the whole rail, from the longest piece; each piece from its own length
> const ptPerCm = Math.min(cap, room / longestCm);
> const hf = (lengthCm * ptPerCm) / (1 - hookY); // hookY: where the hook sits in the picture, 0..1
> ```
> **Check:** put a cropped cardigan (about 48 cm) beside a slip dress (about 118 cm) on the rail: the dress's hem hangs about 2.5 times as far below its hook as the cardigan's.
> **Applies to:** other: product pictures from an AI image tool or a catalogue drawn at scale (seen in React Native 0.86)
> **I assumed:** the pictures' own scale would carry the garments' sizes onto the rail.
> **What happened:** every prompt asked for the garment and its hanger to fill about 85% of the picture's height, as catalogue photographs do, so a cropped mohair cardigan (about 48 cm on its hanger) and a midi slip dress (about 118 cm) arrived the same height. Drawn as they came, fifteen different pieces would have read as one length.
> **The fix:** each garment carries `length` in cm (hanger to hem); the rail's `ptPerCm` is chosen so the longest fits, at most 3 pt per cm on a phone, and every picture of a piece is scaled from that length.
> **Evidence:** commit ac5146e; with the 118 cm dress in the data the jacket in front hangs about 14% shorter than before it (385 px against 331 px on a 2× poster), as a cropped jacket should beside a long dress.

### Two pictures joined at the hook

The two pictures must line up where the garment really is the same in both views. The brief said "the hook point and the shoulder line"; only one of those holds.

> **Lesson: Join front and side photos of a hanging garment at the hook and hem, not the shoulders, and plant faults to test it** (My own mistake; learned by the AI (Claude Opus 5.5))
> **Rule:** If two views of a hanging garment must line up, join them at the hook and scale them by the length to the hem; the shoulder line moves with the view, so measure it only in each view's own terms.
> **Snippet:**
> ```py
> # before: the side scaled so its shoulders meet the front's
> k = front_shoulder_y / side_shoulder_y
> # after: the hook is shared by both views and the hem hangs to the same length
> k = (front_hem_y - front_hook_y) / (side_hem_y - side_hook_y)
> # a shoulder check per view: where the outline first spreads past twice that view's hanger neck
> ```
> **Check:** plant a fault (a side with its shoulders 25 px lower, a colourway whose outline drifted): the pair check must fail it, and pass the true pairs within 0.6 px.
> **Applies to:** other: cut-out photographs of garments on hangers (Python with NumPy)
> **I assumed:** front and side would be aligned by "the hook point and the shoulder line", as the brief said.
> **What happened:** measured against the front's width, a near profile's narrower top reached the shoulder mark lower (5.6 to 7.5 px "off" at 2×), and a clip hanger's bar counted as shoulders (16.8 px). The hook, by contrast, is a point both views share, and a garment turned on its hook hangs to the same hem.
> **The fix:** the hook places a pair and the length scales it; the shoulder line is measured in each view as where the outline spreads past twice that view's hanger neck, and the good pairs agree within 0.6 px. Trousers on a clip hanger skip it.
> **Evidence:** planted faults caught (shoulders 25 px low: 10.1 px off; a drifted outline: IoU 0.916 against 0.998 for a true pair), commit ac5146e.

One more property of a hanging garment does the levelling, and it matters again for the swing below.

> **Lesson: Level a cut-out of a hanging garment by physics: turn it until its alpha centroid is straight below the hook** (My own mistake; learned by the AI (Claude Opus 5.5))
> **Rule:** If a garment cut-out has no horizontal line to level by (a hanger hidden in the cloth, an edge-on side view), turn it about its hook until its alpha-weighted centroid is directly below the hook.
> **Snippet:**
> ```py
> # before: level the hanger's bar (often hidden, and edge-on in a side view)
> angle = bar_angle(img)
> # after: at rest a hanging body's centre of mass is straight below its pivot
> ys, xs = np.nonzero(alpha > 0)
> w = alpha[ys, xs]
> cx, cy = (xs * w).sum() / w.sum(), (ys * w).sum() / w.sum()
> angle = np.degrees(np.arctan2(cx - hook_x, cy - hook_y))  # turn by -angle about the hook
> ```
> **Check:** draw a test picture 1.5° off: the measure reads about 1.6°, and after the turn the master measures within ±0.02°.
> **Applies to:** other: cut-out pictures of anything hanging from one point (Python with NumPy)
> **I assumed:** levelling a cut-out means making its hanger horizontal.
> **What happened:** the hanger is mostly hidden inside the garment, and in a side view it is edge-on, so there was often no line to measure at all.
> **Why:** a body hanging from a pivot comes to rest with its centre of mass directly below the pivot, in any view.
> **The fix:** each picture is turned about its hook until its alpha-weighted centroid is straight below it. It also matters to the motion: the rail's pendulum swings about the same hook, and a piece whose centre of mass is off its hook would hang crooked at rest and swing about the wrong axis.
> **Evidence:** a stand-in drawn 1.5° off measured 1.64 to 1.71° and was turned back; on the master 0.01° and −0.02° remain (commit ac5146e).

## The turn: perspective, then a wipe to the side photo

The obvious drawing is the brief's: the garment as a box turning on its hook, the front photo on one face foreshortened by cos θ and the side photo on the other by sin θ, with a shading band at the fold. It was built first, and it is still in the code as `draw: 1`, kept to compare on the strips.

> **Lesson: Two photos of a whole garment are not two faces of a box: turn the front in perspective, then hand over to the side** (My own mistake; learned by the AI (Claude Opus 5.5); react-native-reanimated@4.5.1)
> **Rule:** If a box built from a front and a side photo shows two garments side by side mid-turn, turn the front alone in perspective with a drawn edge, then hand over to the side photo.
> **Snippet:**
> ```ts
> // before: a box on its hook, both photos at once
> const front = [{ translateX: f.frontShift }, { scaleX: Math.cos(th * DEG) }];
> const side = [{ translateX: f.sideShift }, { scaleX: Math.sin(th * DEG) }];
> // after: one silhouette at a time; the front turns (its right edge comes towards you as it
> // faces left), then a short wipe near 81-89° hands over to the side photo at the same width
> const front = [{ perspective: 900 }, { rotateY: `${-perspectiveTurn(geo, th).phi}deg` }];
> const side = [{ translateX: -geo.hs * geo.ws }]; // still; shown through the wipe's clip
> ```
> **Check:** draw one piece at 24 angles from 0 to 90° at 2×: between 35° and 55° the box drew two jackets; the perspective turn draws one outline in every cell, with no detached line next to it.
> **Applies to:** react-native-reanimated@>=4.5 <5, react-native@>=0.86 <0.87, other: any turn drawn from two full-view photographs
> **I assumed:** with a front and a side photo, the turn is a box on its hook: the front foreshortened by cos θ on the leading side, the side by sin θ on the trailing side, both opaque.
> **What happened:** near each end one face was a sliver, and the two outlines did not meet along a straight fold (sleeves, hems and collars differ), so the sliver floated beside the other face as a stray line. Sliding the thinner face under the other removed most slivers and showed the real fault: between 35° and 55° the composite drew two jackets side by side. A first perspective turn with a squeezed copy of the picture as its edge read as a second, thin jacket, and it turned the wrong way, so its leading edge stood out past the side photo at the hand-over.
> **Why:** each photo is a full view of the garment (front panel, sleeve and all), not one face of a box, so two of them shown at once are two garments.
> **The fix:** the front turns with `rotateY(-φ)` (a front turning to face left brings its right edge towards you); its edge is the front's own outline pushed back by 4.5% of its width and darkened, behind it; φ runs until front and edge are 88% as wide as the side photo; between 81° and 89° a wipe of two clipping boxes hands over to the side photo. Every picture is opaque in every frame.
> **Evidence:** single-piece strips of the varsity, leather, puffer and denim jackets at 24 angles, 2× crops checked for detached lines (commit ac5146e).

### The angle at which the front hands over

The front turns from 0 to an angle `φ_side` while the piece turns from 0 to 81°, and it must arrive exactly as wide as the side photo it hands over to. Its drawn width at a perspective angle φ, front plus edge, is

`w(φ) = wf cos φ + e sin φ`, with `e = 0.045 × wf` the edge's depth.

The target is 88% of the side photo's width `ws`, and never under 0.34 of the front: a hair narrower than the side photo, so the front's outline sits inside the side's while the wipe line crosses it (at the full width its leading edge stood a hair outside and showed as a line). Writing `w(φ)` as one cosine,

`w(φ) = R cos(φ − α)`, with `R = √(wf² + e²)` and `α = atan2(e, wf)`,

so `φ_side = α + acos(target / R)`, and the front's angle runs linearly with the piece's, `φ = φ_side × min(θ, 81) / 81`. From 81° to 89° the wipe runs from 0 to 1 on a smoothstep, and the piece's reach left and right of its hook is blended from the turned front's to the side photo's by the same amount, so the spacing on the rail stays continuous through the hand-over.

`components/turnrail/geometry.ts`, lines 145–199:

```ts
/**
 * The drawn edge's depth for the perspective turn, as a share of the front's width. The edge is
 * the front's own outline pushed back by this much (an extrusion), not a squeezed copy of the
 * picture: a squeezed copy read as a second, thin jacket beside the first.
 */
export const EDGE = 0.045;
/** The wipe from the turned front to the side photo, in degrees of turn. */
export const WIPE_FROM = 81;
export const WIPE_TO = 89;

export type Turn = {
  /** The front picture's perspective angle, in degrees. */
  phi: number;
  /** 0: all front; 1: all side photo. Between: a wipe whose line runs from the trailing edge. */
  wipe: number;
  /** The piece's reach left and right of its hook. */
  left: number;
  right: number;
};

/**
 * The perspective turn (draw 0). Two photos of a whole garment cannot be joined as the faces of
 * a box: each is a full view, so between 35° and 55° the composite drew two jackets side by side
 *. Instead the front picture turns in perspective with a drawn edge, narrowing until
 * front and edge are exactly as wide as the side photo (angle phiSide), and near side-on a short
 * wipe hands over to the side photo at that width. One silhouette at a time, both opaque, and
 * never narrower than the side photo, which is a third or more of the front.
 * Without a side photo it is the front-only turn: perspective and edge all the way to 70°.
 */
export function perspectiveTurn(s: PieceSize, theta: number): Turn {
  'worklet';
  const e = EDGE * s.wf;
  if (s.rest < SIDE_ANGLE) {
    const c = Math.cos(theta * DEG);
    const sn = Math.sin(theta * DEG);
    return { phi: theta, wipe: 0, left: s.hx * s.wf * c, right: (1 - s.hx) * s.wf * c + e * sn };
  }
  // The front hands over a little narrower than the side photo (88%), so its outline sits inside
  // the side's while the wipe line crosses: at full width its leading edge stood a hair outside
  // and showed as a line. Never under a third of the front's width.
  // wf cos φ + e sin φ = target, as R cos(φ − α) = target.
  const target = Math.max(0.88 * s.ws, 0.34 * s.wf);
  const R = Math.hypot(s.wf, e);
  const alpha = Math.atan2(e, s.wf);
  const phiSide = (alpha + Math.acos(Math.min(1, target / R))) / DEG;
  const phi = phiSide * Math.min(theta, WIPE_FROM) / WIPE_FROM;
  const wipe = smooth((theta - WIPE_FROM) / (WIPE_TO - WIPE_FROM));
  const c = Math.cos(phi * DEG);
  const sn = Math.sin(phi * DEG);
  const lf = s.hx * s.wf * c;
  const rf = (1 - s.hx) * s.wf * c + e * sn;
  const ls = s.hs * s.ws;
  const rs = (1 - s.hs) * s.ws;
  return { phi, wipe, left: lf + (ls - lf) * wipe, right: rf + (rs - rf) * wipe };
}
```

### Drawing it: the edge, the wipe, and no animated widths

A piece is a stack of `Animated.Image`s that all share one source. The front is drawn, then a black-tinted copy over it whose opacity is `0.26 × (1 − cos φ)` (the side turning away from the light), then a surface-tinted copy for depth in a bunch. Behind the front, the edge is the same picture again, turned with it and shifted by `e × sin φ` towards the receding side, under a black copy at 40%. Shading by tinted copies of the picture itself keeps every shade inside the garment's outline: nothing spills onto the wall.

`components/turnrail/piece.tsx`, lines 137–171:

```tsx
  // ── The front face ─────────────────────────────────────────────────────────────────────────
  const frontT = (th: number) => {
    'worklet';
    if (composite) {
      const f = turnFaces(geo, th);
      return [{ translateX: f.frontShift }, { scaleX: Math.max(Math.cos(th * DEG), 0.0001) }];
    }
    return [{ perspective: PERSPECTIVE }, { rotateY: `${-perspectiveTurn(geo, th).phi}deg` }];
  };
  // The drawn edge: the front's own outline, turned with it and pushed back towards the receding
  // side by the garment's depth, filled with its own colour darkened, behind the front. It meets
  // the outline everywhere, and at 0° it hides exactly behind the front.
  const edgeT = (th: number) => {
    'worklet';
    const phi = perspectiveTurn(geo, th).phi;
    return [{ translateX: EDGE * geo.wf * Math.sin(phi * DEG) }, { perspective: PERSPECTIVE }, { rotateY: `${-phi}deg` }];
  };
  // The side photo: still in the default drawing, compressed by sin θ in the composite.
  const sideT = (th: number) => {
    'worklet';
    if (composite) {
      const f = turnFaces(geo, th);
      return [{ translateX: f.sideShift }, { scaleX: Math.max(Math.sin(th * DEG), 0.0001) }];
    }
    return [{ translateX: -geo.hs * geo.ws }];
  };
  const frontZ = (th: number) => {
    'worklet';
    return composite && th >= 45 ? 10 : 20;
  };
  const frontShadeOf = (th: number) => {
    'worklet';
    const phi = composite ? th : perspectiveTurn(geo, th).phi;
    return 0.26 * (1 - Math.cos(phi * DEG));
  };
```

> **Lesson: A turned photo's edge filled with its average colour reads as cardboard: draw the picture itself, shifted and darkened** (My own mistake; learned by the AI (Claude Opus 5.5); react-native-reanimated@4.5.1)
> **Rule:** If the thickness behind a turning cut-out reads as a flat slab, draw it from the same picture, shifted back by its depth and darkened by a tinted copy.
> **Snippet:**
> ```tsx
> // before: a flat fill of the garment's darkened average colour behind the front
> <Animated.View style={[styles.abs, ff, { backgroundColor: darken(front.average, 0.3) }, edgeStyle]} />
> // after: the garment's own picture, turned with the front and pushed back, then darkened 40%
> <Animated.Image source={frontSrc} resizeMode="stretch" style={[styles.abs, ff, fo, edgeStyle]} />
> <Animated.Image source={frontSrc} resizeMode="stretch" style={[styles.abs, ff, fo, { tintColor: '#000000' }, edgeShade]} />
> ```
> **Check:** turn a two-coloured garment (a green body with cream sleeves) to 60–80°: the edge beside the sleeve is a darker cream, not a grey band.
> **Applies to:** react-native-reanimated@>=4.5 <5, react-native-web@>=0.21 <0.22, other: any cut-out photo turned in perspective
> **I assumed:** an edge filled with the garment's average colour, darkened 30%, would read as the garment's thickness.
> **What happened:** on the filmstrips, the edge behind the turning varsity jacket read as a flat grey slab beside its cream sleeve, like a sheet of card.
> **Why:** an average colour of a jacket of two colours is a third colour that is in neither; and a flat fill has none of the cloth's shading.
> **The fix:** the edge is the front picture itself, pushed back by 4.5% of its width and darkened 40% by a black-tinted copy at opacity 0.4, so a cream sleeve has a darker cream edge and a green body a darker green.
> **Evidence:** an edge sheet of the varsity, leather and puffer jackets at six angles each, light and dark (commit c31bbd7).

The wipe is two clipping boxes, one ending at a moving line and one starting at it. Each box slides with the line, and the picture inside it slides back by the same amount, so the pictures stay put while the line sweeps across the garment. The line is at `right + 4 − wipe × (right + left + 8)`: just past the turned front's trailing edge at `wipe = 0` and just past the side photo's other edge at `wipe = 1`. Nothing animates a width: on the web an animated width lays the page out again every frame.

`components/turnrail/piece.tsx`, lines 200–209:

```tsx
  // ── The wipe: two clip boxes that slide; their contents counter-slide and stay put ─────────────
  const wipeAt = (th: number) => {
    'worklet';
    const t = perspectiveTurn(geo, th);
    return t.right + 4 - t.wipe * (t.right + t.left + 8);
  };
  const frontClip = useAnimatedStyle(() => (composite ? {} : { transform: [{ translateX: wipeAt(thV.value) }] }));
  const frontInner = useAnimatedStyle(() => (composite ? {} : { transform: [{ translateX: CLIP - wipeAt(thV.value) }, { translateY: BIGH }] }));
  const sideClip = useAnimatedStyle(() => (composite ? {} : { transform: [{ translateX: wipeAt(thV.value) }] }));
  const sideInner = useAnimatedStyle(() => (composite ? {} : { transform: [{ translateX: -wipeAt(thV.value) }, { translateY: BIGH }] }));
```

The front-only turn, for shops with no side photos, is the same perspective turn with its edge and no wipe, resting at 70° so the front is still recognisable side-on.

## Making room, and bunching at the brackets

Chaining hooks outwards from the front gives places that run off both ends of the rail. Towards each bracket the places are compressed: identity inside a band, and an exponential approach to a limit outside it. For a place `x` past the inner limit `hi`,

`x' = hi + B × (1 − e^(−(x − hi)/B))`,

which follows `x` at first and never passes `hi + B`, however far `x` goes. `B` is the bunch's depth (a tenth of the rail's width, at least 30 pt). How deep a piece is in the bunch, `clamp((x − hi) / 2B)`, tints it towards the wall a little and damps its swing more.

`components/turnrail/geometry.ts`, lines 220–239:

```ts
/**
 * Pieces near a bracket pile up: identity inside, an easing towards the bracket outside. The
 * limit is the piece's own outline, not its hook: with the hook as the limit, a side-on piece's
 * body hung past the bracket and off the screen (first stills).
 */
export function bunchPlace(shape: RailShape, x: number, reachL = 0, reachR = 0): [number, number] {
  'worklet';
  const B = shape.bunchDepth;
  const hi = shape.right - reachR - B;
  const lo = shape.left + reachL + B;
  if (x > hi) {
    const over = x - hi;
    return [hi + B * (1 - Math.exp(-over / B)), clamp01(over / (2 * B))];
  }
  if (x < lo) {
    const over = lo - x;
    return [lo - B * (1 - Math.exp(-over / B)), clamp01(over / (2 * B))];
  }
  return [x, 0];
}
```

> **Lesson: Pieces bunched at a rail's end by their hooks hang past the end: limit each by its own outline at its present angle** (My own mistake; learned by the AI (Claude Opus 5.5))
> **Rule:** If items compressed towards a container's end by their anchor points still stick out past it, put each one's own reach at its present angle into the limit, not only its anchor.
> **Snippet:**
> ```ts
> // before: the hook is kept inside the bracket
> const hi = shape.right - B;
> // after: the piece's outline is: its reach towards the bracket at its angle comes off the limit
> const [reachL, reachR] = extents(size, theta);
> const hi = shape.right - reachR - B;
> const lo = shape.left + reachL + B;
> ```
> **Check:** park the widest side-on piece at the end of the rail and drag past it at 320 × 568: no piece's outline crosses the bracket's inner face in any frame.
> **Applies to:** other: any carousel or rail whose items bunch at its ends (seen in React Native 0.86 with Reanimated 4.5)
> **I assumed:** pieces bunch by their hooks, so keeping the hooks inside the brackets keeps the pieces inside.
> **What happened:** on the first stills at 390 × 844 and 320 × 568 the hooks stayed inside the brackets, but side-on pieces in a bunch hung past the bracket and off the screen.
> **Why:** a hook sits at its own point in each picture (a side photo's hook is off its middle), so a piece reaches past its hook by a different amount at every angle.
> **The fix:** `bunchPlace` takes each piece's reach left and right at its angle (`extents`), and the limit is the outline: `shape.right − reachR − B`.
> **Evidence:** the first stills at four sizes, light and dark (commit ac5146e); every film since: hooks within 0.001 pt of the rail and no piece past a bracket.

Right to left, the rail runs the other way, but a shop cannot show its garments flipped: a pocket or a zip would be on the wrong side. So every photo stays as shot, and only the order is mirrored.

> **Lesson: A right-to-left rail of unmirrored photos must add the reaches that face each other, not the left-to-right sum mirrored** (My own mistake; learned by the AI (Claude Opus 5.5))
> **Rule:** If a right-to-left rail of photos that are never mirrored puts items off screen, space each pair by the reaches that actually face each other: this item's left and the next one's right.
> **Snippet:**
> ```ts
> // before: the left-to-right spacing, mirrored
> const D = ea[1] + gap + eb[0];
> const x = centre - xs[i];
> // after: right to left the next piece hangs to the left, so its right reach meets this one's left
> const D = dir > 0 ? ea[1] + gap + eb[0] : ea[0] + gap + eb[1];
> const x = centre + dir * xs[i];
> ```
> **Check:** run the rail with `dir="rtl"` and asymmetric pictures (a hook off the picture's middle): neighbours keep the same gap as left to right, and no tag or piece leaves the screen.
> **Applies to:** other: any right-to-left layout of pictures that keep their own orientation (seen in React Native 0.86)
> **I assumed:** mirroring the left-to-right places gives the right-to-left rail.
> **What happened:** the first right-to-left screen, using the left-to-right sum mirrored, put a neighbour's tag off the screen, because the reaches added were the ones facing away from each other.
> **Why:** only the order is mirrored, not the pictures, so a piece's left reach stays its left reach.
> **The fix:** `pair()` picks the reaches by direction, and the bunch limits and the hit test use each piece's physical reaches too.
> **Evidence:** the right-to-left purchase with Persian words and digits (commit 8861494).

## Drag at 1:1, flicks, and catching a moving rail

A drag must keep the piece under the finger under the finger. One step of the rail is not a fixed number of points: it is the distance between the two hooks the front lies between, at their present angles, and it changes as they turn. So each move of the finger is divided by the step width at that moment:

`p ← p − dir × dx / stepWidth(p)`

`components/turnrail/geometry.ts`, lines 305–323:

```ts
/**
 * How many points one step of the rail is at position p: the distance between the two hooks the
 * front lies between, at their present angles. A drag moves p by its pixels over this, so the
 * piece under the finger follows it 1:1.
 */
export function stepWidth(sizes: PieceSize[], shape: RailShape, p: number, model: number, settle: number, dir = 1): number {
  'worklet';
  const n = sizes.length;
  if (n < 2) return 1;
  const pc = Math.min(n - 1, Math.max(0, p));
  const a = Math.min(n - 2, Math.floor(pc));
  const angleOf = (i: number): number => {
    if (model === 0) return middleAngle(sizes[i], i - pc);
    return i === Math.round(pc) ? sizes[i].rest * (1 - smooth(settle)) : sizes[i].rest;
  };
  const ea = extents(sizes[a], angleOf(a));
  const eb = extents(sizes[a + 1], angleOf(a + 1));
  return Math.max(1, dir > 0 ? ea[1] + shape.gap + eb[0] : ea[0] + shape.gap + eb[1]);
}
```

The pan uses Gesture Handler 2.32 on all platforms, with three lessons from earlier UpFork products applied as found: `dx` is taken from `absoluteX` between updates rather than from `translationX`, which on the web counts from activation (L-S7AS); the flick's speed comes from the pan's own samples of the last 100 ms when `velocityX` reads 0 (L-ACFA); and a release more than 80 ms after the last real move counts as a release at rest (L-9DC7). Past an end, the finger's travel counts at 0.32.

`components/turnrail/turnrail.tsx`, lines 479–530:

```tsx
  const pan = Gesture.Pan()
    .enabled(n > 1)
    .activeOffsetX([-10, 10])
    .failOffsetY([-10, 10])
    .onBegin((event) => {
      // A hand on a moving rail catches it, where it is.
      cancelAnimation(p);
      aim.value = Math.round(p.value);
      startX.value = event.absoluteX;
      lastX.value = event.absoluteX;
      startTime.value = Date.now();
      lastMove.value = Date.now();
      samples.value = [event.absoluteX, Date.now()];
      // A finger resting on a piece shows its price (a mouse has shown it already, by hovering).
      pointAt(pieceAt(event.x, event.y + topBar));
    })
    .onStart(() => {
      scheduleOnRN(startSwing);
      scheduleOnRN(markMoved);
    })
    .onUpdate((event) => {
      // Measured from pointer-down, not from activation, so the first pixels count (L-S7AS).
      const dx = event.absoluteX - lastX.value;
      lastX.value = event.absoluteX;
      if (dx !== 0) {
        lastMove.value = Date.now();
        samples.value = [...samples.value.slice(-12), event.absoluteX, Date.now()];
      }
      const step = stepWidth(geos, shape, p.value, 0, 1, dirN);
      const beyond = p.value < 0 || p.value > last;
      p.value -= (dirN * dx * (beyond ? RUBBER : 1)) / step;
    })
    .onEnd((event) => {
      const resting = Date.now() - lastMove.value > RESTING_MS;
      const speed = Math.abs(event.velocityX) > 1 ? event.velocityX : trackedSpeed(samples.value);
      const step = stepWidth(geos, shape, p.value, 0, 1, dirN);
      const velocity = resting ? 0 : (-dirN * speed) / step;
      const from = Math.round(p.value);
      let target = Math.round(p.value + velocity * FLICK_CARRY);
      // A deliberate flick always moves at least one piece.
      if (target === from && Math.abs(velocity) > 1.4) target = from + Math.sign(velocity);
      springTo(target, velocity);
    })
    .onFinalize((event, success) => {
      // A lifted finger lets its tag go, after the grace; a mouse keeps it while it stays.
      if (event.pointerType !== PointerType.MOUSE) pointAt(-1);
      if (success) return;
      const still = Math.abs(event.absoluteX - startX.value) < 10 && Date.now() - startTime.value < 450;
      // The gesture's view starts topBar below the rail view, whose points the tags are in.
      if (still) tapAt(event.x, event.y + topBar);
      else springTo(p.value);
    });
```

At the release, the velocity in steps per second is carried forward for 0.32 s and rounded to a piece, and a deliberate flick (over 1.4 steps a second) always moves at least one. One spring takes it there, critically damped for mass 1, so its damping is `2√k`:

`ẍ = −k (x − target) − c ẋ`, with `k = 90` and `c = 2√90 ≈ 18.97`.

Its natural frequency is `√90 ≈ 9.5` rad/s and a step settles in about 700 ms, with no overshoot past the piece it aims at. The flick's speed is passed to the spring as its initial velocity, so the release is continuous. A hand landing on a moving rail cancels the spring in `onBegin` and the drag carries on from wherever the rail is: that is what "catching" a moving rail means here, and it needs nothing more because `p` is the only state.

`components/turnrail/motion.ts`, lines 12–41:

```ts
/**
 * Critically damped (damping = 2 sqrt(stiffness) for mass 1), so it never overshoots a piece. One
 * step settles in about 700 ms: inside the 550-750 ms aimed for, and a touch softer on landing than
 * the first 650 (an eased landing, never a stop). Arrow steps use the same spring.
 */
export const SNAP = { stiffness: 90, damping: 18.97, mass: 1, restDisplacementThreshold: 0.002, restSpeedThreshold: 0.004 };
/** Reduced motion: no spring, a short settle. */
export const REDUCED = { duration: 160 };
/** How far a flick carries, in seconds of its speed: a flick at 6 steps a second goes about 2. */
export const FLICK_CARRY = 0.32;
/** Past an end, the rail gives this much of the finger's travel (the rubber band). */
export const RUBBER = 0.32;
/** A release this long after the last real move is a release at rest (L-9DC7). */
export const RESTING_MS = 80;

/**
 * Speed from the pan's own samples [x, t, x, t, ...] over the last 100 ms, in points a second:
 * Gesture Handler's velocityX can be 0 at the end of a quick web flick (L-ACFA).
 */
export function trackedSpeed(samples: number[]): number {
  'worklet';
  const n = samples.length;
  if (n < 4) return 0;
  const x1 = samples[n - 2];
  const t1 = samples[n - 1];
  let k = n - 4;
  while (k >= 2 && t1 - samples[k - 1] <= 100) k -= 2;
  const dt = t1 - samples[k + 1];
  return dt > 0 ? ((x1 - samples[k]) / dt) * 1000 : 0;
}
```

> **Lesson: Chain button steps from where a spring is heading, and let the control shown take the press, not the one at rest** (My own mistake; learned by the AI (Claude Opus 5.5); react-native-reanimated@4.5.1)
> **Rule:** If quick Next or Previous presses during a spring move go fewer steps than pressed, step from the spring's target (set when it is aimed) and let the control shown take touches.
> **Snippet:**
> ```ts
> // before: a step from the settled index, and only the settled card takes touches
> const stepBy = (d: number) => springTo(settledIndex + d);
> // after: the target is kept as the spring is aimed, and steps chain from it
> const aim = useSharedValue(index);
> const springTo = (t: number, velocity?: number) => {
>   'worklet';
>   aim.value = t;
>   p.value = withSpring(t, { ...SNAP, velocity });
> };
> const stepBy = (d: number) => springTo(aim.value + d);
> ```
> **Check:** press Next three times 200 ms apart from piece 0: the rail lands on piece 3; two presses 300 ms apart from 3 back to 1.
> **Applies to:** react-native-reanimated@>=4.5 <5, react-native-web@>=0.21 <0.22
> **I assumed:** `pointerEvents` following the settled index was enough, because only one card is shown at a time.
> **What happened:** a script pressing Previous four times, 1.1 s apart, moved the rail two pieces. The card shown changes when the rail passes the middle, but the card taking touches changed only when the spring settled, so a press in between went to the old card hidden under the new one, which aimed the rail where it already was.
> **The fix:** each card takes touches as soon as it is shown, and the arrows step from where the rail is heading (`aim`), not from the card's own number, so quick presses chain into one spring.
> **Evidence:** two presses 300 ms apart went 3 → 1; three presses 200 ms apart went 0 → 3 (commit 2ad529e).

In a scrolling page the rail is one section among others, and a vertical swipe that starts on it must still scroll the page.

> **Lesson: Gesture Handler on the web blocks page scrolling over its view: give a sideways pan's GestureDetector touchAction pan-y** (Not in the docs; learned by the AI (Claude Opus 5.5); react-native-gesture-handler@2.32.0, react-native-web@0.21.4)
> **Rule:** If a horizontal Gesture Handler pan inside a scrolling web page stops the page from scrolling under a finger, give its GestureDetector `touchAction="pan-y"`.
> **Snippet:**
> ```tsx
> // before: Gesture Handler sets touch-action: none on its view, so the browser never scrolls there
> <GestureDetector gesture={railGesture}>
>   <View style={styles.rail}>{pieces}</View>
> </GestureDetector>
> // after: the browser keeps vertical pans; a sideways drag still reaches the pan
> <GestureDetector gesture={railGesture} touchAction="pan-y">
>   <View style={styles.rail}>{pieces}</View>
> </GestureDetector>
> ```
> **Check:** in a scrolling page, a real touch swipe up from the rail scrolls the page (the pan receives pointercancel and the rail stays put), and a sideways swipe moves the rail.
> **Applies to:** react-native-gesture-handler@>=2.32 <3, react-native-web@>=0.21 <0.22
> **I assumed:** pan offsets (start at 10 pt sideways, fail at 10 pt vertically) were enough to let a host's vertical scroll pass through.
> **What happened:** on the web a finger landing on the rail could not scroll the page around it at all: Gesture Handler sets `touch-action: none` on its view, so the browser never starts a scroll there, whatever the gesture's offsets say.
> **The fix:** `touchAction="pan-y"` on the rail's and the card's detectors: the browser takes a vertical drag and the pan gets `pointercancel`; a sideways one still moves the rail.
> **Evidence:** a real touch swipe sideways moved the rail two pieces; a vertical one ended in pointercancel with the rail unmoved; the wheel over the rail scrolled the page 240 pt (commit 8861494). Headless Chrome could not synthesise a finger scroll of the page itself, so that part is for a real phone.

## The swing

Each piece is a damped pendulum about the point where its hook meets the rail, pushed by the rail's acceleration. A garment on a hook that speeds up to the right hangs back to the left; when the hook stops, the garment's lag behind it swings it forward and it settles. Per frame, for each piece,

`ψ̈ = −ω² (ψ − ψ_rest) − 2ζω ψ̇`, with `ψ_rest = clamp(g × a(t − lag), ±2.6°)`

- `ψ` is the piece's swing in degrees, positive clockwise on screen;
- `ω = 2π × 1.3` rad/s, about 1.3 swings a second;
- `ζ = 0.68` of critical, plus `0.45 × bunch` in a bunch, where hangers knock together;
- `a` is its hook's acceleration on screen in points per second squared, worked out from the rail's position over the last frames and the step width: `a = −dir × (Δv / Δt) × step`;
- `g = 0.0061` degrees per pt/s² is how far it hangs back;
- `lag` is two frames per step from the front, so the push travels outwards along the rail, as on a real one.

It is integrated with semi-implicit Euler at the frame's own interval, clamped to 50 ms, inside a `useFrameCallback` that stops itself after 120 ms still (under 0.05° and 0.3°/s), so at rest nothing runs.

`components/turnrail/motion.ts`, lines 43–108:

```ts
/**
 * Pendulum constants: about 1.3 swings a second. Damped at 0.68 of critical, a garment's second
 * swing is about 5% of its first: one small swing on landing, where 0.5 rocked it back
 * by a fifth.
 */
export const SWING = {
  omega: 2 * Math.PI * 1.3,
  zeta: 0.68,
  /** Degrees a garment hangs back per point per second squared of its hook's acceleration. */
  gain: 0.0061,
  /** Never more than this many degrees. */
  max: 2.6,
  /** Frames of delay per step from the front: the push travels outwards. */
  delayPerStep: 2,
};

export type SwingState = {
  psi: number[];
  vel: number[];
  /** The hooks' recent accelerations on screen, newest first, in points per second squared. */
  accel: number[];
  lastP: number;
  lastV: number;
  quiet: number;
};

export function swingState(n: number, p: number): SwingState {
  'worklet';
  return { psi: new Array(n).fill(0), vel: new Array(n).fill(0), accel: new Array(16).fill(0), lastP: p, lastV: 0, quiet: 0 };
}

/**
 * One frame of every pendulum, as a new state (a shared value's object is never changed in place).
 * Each garment is pulled towards the angle it would hang at under its hook's present acceleration
 * (it hangs back as the rail speeds up); its lag behind that pull is what swings it forward when
 * the rail stops. `step` is the rail's points per step at the front, `dir` 1 or -1 (right-to-left),
 * `ds` each piece's distance to the front in steps, `bunch` how deep in a bunch (more damping:
 * hangers that knock together). `quiet` counts the still time; the caller stops at 120 ms.
 */
export function swingStep(s: SwingState, p: number, dtMs: number, step: number, dir: number, ds: number[], bunch: number[]): SwingState {
  'worklet';
  const dt = Math.min(0.05, Math.max(0.001, dtMs / 1000));
  const v = (p - s.lastP) / dt;
  // A rising p slides the hooks left on screen (right in right-to-left).
  const a = -dir * ((v - s.lastV) / dt) * step;
  const accel = [Number.isFinite(a) ? a : 0, ...s.accel.slice(0, s.accel.length - 1)];
  let moving = Math.abs(v) > 0.002;
  const w = SWING.omega;
  const psi: number[] = [];
  const vel: number[] = [];
  for (let i = 0; i < s.psi.length; i++) {
    const lag = Math.min(accel.length - 1, Math.round(Math.abs(ds[i] ?? 0) * SWING.delayPerStep));
    const zeta = SWING.zeta + 0.45 * (bunch[i] ?? 0);
    // Rotate(+) turns clockwise on screen, which swings a hanging garment's hem to the left: the
    // way it hangs back when its hook accelerates to the right.
    const rest = Math.max(-SWING.max, Math.min(SWING.max, SWING.gain * accel[lag]));
    const acc = -w * w * (s.psi[i] - rest) - 2 * zeta * w * s.vel[i];
    const nv = s.vel[i] + acc * dt;
    const np = Math.max(-SWING.max, Math.min(SWING.max, s.psi[i] + nv * dt));
    // Still once under what the eye can see (0.05 degrees): stricter, the frames ran 1.9 s.
    if (Math.abs(np) > 0.05 || Math.abs(nv) > 0.3) moving = true;
    psi.push(np);
    vel.push(nv);
  }
  return { psi, vel, accel, lastP: p, lastV: v, quiet: moving ? 0 : s.quiet + dtMs };
}
```

`components/turnrail/turnrail.tsx`, lines 254–273:

```tsx
  const swingFrame = useFrameCallback((info) => {
    'worklet';
    const f = frames.value;
    const ds: number[] = [];
    const bunch: number[] = [];
    for (let i = 0; i < f.length; i++) {
      ds.push(f[i].d);
      bunch.push(f[i].bunch);
    }
    const next = swingStep(swingS.value, p.value, info.timeSincePreviousFrame ?? 16, stepWidth(geos, shape, p.value, 0, 1, dirN), dirN, ds, bunch);
    swingS.value = next;
    swing.value = next.psi;
    if (next.quiet >= 120) scheduleOnRN(stopSwing);
  }, false);
  stopRef.current = () => swingFrame.setActive(false);
  const startSwing = useCallback(() => {
    if (reduceMotion || swingFrame.isActive) return;
    swingS.value = { ...swingS.value, lastP: p.value, lastV: 0, quiet: 0 };
    swingFrame.setActive(true);
  }, [reduceMotion, swingFrame, swingS, p]);
```

> **Lesson: A pendulum that rocks back too far needs a higher damping ratio, not a cap: the swing back is exp(−πζ/√(1−ζ²))** (My own mistake; learned by the AI (Claude Opus 5.5); react-native-reanimated@4.5.1)
> **Rule:** If a damped swing on landing rocks back visibly, raise its damping ratio and compute the overshoot, rather than clamping the angle: a clamp flattens the peak and leaves the rocking.
> **Snippet:**
> ```ts
> // before: half of critical damping, and a cap on the angle
> const SWING = { omega: 2 * Math.PI * 1.3, zeta: 0.5, max: 3.2 };
> // after: the swing back, half a period later, is exp(-PI*zeta/sqrt(1-zeta^2)) of the first:
> // 0.16 at zeta 0.5, 0.054 at zeta 0.68
> const SWING = { omega: 2 * Math.PI * 1.3, zeta: 0.68, max: 2.6 };
> const acc = -w * w * (psi - rest) - 2 * zeta * w * vel;
> ```
> **Check:** record a piece's swing over a landing after three quick steps: the first swing peaks at 2.6° or less and the swing back is about 5% of it.
> **Applies to:** other: any damped spring-pendulum animation (seen with react-native-reanimated@>=4.5 <5)
> **I assumed:** "at most one small swing on landing" was met by capping the angle.
> **What happened:** at half of critical damping each garment rocked back by about a fifth of its first swing after landing, which read as rocking rather than settling, and three quick chevron steps still took the swing up to its 3.2° cap.
> **Why:** for a damped oscillator the ratio of one peak to the next one the other way, half a period later, is `exp(−πζ/√(1 − ζ²))`; a cap only clips the first peak.
> **The fix:** damping went from 0.5 to 0.68 of critical, and the cap to 2.6°; the snap spring stays critically damped at stiffness 90, so it never passes a piece.
> **Evidence:** the films after the change: swing at most 2.6°, still 458 to 966 ms after the last move (commit a22aa93).

## What a frame costs on the web, and what helped

On a phone Reanimated runs all of this on the UI thread. On the web the worklets run on the page's own thread, and Reanimated's web style writer turns every animated style into DOM writes. Every number in this section is from the production web export in installed Chrome, headless with ANGLE, at 390 × 844, on a development machine that other test suites kept 40–63% busy. Nothing in it was measured on a real phone.

### Layers, stacking by rank, and numbers that settle

The first slow drag ran at a median of 23.9 ms a frame and a p95 of 55.8 ms. A trace put 4.7 ms a frame into Chrome's `Layerize` and 7,800 paints into 2 s; a CPU profile put Reanimated's web style writer at the top.

`components/turnrail/piece.tsx`, lines 88–99:

```tsx
  // The numbers the styles below are made from, each a shared value of its own. Away from the front
  // they stop changing (a piece in a bunch keeps its angle while the rail moves on), and a shared
  // value set to the number it already holds tells no one, so those styles do not run again. Every
  // style that read the frames itself was worked out and written to the page on every frame for
  // every piece: about 250 style writes a frame, the slowest part of a drag.
  const thV = useDerivedValue(() => thetaOf(frames.value[index]));
  const bunchV = useDerivedValue(() => {
    const f = frames.value[index];
    return f ? Math.round(f.bunch * 200) / 200 : 0;
  });
  const liftV = useDerivedValue(() => liftOf(frames.value[index]));
  const psiV = useDerivedValue(() => (swing ? Math.round((swing.value[index] ?? 0) * 100) / 100 : 0));
```

> **Lesson: Reanimated on the web: a many-layered view moves cheaply with will-change layers, zIndex by rank and settled values** (Not in the docs; learned by the AI (Claude Opus 5.5); react-native-reanimated@4.5.1, react-native-web@0.21.4)
> **Rule:** If a web trace of a Reanimated drag shows Layerize and thousands of paints, put will-change on every moving box, give zIndex by rank, and derive settled numbers so far items' styles stop running.
> **Snippet:**
> ```ts
> // before: every style reads the frames itself, z from the distance (new nearly every frame)
> const style = useAnimatedStyle(() => ({ zIndex: Math.round(1000 - 100 * Math.abs(frames.value[i].d)), transform: frontT(frames.value[i].theta) }));
> // after: a layer per moving box, z by rank, and a settled angle of its own
> const LAYER = Platform.OS === 'web' ? { willChange: 'transform, opacity' } : {};
> const thV = useDerivedValue(() => frames.value[i]?.theta ?? rest); // same number: nobody is told
> const style = useAnimatedStyle(() => ({ transform: frontT(thV.value) }));
> // z = 1000 - rank, where rank is the place in distance order: it changes only when two swap
> ```
> **Check:** trace a slow drag in Chrome: Layerize falls (here 4.7 to 3.0 ms a frame) and script time over the drag roughly halves (here 1,020 to 440 ms); frame p95 falls with them.
> **Applies to:** react-native-reanimated@>=4.5 <5, react-native-web@>=0.21 <0.22
> **I assumed:** with every motion on Reanimated's side and no React renders, a drag would run at 60 fps on the web.
> **What happened:** a slow drag ran at a median of 23.9 ms a frame, p95 55.8 ms, with four frames of 54 to 116 ms. The trace showed 4.7 ms a frame of Layerize and 7,800 paints in 2 s; the profile showed Reanimated's web style writer merging and converting the whole previous style of about 250 animated views every frame.
> **Why:** each piece's boxes moved by 2D transforms without layers of their own, so each move repainted them; a zIndex from the distance changed on nearly every frame for every piece, and each change is a new stacking order to repaint and re-layer; and every style read the frames, so every one ran every frame, even deep in a bunch where its angle never changes.
> **The fix:** `willChange: 'transform, opacity'` on the web for every moving box (root, clips, the boxes inside them, tag, hook curl) and not on still layers; zIndex by rank (1000 minus the place in distance order); and each piece derives the numbers its styles need (angle, bunch depth to 0.005, lift, swing to 0.01°) as shared values, since a shared value set to the number it already holds tells no one.
> **Evidence:** frame p95 drag 55.8 → 19.0–20.4 ms, flick 24.9 → 17.4–18.9 ms, chevron step 23.1 → 17.6–19.5 ms; with 40 pieces (front ±10 mounted) a flick's median went from 34.7 to 16.7 ms (commit 70a7d58).

The rank is worked out in `railFrames` itself (the last part of the first excerpt): sort the pieces by distance to the front, and give each `1000 − rank`. It changes only at the half-way points, where two pieces swap which is nearer.

### Writing the hot transform yourself

Even with settled values, the root of every moving piece has to change every frame of a drag, and each of those goes through Reanimated's style mapper and React Native Web's style resolver. On the web the worklets run on the page's thread anyway, so the hottest ones skip both: an animated reaction builds the transform string and writes it to the element, and only when the string changed.

`components/turnrail/piece.tsx`, lines 101–135:

```tsx
  // The piece swings about where its hook meets the rail, so the hook never leaves the rail's
  // curve: placed at that point, turned by the swing, then hung down by the hook's drop.
  const root = useAnimatedStyle(() => {
    if (WEB) return {};
    const f = frames.value[index];
    if (!f) return { opacity: 0 };
    return { opacity: 1, zIndex: f.z, transform: [{ translateX: f.x }, { translateY: f.y }, { rotate: `${psiV.value}deg` }, { translateY: geo.drop }] };
  });
  // On the web the worklets run on the page's own thread, so the root writes its transform to its
  // element directly, and only when it changed: through a style mapper, React Native Web converted
  // the whole style for every piece on every frame. A phone keeps the mapper.
  const rootRef = useRef<View>(null);
  if (WEB) {
    // eslint-disable-next-line react-hooks/rules-of-hooks -- WEB is fixed for the app's life.
    useAnimatedReaction(
      () => {
        const f = frames.value[index];
        return f ? `translate(${f.x}px, ${f.y}px) rotate(${psiV.value}deg) translateY(${geo.drop}px)|${f.z}` : '';
      },
      (v, was) => {
        if (v === was) return;
        const el = rootRef.current as unknown as { style: Record<string, string> } | null;
        if (!el) return;
        if (!v) {
          el.style.opacity = '0';
          return;
        }
        const bar = v.indexOf('|');
        el.style.transform = v.slice(0, bar);
        el.style.zIndex = v.slice(bar + 1);
        el.style.opacity = '1';
      },
      [geo.drop],
    );
  }
```

> **Lesson: On the web, write a hot Reanimated transform to the element yourself: the style mapper converts every style each frame** (Not in the docs; learned by the AI (Claude Opus 5.5); react-native-reanimated@4.5.1, react-native-web@0.21.4)
> **Rule:** If a web profile shows Reanimated's style mapper converting many moving views' styles each frame, write their transform to the element in a useAnimatedReaction, only on change.
> **Snippet:**
> ```tsx
> // before: an animated style on every piece's root
> const root = useAnimatedStyle(() => ({ transform: [{ translateX: f.x }, { translateY: f.y }, { rotate: `${psi}deg` }] }));
> // after (web only; WEB is a module constant, so the hooks' order never changes)
> if (WEB) {
>   useAnimatedReaction(
>     () => { const f = frames.value[i]; return `translate(${f.x}px, ${f.y}px) rotate(${psiV.value}deg)`; },
>     (v, was) => { if (v !== was && ref.current) (ref.current as unknown as HTMLElement).style.transform = v; },
>   );
> }
> // the root is a plain View on the web, so its ref is the DOM element
> ```
> **Check:** profile a drag at 1× and 3× DPR before and after: the style mapper's share falls, and frame p95 drops (here drag 21 → 19.5 ms, arrow step 22 → 17.5–18 ms).
> **Applies to:** react-native-reanimated@>=4.5 <5, react-native-web@>=0.21 <0.22
> **I assumed:** Reanimated's animated styles were the cheapest way to move a view on the web too.
> **What happened:** after layers and settled values, the remaining script per frame was mostly Reanimated's web style mapper handing every animated style to React Native Web, which resolved and converted the whole style each time, for every piece root, tag and thread.
> **Why:** on the web the worklets already run on the page's own thread, so the mapper buys nothing there but the conversion.
> **The fix:** the piece roots, tags and threads write their transform (and z and opacity) to their own element in a `useAnimatedReaction`, only when the string changed; the roots are plain Views on the web so the ref is the element; phones keep the mappers.
> **Evidence:** at 1× CPU and 3× DPR on a busy machine, drag p95 21 → 19.5 ms, flick 21 → 18–19.5, step 22 → 17.5–18, fling to the end 23.5 → 18.2–18.5 (commit a22aa93).

> **Lesson: One layer per item instead of one per moving part doubled a web drag's p95: 3D-turned pictures need their own** (The tools and the machine; learned by the AI (Claude Opus 5.5); react-native-reanimated@4.5.1, react-native-web@0.21.4)
> **Rule:** If Layerize dominates a web frame with many layers, do not merge an item's 3D-turned pictures into one layer: measure first, since the turned pictures then repaint at full DPR inside it.
> **Snippet:**
> ```ts
> // tried: one layer for the whole piece (its root only)
> root: { position: 'absolute', willChange: 'transform' },
> clip: { position: 'absolute', overflow: 'hidden' },   // no layer
> // kept: every moving box its own layer; only still boxes have none
> root: { position: 'absolute', willChange: 'transform, opacity' },
> clip: { position: 'absolute', overflow: 'hidden', willChange: 'transform, opacity' },
> ```
> **Check:** trace the same drag with `--cpu 4 --dpr 3` both ways: with one layer per piece the drag's p95 doubles (here 94 → 185 ms, a step 432 ms).
> **Applies to:** react-native-web@>=0.21 <0.22, other: Chrome with GPU compositing
> **I assumed:** with Layerize the largest part of a 4× CPU frame (18.3 ms of it, with script 13.3, commit 6.3 and style 5.0), fewer layers would be cheaper.
> **What happened:** one layer per piece instead of five made the drag's p95 go from 94 to 185 ms and a step to 432 ms at 4× CPU and 3× DPR.
> **Why:** the turning photos, with their perspective transforms, repainted at 3× inside the piece's single layer every frame instead of being moved by the compositor.
> **The fix:** reverted; each moving box keeps its layer, and each moving tag and thread got one too (without, the 28 of them repainted the rail's layer and the drag's p95 at 1× rose from 21 to 37 ms).
> **Evidence:** the paired runs of the polish round (commit a22aa93).

### Mounting fewer pieces, at the right moment

With 40 pieces on the rail, a move drew 40 pieces' layers. Only the front, its neighbours and about eight in each bunch are mounted (front ±10 on a phone, ±12 on a tablet), and the window moves with the rail.

`components/turnrail/turnrail.tsx`, lines 967–1004:

```tsx
  // Only the pieces near the front are mounted: with 40 on the rail, a move drew 40 pieces' layers.
  const reach = tablet ? MOUNT_TABLET : MOUNT_PHONE;
  // The window moves with the rail when it settles, a render at rest; mid-move only when the front
  // comes within four of its edge (moving it at every third piece mounted pieces mid-flick: 172 ms).
  const [center, setCenter] = useState(index);
  const centerAt = useSharedValue(index);
  const windowed = n > 2 * reach + 1;
  useEffect(() => {
    // Only when the window is in use: otherwise the extra render after each settle is wasted.
    if (!windowed) return;
    centerAt.value = index;
    setCenter(index);
  }, [index, centerAt, windowed]);
  useAnimatedReaction(
    () => Math.round(p.value),
    (now) => {
      if (Math.abs(now - centerAt.value) > reach - 4) {
        centerAt.value = now;
        scheduleOnRN(setCenter, now);
      }
    },
    [reach],
  );
  // At first only the front and two each side: their pictures are asked for first and nothing else
  // competes with them; the rest fill in once the front's picture is in (loading). The first
  // render with every piece put the front's picture 0.5-0.75 s after the scripts on fast 4G.
  const [warm, setWarm] = useState(false);
  const warmUp = useCallback((i: number) => {
    if (i === settledRef.current) setWarm(true);
  }, []);
  useEffect(() => {
    if (warm) return undefined;
    const t = setTimeout(() => setWarm(true), 1500);
    return () => clearTimeout(t);
  }, [warm]);
  const lo = !warm ? Math.max(0, index - 2) : n > 2 * reach + 1 ? Math.max(0, center - reach) : 0;
  const hi = !warm ? Math.min(last, index + 2) : n > 2 * reach + 1 ? Math.min(last, center + reach) : last;
  const mount = useMemo(() => ({ lo, hi }), [lo, hi]);
```

> **Lesson: A mount window that moves mid-move mounts items mid-flick: move it when the motion settles, mid-move only near its edge** (My own mistake; learned by the AI (Claude Opus 5.5); react-native-reanimated@4.5.1)
> **Rule:** If a windowed list of animated items drops a frame mid-flick, move the mount window in a render at rest, and mid-move only when the front comes within a few items of the window's edge.
> **Snippet:**
> ```ts
> // before: the window follows the front every three items, mid-move too
> useAnimatedReaction(() => Math.round(p.value), (now) => {
>   if (Math.abs(now - centerAt.value) >= 3) { centerAt.value = now; scheduleOnRN(setCenter, now); }
> });
> // after: at rest it moves with the settled index; mid-move only near the edge
> useEffect(() => { centerAt.value = index; setCenter(index); }, [index]);
> useAnimatedReaction(() => Math.round(p.value), (now) => {
>   if (Math.abs(now - centerAt.value) > reach - 4) { centerAt.value = now; scheduleOnRN(setCenter, now); }
> });
> ```
> **Check:** flick across a rail of 40: no frame over 50 ms where a window move mounts pieces (here a 172 ms frame was gone).
> **Applies to:** react-native-reanimated@>=4.5 <5, react-native-worklets@>=0.10 <0.11
> **I assumed:** keeping the window centred on the front all the time was the cheapest way to have the right pieces mounted.
> **What happened:** with 40 pieces, the window moved whenever the front passed three pieces from its centre, and each move mounted new pieces (fifteen animated styles and three pictures each) in the middle of a flick: a 172 ms frame.
> **The fix:** the window moves when the rail settles, a render at rest; mid-move it moves only if the front comes within four pieces of its edge.
> **Evidence:** 40 pieces, front ±10 mounted: drag p95 20.9 ms, flick 18.1 ms, step 18.5 ms, the same as with 14 (commits 8861494 and 70a7d58).

> **Lesson: Mount only the item in front and its neighbours first: the first picture arrives sooner when nothing competes with it** (My own mistake; learned by the AI (Claude Opus 5.5); react-native-reanimated@4.5.1)
> **Rule:** If the first picture of a carousel shows late on a slow network, mount only the front and two each side at first, and the rest once the front's picture has loaded (or after a timeout).
> **Snippet:**
> ```ts
> // before: every piece mounted in the first render, every picture requested at once
> const lo = 0, hi = last;
> // after: front ±2 until the front's picture is in, or 1.5 s
> const [warm, setWarm] = useState(false);
> const lo = warm ? Math.max(0, center - reach) : Math.max(0, index - 2);
> const hi = warm ? Math.min(last, center + reach) : Math.min(last, index + 2);
> // Piece's onLoad for the front calls setWarm(true); a timer does after 1.5 s
> ```
> **Check:** load the page cold on fast 4G with gzip: the first piece shows at about 1.9–2.4 s (median about 2.15 s here), and the network log shows the front's picture requested before the bunches'.
> **Applies to:** react-native-web@>=0.21 <0.22, other: any image carousel on the web
> **I assumed:** the browser would fetch the front's picture first, since it was the first in the page.
> **What happened:** the first render with every piece put the front's picture 0.5 to 0.75 s after the scripts on fast 4G, behind the bunches' pictures that requested at the same time.
> **The fix:** at first only the front and two on each side are mounted; the rest fill in once the front's picture has loaded, or after 1.5 s.
> **Evidence:** first piece shown 1.9–2.4 s, median about 2.15 s, cold cache, fast 4G, against the 2 s aimed for: missed by about 0.15 s, the rest being script start-up (commit 70a7d58).

### Measuring it honestly

> **Lesson: Honest web frame budgets: one sampler per run, only frames that move, long-animation-frame entries, gzip served** (The tools and the machine; learned by the AI (Claude Opus 5.5); react-native-reanimated@4.5.1)
> **Rule:** If frame numbers of a web animation look too good or too bad, run one sampler per run, count only the frames where the animated value changed, and serve the build compressed for load numbers.
> **Snippet:**
> ```js
> // before: every requestAnimationFrame interval counted, a sampler per move left running
> requestAnimationFrame(function tick(t) { gaps.push(t - last); last = t; requestAnimationFrame(tick); });
> // after: one sampler; a frame counts only when the rail (or sheet, or copy) moved since the last
> let last = 0, lastP = rail.p();
> const tick = (t) => {
>   const p = rail.p();
>   if (p !== lastP && last) gaps.push(t - last);
>   last = t; lastP = p;
>   if (running) requestAnimationFrame(tick);
> };
> // plus PerformanceObserver({ type: 'long-animation-frame' }) overlapping those frames
> ```
> **Check:** run the same move twice in a row: the medians agree, and none is near 0 ms (a second sampler halves every interval).
> **Applies to:** other: Chrome's long-animation-frame entries, other: any requestAnimationFrame sampler
> **I assumed:** a requestAnimationFrame sampler per move, and the review server as it was, would give the numbers.
> **What happened:** a second sampler loop left running from the move before halved every interval, giving medians near 0 ms; idle frames between moves diluted the percentiles; and the review server sent the JavaScript uncompressed (2 MB, where a site sends 472 KB), so the first view on fast 4G read 3.1–3.5 s.
> **The fix:** one sampler per run; only frames where the rail's position, the sheet's rise or the bag copy's time changed; long frames from `long-animation-frame` entries that overlap them; React renders counted through a test hook; idle checked as animation frames and DOM changes over 4 s after every timer; and a small Node server that gzips, keyed by the file's time.
> **Evidence:** the first view with gzip went from 3.1–3.5 s to 1.9–2.8 s on fast 4G; 0 animation frames and 0 DOM changes at rest, in every run (commit 70a7d58).

> **Lesson: On a busy machine compare two builds in alternating runs and medians: single runs of one move swung 82 to 484 ms** (The tools and the machine; learned by the AI (Claude Opus 5.5))
> **Rule:** If before-and-after frame numbers jump between runs on a shared machine, run the two builds alternately at each CPU rate and compare medians; at 4× CPU even medians may be too noisy to judge.
> **Snippet:**
> ```js
> // before: all runs of build A, then all of build B
> for (const b of [A, B]) for (let k = 0; k < 3; k++) results[b].push(await run(b));
> // after: A, B, A, B... so a slow spell on the machine falls on both
> for (let k = 0; k < 6; k++) for (const b of k % 2 ? [B, A] : [A, B]) results[b].push(await run(b));
> const median = (xs) => xs.slice().sort((a, b) => a - b)[xs.length >> 1];
> ```
> **Check:** run the same build as both A and B: at 1× CPU the medians agree run to run; if they do not, the machine is too busy for that rate.
> **Applies to:** other: Chrome DevTools CPU throttling, other: Puppeteer or Playwright performance runs
> **I assumed:** one run of each build at 4× CPU slowdown would show which was faster.
> **What happened:** while another test suite held the machine at 42–63% load, single 4× runs of the same arrow step swung from 82 to 484 ms.
> **The fix:** a paired runner alternates before and after at each CPU rate and reports medians; at 1× they agreed run to run, and the 4× rows are reported as ranges, not a verdict.
> **Evidence:** the before-and-after table of the polish round (commit a22aa93).

### Where the budgets stand

At 1× CPU on the busy machine, after all of the above: a slow drag's p95 is about 19.5 ms, a flick 18–19.5 ms, an arrow step 17.5–18.1 ms, a fling to the end 18.2–18.5 ms, and the flights 17 ms. The budget aimed for was 17.5 ms, so the drag still misses it by about 2 ms. At rest, 0 animation frames and 0 DOM changes.

At 4× CPU slowdown the budget of 16.7 ms is **not met**: the drag's p95 is 84–114 ms, a flick 179 ms, a step 252 ms. What is left in a frame is mostly the browser's own `Layerize`, which grows with the number of layers, and the turning photos need theirs. A low-end phone's web view would run this rail at well under 60 fps. On a phone app, where Reanimated runs the same worklets on the UI thread, none of this was measured.

The next step, not taken, would be to hand the rail's own motion to the compositor on the web once the finger lifts (keyframes for the settle), so a throttled main thread could not drop its frames.

## What was measured, and what was not

- **Measured** on the production web export, frame by frame: hooks within 0.001 pt of the rail in every frame of every film; the thinnest piece 0.338 to 0.401 of its front; 0 frames with two cards showing; the swing at most 2.6°; still 458 to 966 ms after the last move; the frame budgets above.
- **Not measured:** anything on a real phone, iOS or Android; the 4× budget on a quiet machine; a finger scrolling the page past the rail (only a real touch swipe sideways and vertically on the rail itself).

## Make it yours

The rail needs, per garment, a cut-out front photo and, for the full turn, a side photo of the same piece, each with its hook point `[x, y]` (0..1, where the hanger's neck meets its hook) and the garment's `length` in cm. A piece without a side photo turns front-only and rests at 70°. `dir="rtl"` runs the rail right to left without mirroring a single photo. The pendulum's constants are in `motion.ts` (`SWING`), the snap spring's in `SNAP`, and the bunch's depth and the gap in `railLayout`. The tags that hang from these hooks, and the flight off the hook into the detail and into the bag, are the subject of the second article.

---

On UpFork: https://upfork.dev/articles/a-garment-rail-in-react-native-where-each-piece-turns-on-its-hook-perspectiv
