arc reel.

An endless strip of cards riding a parabola across the frame, each one banking with the curve beneath it.

react / motion / carousel / raf

Loading component...

live component, try it

what to expect.

Watch a card cross the wrap point: nothing jumps
Cards bank with the slope of the curve
Hover, or press and hold, to ease the strip to a stop
Holds still under reduced motion

how it works.

The strip rides a parabola recomputed every frame. An earlier version jumped at every wrap because each card's pose came from its index, so the pattern travelled with the cards instead of the cards travelling through it. Keying the pose on screen position makes the arc a fixed shape in the viewport, and the loop seamless by construction. The bank angle is the real tangent, atan(dPerp/dAlong), not a decorative sine, so a card turns exactly as much as the curve turns under it. The loop also stops whenever the strip is scrolled out of view or the tab is hidden.

the component.

export function bulge(p: number): number {
  const t = p - 0.5;
  return 1 - 4 * t * t;
}

export function bulgeSlope(p: number): number {
  return -8 * (p - 0.5);
}

function clamp01(x: number): number {
  return x < 0 ? 0 : x > 1 ? 1 : x;
}

export interface ArcConfig {
  amp: number;
  span: number;
  tilt?: number;
  maxRot?: number;
  lift?: number;
}

export interface ArcPose {
  perp: number;
  rot: number;
  scale: number;
}

const DEFAULTS = { tilt: 0.45, maxRot: 9, lift: 0.06 } as const;

export function arcPose(p: number, cfg: ArcConfig): ArcPose {
  const { amp, span } = cfg;
  const tilt = cfg.tilt ?? DEFAULTS.tilt;
  const maxRot = cfg.maxRot ?? DEFAULTS.maxRot;
  const lift = cfg.lift ?? DEFAULTS.lift;

  const t = clamp01(p);
  const b = bulge(t);

  // Bank by the curve's real tangent, not a decorative sine.
  const slope = span > 0 ? (amp * bulgeSlope(t)) / span : 0;
  const rot = tilt * Math.atan(slope) * (180 / Math.PI);

  return {
    perp: amp * b,
    rot: Math.max(-maxRot, Math.min(maxRot, rot)),
    scale: 1 + lift * b,
  };
}

export function perpFootprint(cfg: ArcConfig, perp: number, along: number): number {
  const theta = Math.abs(arcPose(0, cfg).rot) * (Math.PI / 180);
  const scale = 1 + (cfg.lift ?? DEFAULTS.lift);
  return scale * (perp * Math.cos(theta) + along * Math.sin(theta));
}

export function fitAmp(
  want: number,
  slack: number,
  cfg: Omit<ArcConfig, "amp">,
  perp: number,
  along: number,
): number {
  const fits = (amp: number) => amp + perpFootprint({ ...cfg, amp }, perp, along) - perp <= slack;

  let lo = 0;
  // Bisect: a taller arc banks harder and needs more room, so the two constrain each other.
  let hi = Math.max(0, Math.min(want, slack));
  if (fits(hi)) return hi;

  for (let i = 0; i < 16; i++) {
    const mid = (lo + hi) / 2;
    if (fits(mid)) lo = mid;
    else hi = mid;
  }
  return lo;
}