# Olive Oil Bottle Hero Turning on a Seamless Backdrop with the Headline Orbiting Round the Glass

> Product hero where a dark glass bottle turns on a yellow backdrop and the headline circles it as a ring of type, passing behind. Free React and HTML code.

A shadcn/ui block from the [@weinc](https://we.inc/components?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/weinc/olive-oil-bottle-hero-turning-on-a-seamless-backdrop-with-the-headline-orbiting-round-the-glass).

- Page, with a live preview: https://shadcn.exchange/r/weinc/olive-oil-bottle-hero-turning-on-a-seamless-backdrop-with-the-headline-orbiting-round-the-glass
- Screenshot: https://shadcn.exchange/shot/weinc/olive-oil-bottle-hero-turning-on-a-seamless-backdrop-with-the-headline-orbiting-round-the-glass/light.png
- Registry item: https://we.inc/r/olive-oil-bottle-hero-turning-on-a-seamless-backdrop-with-the-headline-orbiting-round-the-glass.json
- Listed: 2026-10-28, source last changed 2026-10-28

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/weinc/olive-oil-bottle-hero-turning-on-a-seamless-backdrop-with-the-headline-orbiting-round-the-glass.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `framer-motion`

## Files

### `components/we-inc/olive-oil-bottle-hero-turning-on-a-seamless-backdrop-with-the-headline-orbiting-round-the-glass.tsx`

```tsx
import { useEffect, useRef } from "react";
import type { KeyboardEvent as ReactKeyboardEvent, PointerEvent as ReactPointerEvent } from "react";
import { useReducedMotion } from "framer-motion";

// The words on the page and on the bottle. The headline is set twice round the ring so half of it always faces you.
const hero = {
  maker: "Halden Grove",
  note: "Early harvest, 500 ml",
  headline: "Worth the wait",
  line: "One grove, picked green in the first week of November and pressed the same night.",
  button: "Reserve a bottle",
  label: { top: "EARLY HARVEST", name: "Halden", sub: "GROVE", foot: "EXTRA VIRGIN OLIVE OIL", lot: "Lot 14  ·  500 ml" },
  back: ["Pressed within six hours", "of picking. Unfiltered.", "Keep cool and out of", "the light. Shake gently."],
};

/** How far the camera looks down on the bottle. It bends the label edges, the base and the ring of type together. */
const TILT = 0.15;
/** Seconds for one unaided turn of the bottle, and of the ring of type going the other way. */
const BOTTLE_TURN = 26;
const RING_TURN = 46;
/** Bottle height in units of its own body radius. */
const TALL = 7.2;
const LABEL_TOP = 4.25;
const LABEL_TALL = 2.3;
const TEX_W = 1536;
const TEX_H = Math.round((TEX_W * LABEL_TALL) / (Math.PI * 2));

const step = (a: number, b: number, v: number) => {
  const t = Math.min(1, Math.max(0, (v - a) / (b - a)));
  return t * t * (3 - 2 * t);
};

/** Radius of the bottle at a height measured down from the top of the cap, both in body radii. */
function profile(y: number) {
  if (y < 0 || y > TALL) return 0;
  if (y < 0.82) return 0.375 - 0.05 * Math.pow(Math.max(0, 1 - y / 0.07), 2);
  if (y < 0.98) return 0.405;
  if (y < 1.9) return 0.315 + 0.03 * ((y - 0.98) / 0.92);
  if (y < 3.0) {
    const t = (y - 1.9) / 1.1;
    return 0.345 + 0.655 * Math.sqrt(1 - (1 - t) * (1 - t));
  }
  if (y < 7.0) return 1;
  const t = (y - 7.0) / 0.2;
  return 0.9 + 0.1 * Math.sqrt(Math.max(0, 1 - t * t));
}

/** What the glass mirrors: a tall window on the left with a glazing bar, a dim card on the right, a pale ceiling. */
function studio(rx: number, ry: number, rz: number) {
  const az = (Math.atan2(rx, rz) * 180) / Math.PI;
  const el = (Math.asin(Math.max(-1, Math.min(1, -ry))) * 180) / Math.PI;
  let light = 0.5 + 0.35 * step(-40, 60, el);
  const tall = step(-34, -22, el) * (1 - step(62, 80, el));
  const win = step(-74, -69, az) * (1 - step(-33, -29, az)) * tall;
  const bar = 1 - 0.85 * step(-56, -54.5, az) * (1 - step(-51.5, -50, az));
  light += 15 * win * bar;
  light += 2.4 * step(36, 44, az) * (1 - step(70, 82, az)) * tall;
  light += 5 * step(96, 104, az) * (1 - step(118, 126, az)) * tall;
  light += 1.6 * step(58, 78, el);
  return light;
}

/** Paints the bottle once, without its paper: black capsule, dark green glass, reflections and its shadow on the floor. */
function paintBottle(cv: HTMLCanvasElement, R: number, cx: number, top: number) {
  const ctx = cv.getContext("2d");
  if (!ctx) return;
  const W = cv.width;
  const H = cv.height;
  ctx.clearRect(0, 0, W, H);

  // Shadow first: a tight dark pool under the base and a long soft one thrown to the right, away from the window.
  const floor = top + (TALL + TILT * 0.45) * R;
  const pool = (dx: number, sx: number, sy: number, a: number) => {
    ctx.save();
    ctx.translate(cx + dx * R, floor);
    ctx.scale(sx, sy);
    const g = ctx.createRadialGradient(0, 0, 0, 0, 0, R);
    g.addColorStop(0, `rgba(58,44,8,${a})`);
    g.addColorStop(0.55, `rgba(58,44,8,${a * 0.45})`);
    g.addColorStop(1, "rgba(58,44,8,0)");
    ctx.fillStyle = g;
    ctx.beginPath();
    ctx.arc(0, 0, R, 0, Math.PI * 2);
    ctx.fill();
    ctx.restore();
  };
  pool(1.5, 2.9, 0.2, 0.3);
  pool(0.5, 1.7, 0.2, 0.42);
  pool(0.05, 1.12, 0.17, 0.85);
  // Light that has come through the oil lands as a green smear inside the long shadow.
  ctx.save();
  ctx.translate(cx + 1.25 * R, floor + 0.02 * R);
  ctx.scale(1.3, 0.1);
  const through = ctx.createRadialGradient(0, 0, 0, 0, 0, R);
  through.addColorStop(0, "rgba(150,160,30,0.5)");
  through.addColorStop(1, "rgba(150,160,30,0)");
  ctx.fillStyle = through;
  ctx.beginPath();
  ctx.arc(0, 0, R, 0, Math.PI * 2);
  ctx.fill();
  ctx.restore();

  const x0 = Math.max(0, Math.floor(cx - R - 2));
  const x1 = Math.min(W, Math.ceil(cx + R + 2));
  const y0 = Math.max(0, Math.floor(top - TILT * R - 2));
  const y1 = Math.min(H, Math.ceil(top + (TALL + TILT) * R + 2));
  const img = ctx.getImageData(x0, y0, x1 - x0, y1 - y0);
  const d = img.data;
  const capR = profile(0.1);

  for (let py = y0; py < y1; py++) {
    for (let px = x0; px < x1; px++) {
      const xs = (px + 0.5 - cx) / R;
      const ys = (py + 0.5 - top) / R;
      // Each ring of the bottle is seen as a shallow ellipse, so find the height whose front edge passes through this pixel.
      let y = ys;
      for (let k = 0; k < 4; k++) {
        const r = profile(y);
        y = ys - TILT * Math.sqrt(Math.max(0, r * r - xs * xs));
      }
      const r = profile(y);
      let cr = 0;
      let cg = 0;
      let cb = 0;
      let cover = 0;
      if (y >= 0 && y <= TALL && Math.abs(xs) < r) {
        cover = Math.min(1, (r - Math.abs(xs)) * R);
        const slope = (profile(y + 0.012) - profile(y - 0.012)) / 0.024;
        const side = xs / r;
        const facing = Math.sqrt(Math.max(0, 1 - side * side));
        const len = Math.sqrt(1 + slope * slope);
        const nx = side / len;
        const ny = -slope / len;
        const nz = facing / len;
        // Mirror the view direction in the surface to see which part of the studio this point shows.
        const env = studio(2 * nz * nx, 2 * nz * ny, 2 * nz * nz - 1);
        const fres = 0.045 + 0.955 * Math.pow(1 - nz, 5);
        if (y < 0.98) {
          // Matte black capsule with fine vertical ribs and one soft sheen.
          const rib = 0.86 + 0.14 * Math.sin(Math.asin(side) * 46);
          const lit = 0.3 + 0.9 * Math.max(0, nx * -0.5 + ny * -0.42 + nz * 0.76);
          const sheen = Math.pow(Math.max(0, nx * -0.36 + ny * -0.3 + nz * 0.88), 26) * 70;
          const v = 24 * lit * rib + sheen + env * fres * 3;
          cr = v;
          cg = v;
          cb = v * 1.02;
          if (y > 0.82 && y < 0.86) {
            cr *= 0.5;
            cg *= 0.5;
            cb *= 0.5;
          }
        } else {
          // Dark green glass: nearly black face on, the room mirrored on top, and oil glowing where light gets through.
          const glow = step(0.1, 0.98, side) * 0.55 + step(6.55, 7.1, y) * (0.35 + 0.65 * step(-0.6, 0.9, side)) * 0.9;
          const thin = y < 3 ? 0.55 : 1;
          cr = 9 + 96 * glow * thin + env * fres * 236;
          cg = 17 + 112 * glow * thin + env * fres * 232;
          cb = 12 + 12 * glow * thin + env * fres * 214;
          // The thick base ring shows as a darker line just above the foot.
          const foot = step(6.93, 6.97, y) * (1 - step(7.0, 7.04, y));
          cr *= 1 - 0.45 * foot;
          cg *= 1 - 0.4 * foot;
          cb *= 1 - 0.45 * foot;
        }
      } else if (ys < TILT * capR) {
        // The flat top of the capsule, seen as a thin ellipse.
        const ex = xs / capR;
        const ey = ys / (TILT * capR);
        const q = ex * ex + ey * ey;
        if (q < 1) {
          cover = Math.min(1, (1 - Math.sqrt(q)) * capR * R);
          const v = 58 - 16 * ex;
          cr = v;
          cg = v;
          cb = v + 1;
        }
      }
      if (cover > 0) {
        const i = ((py - y0) * (x1 - x0) + (px - x0)) * 4;
        const under = d[i + 3] / 255;
        const keep = (1 - cover) * under;
        const a = cover + keep;
        d[i] = (Math.min(255, cr) * cover + d[i] * keep) / a;
        d[i + 1] = (Math.min(255, cg) * cover + d[i + 1] * keep) / a;
        d[i + 2] = (Math.min(255, cb) * cover + d[i + 2] * keep) / a;
        d[i + 3] = a * 255;
      }
    }
  }
  ctx.putImageData(img, x0, y0);
}

/** The light and shade that lies over the paper wherever it happens to be: darker toward the edges, a faint window sheen. */
function paintPaperLight(cv: HTMLCanvasElement, R: number) {
  const ctx = cv.getContext("2d");
  if (!ctx) return;
  const img = ctx.createImageData(cv.width, cv.height);
  const d = img.data;
  for (let px = 0; px < cv.width; px++) {
    const side = Math.max(-0.999, Math.min(0.999, (px + 0.5 - R) / R));
    const nz = Math.sqrt(1 - side * side);
    const lit = (0.56 + 0.62 * Math.max(0, side * -0.42 + nz * 0.84)) * (0.6 + 0.4 * Math.pow(nz, 0.7));
    const az = (Math.asin(side) * 360) / Math.PI;
    const sheen = 0.045 * step(-84, -58, az) * (1 - step(-44, -18, az));
    for (let py = 0; py < cv.height; py++) {
      const i = (py * cv.width + px) * 4;
      if (lit < 1) {
        d[i] = 34;
        d[i + 1] = 30;
        d[i + 2] = 8;
        d[i + 3] = (1 - lit) * 255 * (1 - sheen * 3);
      } else {
        d[i] = d[i + 1] = d[i + 2] = 255;
        d[i + 3] = Math.min(1, (lit - 1) * 0.8 + sheen) * 255;
      }
    }
  }
  ctx.putImageData(img, 0, 0);
}

/** Prints the front and back labels flat, as one strip that wraps the whole way round the bottle (twice, so it can be read past the seam). */
function printLabels(): HTMLCanvasElement {
  const cv = document.createElement("canvas");
  cv.width = TEX_W * 2;
  cv.height = TEX_H;
  const c = cv.getContext("2d");
  if (!c) return cv;
  const ink = "#1f3a29";
  const serif = 'Georgia, "Times New Roman", serif';
  const paper = (x: number, y: number, w: number, h: number) => {
    c.fillStyle = "#f2ead6";
    c.fillRect(x, y, w, h);
    // Laid paper: faint darker fibres.
    for (let k = 0; k < 900; k++) {
      c.fillStyle = `rgba(120,100,60,${0.03 + Math.random() * 0.05})`;
      const long = 1 + Math.random() * 14;
      c.fillRect(x + Math.random() * (w - long), y + Math.random() * (h - 1), long, 1);
    }
  };
  for (let copy = 0; copy < 2; copy++) {
    const o = copy * TEX_W;
    // Front label, centred on the start of the strip, so it is drawn in two halves across the seam.
    const fw = TEX_W * 0.4;
    for (const fx of [o - fw / 2, o + TEX_W - fw / 2]) {
      const mid = fx + fw / 2;
      paper(fx, 0, fw, TEX_H);
      c.fillStyle = "#c8452b";
      c.fillRect(fx, 0, fw, TEX_H * 0.045);
      c.strokeStyle = ink;
      c.lineWidth = 3;
      c.strokeRect(fx + 24, TEX_H * 0.045 + 22, fw - 48, TEX_H * 0.955 - 46);
      c.fillStyle = ink;
      c.textAlign = "center";
      c.textBaseline = "middle";
      const spaced = (text: string, y: number, size: number, gap: number, weight = "600") => {
        c.font = `${weight} ${size}px ${serif}`;
        const widths = [...text].map((ch) => c.measureText(ch).width + gap);
        let x = mid - widths.reduce((a, b) => a + b, 0) / 2;
        [...text].forEach((ch, i) => {
          c.fillText(ch, x + widths[i] / 2, y);
          x += widths[i];
        });
      };
      spaced(hero.label.top, TEX_H * 0.17, 23, 9);
      // An olive sprig: a bowed stem, leaves in pairs, two fruit.
      c.save();
      c.translate(mid, TEX_H * 0.33);
      c.strokeStyle = ink;
      c.lineWidth = 3;
      c.beginPath();
      c.moveTo(-120, 16);
      c.quadraticCurveTo(0, -22, 120, 10);
      c.stroke();
      for (let k = 0; k < 7; k++) {
        const t = k / 6;
        const lx = -112 + t * 224;
        const ly = 16 - 26 * Math.sin(t * Math.PI) * 0.9 + (t > 0.5 ? (t - 0.5) * -8 : 0);
        for (const up of [-1, 1]) {
          c.save();
          c.translate(lx, ly);
          c.rotate(up * (0.75 - t * 0.25) - 0.1);
          c.beginPath();
          c.ellipse(21, 0, 23, 6.5, 0, 0, Math.PI * 2);
          c.fillStyle = up < 0 ? ink : "#55713a";
          c.fill();
          c.restore();
        }
      }
      c.fillStyle = "#c8452b";
      for (const [ox, oy] of [[-34, 34], [46, 30]]) {
        c.beginPath();
        c.ellipse(ox, oy, 10, 13, 0.2, 0, Math.PI * 2);
        c.fill();
      }
      c.restore();
      c.fillStyle = ink;
      c.font = `italic 400 ${Math.round(TEX_H * 0.27)}px ${serif}`;
      c.fillText(hero.label.name, mid, TEX_H * 0.575);
      spaced(hero.label.sub, TEX_H * 0.735, 34, 22, "400");
      c.fillRect(mid - 26, TEX_H * 0.805, 52, 2);
      spaced(hero.label.foot, TEX_H * 0.865, 17, 5);
      spaced(hero.label.lot, TEX_H * 0.925, 16, 3, "400");
    }
    // Back label: smaller, lower, plain.
    const bw = TEX_W * 0.2;
    const bx = o + TEX_W * 0.5 - bw / 2;
    const by = TEX_H * 0.24;
    const bh = TEX_H * 0.6;
    paper(bx, by, bw, bh);
    c.fillStyle = ink;
    c.textAlign = "left";
    c.font = `italic 400 30px ${serif}`;
    c.fillText(hero.label.name, bx + 26, by + 44);
    c.fillRect(bx + 26, by + 70, bw - 52, 1.5);
    c.font = `400 17px ${serif}`;
    hero.back.forEach((text, i) => c.fillText(text, bx + 26, by + 104 + i * 27));
    c.fillRect(bx + 26, by + bh - 74, bw - 52, 1.5);
    c.font = `600 15px ${serif}`;
    c.fillText(hero.label.lot, bx + 26, by + bh - 42);
  }
  return cv;
}

export default function OliveOilBottleHeroTurningOnASeamlessBackdropWithTheHeadlineOrbitingRoundTheGlass() {
  const reduce = useReducedMotion();
  const stage = useRef<HTMLDivElement>(null);
  const canvas = useRef<HTMLCanvasElement>(null);
  const ring = useRef<HTMLDivElement>(null);
  /** Bottle angle in turns, its spin in turns per second, and the drag in progress if there is one. */
  const spin = useRef({ angle: 0, speed: 0, ringAngle: 0, dragX: null as number | null, dragAt: 0, radius: 70 });

  const once = [...hero.headline, " ", "•", " "];
  const letters = [...once, ...once];

  useEffect(() => {
    const el = stage.current;
    const cv = canvas.current;
    const band = ring.current;
    if (!el || !cv || !band) return;
    const ctx = cv.getContext("2d");
    if (!ctx) return;
    const glyphs = Array.from(band.children) as HTMLElement[];
    const texture = printLabels();
    const body = document.createElement("canvas");
    const paper = document.createElement("canvas");
    const light = document.createElement("canvas");
    const idle = reduce ? 0 : 1 / BOTTLE_TURN;
    const s = spin.current;
    // Start a little before the label faces front, so it is square on a couple of seconds in.
    s.angle = reduce ? -0.02 : -0.02 - idle * 2.6;
    s.speed = idle;
    s.ringAngle = reduce ? 0 : 2.6 / RING_TURN;

    let R = 70;
    let cx = 0;
    let top = 0;
    let ringR = 300;
    let ringY = 0;
    let offsets: number[] = [];
    let widths: number[] = [];
    let glyphH = 0;
    let visible = true;
    let raf = 0;
    let last = 0;
    let drawn = NaN;

    const layout = () => {
      const w = el.clientWidth;
      const h = el.clientHeight;
      const ratio = Math.min(window.devicePixelRatio || 1, 2);
      const r = Math.min(h * 0.122, w * 0.15, 82);
      R = Math.round(r * ratio);
      s.radius = r;
      cv.width = Math.round(w * ratio);
      cv.height = Math.round(h * ratio);
      cx = Math.round(cv.width / 2);
      top = Math.round(h * 0.035 * ratio + TILT * R);
      body.width = cv.width;
      body.height = cv.height;
      paintBottle(body, R, cx, top);
      paper.width = light.width = R * 2;
      paper.height = light.height = Math.ceil((LABEL_TALL + TILT) * R) + 2;
      paintPaperLight(light, R);

      // Set the type so that the whole string exactly closes the ring.
      ringR = Math.min(w * 0.4, 430);
      ringY = top / ratio + 2.95 * r;
      band.style.fontSize = "100px";
      glyphs.forEach((g) => (g.style.transform = "none"));
      const raw = glyphs.map((g) => g.getBoundingClientRect().width);
      const total = raw.reduce((a, b) => a + b, 0) || 1;
      const size = (100 * Math.PI * 2 * ringR) / total;
      band.style.fontSize = `${size}px`;
      widths = raw.map((v) => (v * size) / 100);
      glyphH = glyphs[0] ? glyphs[0].offsetHeight : size;
      let run = 0;
      offsets = widths.map((v) => {
        const mid = run + v / 2;
        run += v;
        return mid / ((total * size) / 100);
      });
      drawn = NaN;
    };

    const draw = () => {
      const turn = s.angle - Math.floor(s.angle);
      ctx.clearRect(0, 0, cv.width, cv.height);
      ctx.drawImage(body, 0, 0);
      // The paper: one thin slice of the flat print per column, squeezed toward the edges as the glass curves away.
      const pc = paper.getContext("2d");
      if (pc) {
        pc.globalCompositeOperation = "source-over";
        pc.clearRect(0, 0, paper.width, paper.height);
        const tall = LABEL_TALL * R;
        for (let px = 0; px < R * 2; px++) {
          const a = Math.max(-1, Math.min(1, (px - R) / R));
          const b = Math.max(-1, Math.min(1, (px + 1 - R) / R));
          const ua = Math.asin(a) / (Math.PI * 2);
          const ub = Math.asin(b) / (Math.PI * 2);
          const from = (((ua - turn) % 1) + 1) % 1;
          const facing = Math.sqrt(1 - ((a + b) / 2) * ((a + b) / 2));
          pc.drawImage(texture, from * TEX_W, 0, Math.max(0.5, (ub - ua) * TEX_W), TEX_H, px, Math.round(TILT * R * facing), 1, Math.round(tall));
        }
        pc.globalCompositeOperation = "source-atop";
        pc.drawImage(light, 0, 0);
        ctx.drawImage(paper, cx - R, top + LABEL_TOP * R);
      }

      // The headline: each letter rides a tilted circle, shrinks and mirrors as it goes round the back, and drops behind the glass.
      const cssX = el.clientWidth / 2;
      for (let i = 0; i < glyphs.length; i++) {
        const th = (offsets[i] - 0.22 + s.ringAngle) * Math.PI * 2;
        const z = Math.cos(th);
        const depth = 1 + 0.13 * z;
        const x = cssX + ringR * Math.sin(th) - widths[i] / 2;
        const y = ringY + TILT * ringR * z - glyphH / 2;
        const g = glyphs[i];
        g.style.transform = `matrix(${(depth * z).toFixed(4)},${(-depth * TILT * Math.sin(th)).toFixed(4)},0,${depth.toFixed(4)},${x.toFixed(2)},${y.toFixed(2)})`;
        // Edge on, a letter is a sliver, so it fades out for the moment it turns over.
        g.style.opacity = ((0.34 + 0.66 * step(-0.3, 0.2, z)) * step(0.04, 0.22, Math.abs(z))).toFixed(3);
        g.style.zIndex = z > 0 ? "30" : "10";
      }
    };

    const loop = (stamp: number) => {
      const dt = Math.min(last ? (stamp - last) / 1000 : 0.016, 0.05);
      last = stamp;
      if (s.dragX === null) {
        // Let go, the bottle coasts and settles back to its slow unaided turn.
        s.speed += (idle - s.speed) * Math.min(1, dt * 1.1);
        s.angle += s.speed * dt;
      }
      if (!reduce) s.ringAngle += (-1 / RING_TURN - (s.speed - idle) * 0.22) * dt;
      else s.ringAngle -= s.speed * 0.22 * dt;
      const key = s.angle * 7 + s.ringAngle;
      if (key !== drawn) {
        draw();
        drawn = key;
      }
      raf = visible && !document.hidden ? requestAnimationFrame(loop) : 0;
    };
    const wake = () => {
      if (!raf && visible && !document.hidden) {
        last = 0;
        raf = requestAnimationFrame(loop);
      }
    };

    layout();
    draw();
    const ro = new ResizeObserver(() => {
      layout();
      draw();
    });
    ro.observe(el);
    const io = new IntersectionObserver(([entry]) => {
      visible = entry.isIntersecting;
      wake();
    });
    io.observe(el);
    document.addEventListener("visibilitychange", wake);
    wake();
    return () => {
      cancelAnimationFrame(raf);
      ro.disconnect();
      io.disconnect();
      document.removeEventListener("visibilitychange", wake);
    };
  }, [reduce]);

  function grab(e: ReactPointerEvent<HTMLButtonElement>) {
    e.currentTarget.setPointerCapture(e.pointerId);
    spin.current.dragX = e.clientX;
    spin.current.dragAt = performance.now();
    spin.current.speed = 0;
  }
  function drag(e: ReactPointerEvent<HTMLButtonElement>) {
    const s = spin.current;
    if (s.dragX === null) return;
    const now = performance.now();
    // Moving the hand one bottle radius turns the surface by the same distance.
    const turned = (e.clientX - s.dragX) / (s.radius * Math.PI * 2);
    const dt = Math.max(0.008, (now - s.dragAt) / 1000);
    s.angle += turned;
    s.speed = s.speed * 0.6 + (turned / dt) * 0.4;
    s.dragX = e.clientX;
    s.dragAt = now;
  }
  function release() {
    const s = spin.current;
    s.dragX = null;
    s.speed = Math.max(-1.6, Math.min(1.6, s.speed));
  }
  function onKey(e: ReactKeyboardEvent<HTMLButtonElement>) {
    if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
    e.preventDefault();
    spin.current.speed = e.key === "ArrowRight" ? 0.5 : -0.5;
    if (reduce) spin.current.angle += e.key === "ArrowRight" ? 0.06 : -0.06;
  }

  return (
    <section className="relative isolate h-[700px] w-full overflow-hidden bg-[#e6d49c] sm:h-[820px]">
      <style>{`
        .oob-glyph { position: absolute; left: 0; top: 0; display: block; white-space: pre; line-height: 1; transform-origin: 50% 50%; will-change: transform, opacity; }
      `}</style>
      {/* The paper sweep: wall above, floor below, with no visible join. */}
      <div aria-hidden="true" className="absolute inset-0 -z-10" style={{ background: "radial-gradient(90% 55% at 42% 30%, #f3e6b8 0%, #e6d49c 55%, #d6c184 100%)" }} />
      <div aria-hidden="true" className="absolute inset-x-0 bottom-0 -z-10 h-[30%]" style={{ background: "linear-gradient(180deg, rgba(214,193,132,0) 0%, rgba(201,178,112,0.75) 100%)" }} />

      <header className="absolute inset-x-0 top-0 z-40 flex items-center justify-between px-5 pt-5 text-[13px] font-medium text-[#1f3a29] sm:px-10 sm:pt-8">
        <span className="font-serif text-[17px] italic">{hero.maker}</span>
        <span>{hero.note}</span>
      </header>

      <h1 className="sr-only">{hero.headline}</h1>

      <div ref={stage} className="absolute inset-x-0 top-[52px] h-[470px] sm:top-[40px] sm:h-[640px]">
        <div ref={ring} aria-hidden="true" className="pointer-events-none absolute inset-0 select-none font-serif text-[#bd3a1f]">
          {letters.map((ch, i) => (
            <span key={i} className="oob-glyph">
              {ch}
            </span>
          ))}
        </div>
        <canvas ref={canvas} aria-hidden="true" className="pointer-events-none absolute inset-0 z-20 h-full w-full" />
        <button
          type="button"
          aria-label="Turn the bottle. Drag it sideways, or use the left and right arrow keys."
          onPointerDown={grab}
          onPointerMove={drag}
          onPointerUp={release}
          onPointerCancel={release}
          onKeyDown={onKey}
          className="absolute left-1/2 top-[3%] z-40 h-[94%] w-[150px] -translate-x-1/2 cursor-grab touch-pan-y rounded-[40px] active:cursor-grabbing focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1f3a29] focus-visible:ring-offset-4 focus-visible:ring-offset-[#e6d49c] sm:w-[190px]"
        />
      </div>

      <div className="absolute inset-x-0 bottom-0 z-40 flex flex-col items-center gap-5 px-6 pb-7 text-center sm:pb-9">
        <p className="max-w-[21rem] text-[14px] font-medium leading-snug text-[#1f3a29] sm:max-w-md sm:text-[15px]">{hero.line}</p>
        <a
          href="#"
          className="inline-flex h-12 items-center rounded-full bg-[#1f3a29] px-7 text-[15px] font-semibold text-[#f6efd6] shadow-[0_14px_26px_-14px_rgba(31,58,41,0.9)] transition-transform duration-200 hover:-translate-y-0.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1f3a29] focus-visible:ring-offset-2 focus-visible:ring-offset-[#e6d49c] motion-reduce:transition-none"
        >
          {hero.button}
        </a>
      </div>
    </section>
  );
}
```
