# 3D Hero Flip Slider

> A full-hero 3D flip slider with choreographed title masks, odometer counters, and product panels — premium furniture and product landings.

**Definition:** 3D Hero Flip Slider is a sliders UI animation from collections.html. It is built with HTML, CSS, JavaScript, and GSAP, ships with a live preview, and is free to copy into personal and commercial projects.

## Facts
- Category: Sliders
- Stack: HTML, CSS, JavaScript, and GSAP
- Added: 2026-08-05
- Canonical page: https://html.bosconvertpro.com/collection/3d-hero-flip-slider
- Full-page preview: https://html.bosconvertpro.com/collection/3d-hero-flip-slider/full
- Live demo: https://html.bosconvertpro.com/demos/3d-hero-flip-slider/index.html
- Markdown mirror: https://html.bosconvertpro.com/collection/3d-hero-flip-slider/index.md
- License: https://html.bosconvertpro.com/terms

## How to use
1. Open https://html.bosconvertpro.com/collection/3d-hero-flip-slider/full to preview the animation in full screen.
2. Copy index.html, styles.css, and script.js from the code tabs on https://html.bosconvertpro.com/collection/3d-hero-flip-slider.
3. Paste the files into your project and adjust timing, easing, and content as needed.

## FAQ
### What is 3D Hero Flip Slider?

3D Hero Flip Slider is a sliders UI animation from collections.html. A full-hero 3D flip slider with choreographed title masks, odometer counters, and product panels — premium furniture and product landings.

### What is 3D Hero Flip Slider built with?

3D Hero Flip Slider is built with HTML, CSS, JavaScript, and GSAP. The source for every file is shown on the animation page and can be copied straight into your project.

### How do I use 3D Hero Flip Slider in my project?

Open the animation page (https://html.bosconvertpro.com/collection/3d-hero-flip-slider), preview it live, then copy index.html, styles.css, and script.js from the code tabs. Paste the files into your project and adjust timing, easing, and content as needed. A standalone demo runs at https://html.bosconvertpro.com/demos/3d-hero-flip-slider/index.html.

### Is 3D Hero Flip Slider free to use?

Yes. The 3D Hero Flip Slider demo code may be used in personal and commercial projects. You may not resell or redistribute the collection itself as a competing template pack. Full terms: https://html.bosconvertpro.com/terms.

## Source — index.html
````html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>FORMA — Furniture Hero</title>
    <link rel="preconnect" href="https://fonts.googleapis.com" />
    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
    <link rel="preconnect" href="https://images.unsplash.com" />
    <link
      href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,500;0,600;1,400;1,500&family=DM+Sans:opsz,wght@9..40,300;9..40,400;9..40,500;9..40,600&display=swap"
      rel="stylesheet"
    />
    <link rel="stylesheet" href="styles.css" />
  </head>
  <body>
    <div class="page">
      <section class="hero" id="hero" aria-label="Featured furniture">
        <div class="stage" id="stage">
          <div class="stage__viewport" id="viewport"></div>
        </div>
        <div class="ui">
          <!-- Editorial content panel -->
          <article class="panel" id="panel">
            <div class="panel__label" data-anim>
              <span class="panel__label-dot" aria-hidden="true"></span>
              <span class="panel__label-text" id="cat">Living Collection</span>
            </div>
            <h1 class="panel__title">
              <span class="panel__mask">
                <span class="panel__title-line" id="t1" data-title
                  >Linen Lounge</span
                >
              </span>
              <span class="panel__mask">
                <span
                  class="panel__title-line panel__title-line--soft"
                  id="t2"
                  data-title
                  >Sofa</span
                >
              </span>
            </h1>
            <p class="panel__desc" id="desc" data-anim>
              Soft linen, solid oak, and quiet proportions for rooms that feel
              lived-in and elevated.
            </p>
            <ul class="panel__tags" id="tags" data-anim>
              <li>Oak</li>
              <li>Linen</li>
              <li>Modular</li>
            </ul>
            <div class="panel__foot" data-anim>
              <div class="panel__price">
                <span class="panel__price-label">Starting at</span>
                <span class="panel__price-value" id="price">$2,480</span>
              </div>
              <div class="panel__actions">
                <button class="btn btn--fill" type="button">
                  <span>Explore piece</span>
                  <span class="btn__icon" aria-hidden="true">
                    <svg width="14" height="14" viewBox="0 0 14 14" fill="none">
                      <path
                        d="M2 7h10M8 3l4 4-4 4"
                        stroke="currentColor"
                        stroke-width="1.4"
                        stroke-linecap="round"
                        stroke-linejoin="round"
                      />
                    </svg>
                  </span>
                </button>
                <button class="btn btn--ghost" type="button">Details</button>
              </div>
            </div>
          </article>
          <!-- Compact odometer controls -->
          <div class="controls" aria-label="Slider controls">
            <div class="odometer" aria-live="polite" aria-atomic="true">
              <div class="odometer__window">
                <div class="odometer__wheel" id="odoWheel"><span>01</span></div>
              </div>
              <span class="odometer__sep">—</span>
              <span class="odometer__total" id="odoTotal">05</span>
            </div>
            <div class="controls__nav">
              <button
                class="ctrl"
                id="prev"
                type="button"
                aria-label="Previous"
              >
                <svg
                  width="16"
                  height="16"
                  viewBox="0 0 16 16"
                  fill="none"
                  aria-hidden="true"
                >
                  <path
                    d="M10 3.5 5.5 8 10 12.5"
                    stroke="currentColor"
                    stroke-width="1.4"
                    stroke-linecap="round"
                    stroke-linejoin="round"
                  />
                </svg>
              </button>
              <button class="ctrl" id="next" type="button" aria-label="Next">
                <svg
                  width="16"
                  height="16"
                  viewBox="0 0 16 16"
                  fill="none"
                  aria-hidden="true"
                >
                  <path
                    d="M6 3.5 10.5 8 6 12.5"
                    stroke="currentColor"
                    stroke-width="1.4"
                    stroke-linecap="round"
                    stroke-linejoin="round"
                  />
                </svg>
              </button>
            </div>
          </div>
        </div>
      </section>
    </div>
    <script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js"></script>
    <script src="./script.js"></script>
  </body>
</html>
````

## Source — styles.css
````css
/* =========================================================
   FORMA — Premium editorial furniture hero
   Light · type-led · smooth GSAP motion
   ========================================================= */

:root {
  --bg: #f4f0e9;
  --paper: #fbf8f3;
  --ink: #14120f;
  --ink-soft: #4f4a42;
  --muted: #8a8378;
  --line: rgba(20, 18, 15, 0.1);
  --line-strong: rgba(20, 18, 15, 0.18);
  --glass: rgba(251, 248, 243, 0.78);
  --white: #fffcf7;
  --accent: #8b6a45;

  --serif: "Cormorant Garamond", "Times New Roman", Georgia, serif;
  --sans: "DM Sans", system-ui, sans-serif;

  --ease-out: cubic-bezier(0.16, 1, 0.3, 1);
  --ease-in-out: cubic-bezier(0.65, 0.05, 0.36, 1);
  --shadow-soft: 0 20px 60px rgba(20, 18, 15, 0.08);
  --shadow-btn: 0 14px 36px rgba(20, 18, 15, 0.16);
}

*,
*::before,
*::after {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

html,
body {
  height: 100%;
}

body {
  overflow: hidden;
  background: var(--bg);
  color: var(--ink);
  font-family: var(--sans);
  font-optical-sizing: auto;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-rendering: optimizeLegibility;
}

button {
  border: 0;
  background: none;
  color: inherit;
  font: inherit;
  cursor: pointer;
}

img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

ul {
  list-style: none;
}

.page {
  width: 100%;
  height: 100%;
  min-height: 100vh;
  min-height: 100dvh;
  background: var(--bg);
}

/* ---------- Hero shell ---------- */
.hero {
  position: relative;
  width: 100%;
  height: 100vh;
  height: 100dvh;
  overflow: hidden;
  background: var(--bg);
  touch-action: none;
  cursor: grab;
}

.hero.is-drag {
  cursor: grabbing;
}

/* ---------- Full-hero 3D stage ---------- */
.stage {
  position: absolute;
  inset: 0;
  z-index: 1;
  perspective: 1800px;
  perspective-origin: 50% 48%;
  background: var(--bg);
}

.stage__viewport {
  position: relative;
  width: 100%;
  height: 100%;
  transform-style: preserve-3d;
}

.slide {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  transform-style: preserve-3d;
  backface-visibility: hidden;
  -webkit-backface-visibility: hidden;
  will-change: transform, opacity;
}

.slide__media {
  position: absolute;
  inset: 0;
  overflow: hidden;
  background: #e7e1d6;
}

.slide__media img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  transform: scale(1.08);
  will-change: transform;
  filter: saturate(0.94) contrast(1.03) brightness(1.02);
}

/* Soft light wash — editorial, not muddy */
.slide__media::after {
  content: "";
  position: absolute;
  inset: 0;
  pointer-events: none;
  background:
    linear-gradient(
      105deg,
      rgba(244, 240, 233, 0.94) 0%,
      rgba(244, 240, 233, 0.78) 18%,
      rgba(244, 240, 233, 0.32) 38%,
      rgba(244, 240, 233, 0.05) 55%,
      transparent 68%
    ),
    linear-gradient(
      0deg,
      rgba(244, 240, 233, 0.55) 0%,
      transparent 28%
    ),
    linear-gradient(
      180deg,
      rgba(244, 240, 233, 0.25) 0%,
      transparent 18%
    );
}

/* ---------- UI layer ---------- */
.ui {
  position: absolute;
  inset: 0;
  z-index: 10;
  pointer-events: none;
}

/* Editorial content panel */
.panel {
  position: absolute;
  left: clamp(1.25rem, 5vw, 4.5rem);
  top: 50%;
  width: min(34rem, calc(100% - 2.5rem));
  transform: translateY(-50%);
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 0;
  pointer-events: auto;
}

.panel__label {
  display: inline-flex;
  align-items: center;
  gap: 0.65rem;
  margin-bottom: 1.35rem;
  font-size: 0.6875rem;
  font-weight: 500;
  letter-spacing: 0.22em;
  text-transform: uppercase;
  color: var(--accent);
}

.panel__label-dot {
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: var(--accent);
  box-shadow: 0 0 0 4px rgba(139, 106, 69, 0.12);
}

.panel__title {
  font-family: var(--serif);
  font-weight: 500;
  font-size: clamp(3.15rem, 7.5vw, 5.75rem);
  line-height: 1.08;
  letter-spacing: -0.02em;
  color: var(--ink);
}

/* Extra room so Cormorant italic/descenders never get cut by the reveal mask */
.panel__mask {
  display: block;
  overflow: hidden;
  padding: 0.12em 0.12em 0.18em 0.04em;
  margin: -0.08em -0.12em -0.1em -0.04em;
}

.panel__title-line {
  display: block;
  padding-bottom: 0.06em;
  will-change: transform;
  /* Keep glyphs fully painted during GSAP transforms */
  backface-visibility: hidden;
  -webkit-font-smoothing: antialiased;
}

.panel__title-line--soft {
  font-style: italic;
  font-weight: 400;
  letter-spacing: -0.015em;
  color: #3a342c;
  /* Italic overhangs need a little more horizontal room */
  padding-right: 0.08em;
}

.panel__desc {
  max-width: 32ch;
  margin-top: 1.35rem;
  font-size: clamp(0.9375rem, 1.15vw, 1.0625rem);
  font-weight: 400;
  line-height: 1.7;
  letter-spacing: -0.01em;
  color: var(--ink-soft);
}

.panel__tags {
  display: flex;
  flex-wrap: wrap;
  gap: 0.45rem;
  margin-top: 1.25rem;
}

.panel__tags li {
  padding: 0.45rem 0.85rem;
  border: 1px solid var(--line);
  border-radius: 999px;
  background: rgba(251, 248, 243, 0.55);
  backdrop-filter: blur(8px);
  -webkit-backdrop-filter: blur(8px);
  font-size: 0.6875rem;
  font-weight: 500;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--ink-soft);
}

.panel__foot {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 1.25rem 1.75rem;
  margin-top: 1.75rem;
  padding-top: 1.5rem;
  border-top: 1px solid var(--line);
  width: 100%;
  max-width: 28rem;
}

.panel__price {
  display: flex;
  flex-direction: column;
  gap: 0.2rem;
  min-width: 5.5rem;
}

.panel__price-label {
  font-size: 0.625rem;
  font-weight: 500;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--muted);
}

.panel__price-value {
  font-family: var(--serif);
  font-size: 2rem;
  font-weight: 500;
  letter-spacing: -0.01em;
  color: var(--ink);
  font-variant-numeric: tabular-nums;
}

.panel__actions {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.6rem;
}

.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 0.55rem;
  height: 3rem;
  padding: 0 1.25rem;
  border-radius: 999px;
  font-size: 0.8125rem;
  font-weight: 500;
  letter-spacing: 0.02em;
  transition:
    transform 0.45s var(--ease-out),
    background 0.35s ease,
    border-color 0.35s ease,
    box-shadow 0.45s var(--ease-out),
    color 0.3s ease;
}

.btn__icon {
  display: grid;
  place-items: center;
  transition: transform 0.45s var(--ease-out);
}

.btn--fill {
  background: var(--ink);
  color: var(--white);
  box-shadow: var(--shadow-btn);
}

.btn--fill:hover {
  transform: translateY(-2px);
  background: #000;
  box-shadow: 0 18px 44px rgba(20, 18, 15, 0.22);
}

.btn--fill:hover .btn__icon {
  transform: translateX(3px);
}

.btn--ghost {
  border: 1px solid var(--line-strong);
  background: rgba(251, 248, 243, 0.5);
  backdrop-filter: blur(8px);
  color: var(--ink);
}

.btn--ghost:hover {
  border-color: var(--ink);
  background: var(--white);
}

/* ---------- Controls — bottom right, compact ---------- */
.controls {
  position: absolute;
  right: clamp(1.25rem, 4vw, 3rem);
  bottom: clamp(1.35rem, 3.5vh, 2.5rem);
  display: flex;
  align-items: center;
  gap: 0.85rem;
  padding: 0.55rem 0.55rem 0.55rem 1.15rem;
  border-radius: 999px;
  border: 1px solid var(--line);
  background: var(--glass);
  backdrop-filter: blur(16px) saturate(1.1);
  -webkit-backdrop-filter: blur(16px) saturate(1.1);
  box-shadow: var(--shadow-soft);
  pointer-events: auto;
  width: max-content;
}

.odometer {
  display: flex;
  align-items: center;
  gap: 0.45rem;
  user-select: none;
}

.odometer__window {
  position: relative;
  height: 1.55rem;
  overflow: hidden;
  font-family: var(--serif);
  font-size: 1.5rem;
  font-weight: 500;
  line-height: 1.55rem;
  letter-spacing: 0.02em;
  color: var(--ink);
}

.odometer__wheel {
  display: flex;
  flex-direction: column;
  will-change: transform;
}

.odometer__wheel span {
  display: block;
  height: 1.55rem;
  line-height: 1.55rem;
  font-variant-numeric: tabular-nums;
}

.odometer__sep {
  font-size: 0.75rem;
  color: var(--muted);
  letter-spacing: 0.08em;
}

.odometer__total {
  font-family: var(--serif);
  font-size: 0.9375rem;
  color: var(--muted);
  font-variant-numeric: tabular-nums;
  padding-right: 0.15rem;
}

.controls__nav {
  display: flex;
  gap: 0.35rem;
}

.ctrl {
  width: 2.65rem;
  height: 2.65rem;
  display: grid;
  place-items: center;
  border-radius: 50%;
  border: 1px solid var(--line-strong);
  background: rgba(255, 252, 247, 0.85);
  color: var(--ink);
  transition:
    transform 0.4s var(--ease-out),
    background 0.3s ease,
    border-color 0.3s ease;
}

.ctrl:hover {
  background: var(--white);
  border-color: var(--ink);
  transform: scale(1.06);
}

.ctrl:active {
  transform: scale(0.94);
}

/* ---------- Responsive ---------- */
@media (max-width: 720px) {
  .panel {
    top: auto;
    bottom: 6.5rem;
    transform: none;
    width: calc(100% - 2.5rem);
  }

  .panel__title {
    font-size: clamp(2.5rem, 11vw, 3.25rem);
  }

  .panel__desc {
    max-width: 100%;
    display: -webkit-box;
    -webkit-line-clamp: 2;
    -webkit-box-orient: vertical;
    overflow: hidden;
  }

  .panel__foot {
    margin-top: 1.25rem;
    padding-top: 1.15rem;
    gap: 1rem;
  }

  .btn--ghost {
    display: none;
  }

  .controls {
    right: 1rem;
    bottom: 1.1rem;
    padding: 0.45rem 0.45rem 0.45rem 0.95rem;
  }

  .slide__media::after {
    background:
      linear-gradient(
        180deg,
        rgba(244, 240, 233, 0.4) 0%,
        transparent 22%
      ),
      linear-gradient(
        0deg,
        rgba(244, 240, 233, 0.92) 0%,
        rgba(244, 240, 233, 0.75) 30%,
        rgba(244, 240, 233, 0.25) 55%,
        transparent 72%
      );
  }
}

@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation: none !important;
    transition: none !important;
  }
}
````

## Source — script.js
````javascript
/**
 * FORMA — Premium full-hero 3D furniture slider
 *
 * Motion principles (research-backed modern hero patterns):
 * - Custom expo-style easing (smooth start/end, no mechanical feel)
 * - Clip/mask title reveals (editorial, not plain opacity fades)
 * - Content exit early → 3D peak → content enter (choreographed timeline)
 * - Subtle image scale breathe during face rotation
 * - Odometer number roll synced to direction
 */

(() => {
  "use strict";

  const SLIDES = [
    {
      cat: "Living Collection",
      t1: "Linen Lounge",
      t2: "Sofa",
      desc: "Soft linen, solid oak, and quiet proportions for rooms that feel lived-in and elevated.",
      price: "$2,480",
      tags: ["Oak", "Linen", "Modular"],
      image:
        "https://images.unsplash.com/photo-1493663284031-b7e3aefcae8e?auto=format&fit=crop&w=2000&q=85",
    },
    {
      cat: "Dining Room",
      t1: "Nordic Oak",
      t2: "Table",
      desc: "A sculpted white-oak dining table made for slow meals and long conversations.",
      price: "$3,120",
      tags: ["White Oak", "Solid", "Seats 8"],
      image:
        "https://images.unsplash.com/photo-1617806118233-18e1de247200?auto=format&fit=crop&w=2000&q=85",
    },
    {
      cat: "Bedroom Suite",
      t1: "Cloud Form",
      t2: "Bedframe",
      desc: "Low platform, bouclé headboard, and calm geometry for a softer way to rest.",
      price: "$2,890",
      tags: ["Bouclé", "King", "Platform"],
      image:
        "https://images.unsplash.com/photo-1616594039964-ae9021a400a0?auto=format&fit=crop&w=2000&q=85",
    },
    {
      cat: "Studio Seating",
      t1: "Arc",
      t2: "Lounge Chair",
      desc: "Bentwood form with deep cushioning — mid-century spirit, modern restraint.",
      price: "$1,640",
      tags: ["Bentwood", "Wool", "Iconic"],
      image:
        "https://images.unsplash.com/photo-1567538096630-e0c55bd6374c?auto=format&fit=crop&w=2000&q=85",
    },
    {
      cat: "Interior Edit",
      t1: "Warmth",
      t2: "Living Set",
      desc: "A curated ensemble of texture, light wood, and soft neutrals for modern homes.",
      price: "$4,200",
      tags: ["Curated", "Textiles", "Lighting"],
      image:
        "https://images.unsplash.com/photo-1586023492125-27b2c045efd7?auto=format&fit=crop&w=2000&q=85",
    },
  ];

  const TOTAL = SLIDES.length;

  /* Motion tokens — smooth / modern */
  const EASE_3D = "expo.inOut";
  const EASE_OUT = "expo.out";
  const EASE_IN = "power2.in";
  const DUR_3D = 1.28;
  const DUR_COPY_OUT = 0.38;
  const DUR_COPY_IN = 0.85;
  const AUTO = 7200;

  const hero = document.getElementById("hero");
  const viewport = document.getElementById("viewport");
  const prevBtn = document.getElementById("prev");
  const nextBtn = document.getElementById("next");
  const catEl = document.getElementById("cat");
  const t1El = document.getElementById("t1");
  const t2El = document.getElementById("t2");
  const descEl = document.getElementById("desc");
  const priceEl = document.getElementById("price");
  const tagsEl = document.getElementById("tags");
  const odoWheel = document.getElementById("odoWheel");
  const odoTotal = document.getElementById("odoTotal");
  const panel = document.getElementById("panel");

  let slides = [];
  let current = 0;
  let busy = false;
  let autoId = null;
  let dragY0 = 0;
  let dragDy = 0;
  let dragging = false;
  let wheelLock = false;
  let copyTween = null;
  let odoTween = null;

  function pad(n) {
    return String(n).padStart(2, "0");
  }

  function build() {
    viewport.innerHTML = "";
    slides = [];
    odoTotal.textContent = pad(TOTAL);
    odoWheel.innerHTML = `<span>${pad(1)}</span>`;
    gsap.set(odoWheel, { y: 0 });

    SLIDES.forEach((s, i) => {
      const el = document.createElement("article");
      el.className = "slide";
      el.setAttribute("aria-hidden", i === 0 ? "false" : "true");
      el.innerHTML = `
        <div class="slide__media">
          <img
            src="${s.image}"
            alt="${s.t1} ${s.t2}"
            draggable="false"
            loading="${i === 0 ? "eager" : "lazy"}"
          />
        </div>
      `;
      viewport.appendChild(el);
      slides.push(el);
    });

    gsap.set(slides, {
      rotationX: 90,
      opacity: 0,
      transformOrigin: "50% 50%",
      force3D: true,
    });
    gsap.set(slides[0], { rotationX: 0, opacity: 1 });

    applyCopy(0);
  }

  function applyCopy(i) {
    const s = SLIDES[i];
    catEl.textContent = s.cat;
    t1El.textContent = s.t1;
    t2El.textContent = s.t2;
    descEl.textContent = s.desc;
    priceEl.textContent = s.price;
    tagsEl.innerHTML = s.tags.map((t) => `<li>${t}</li>`).join("");
  }

  /* ---------- Odometer ---------- */
  function spinOdometer(toIndex, dir) {
    if (odoTween) odoTween.kill();

    const nextLabel = pad(toIndex + 1);
    const line = odoWheel.querySelector("span");
    const lineH = line ? line.getBoundingClientRect().height : 24;

    const incoming = document.createElement("span");
    incoming.textContent = nextLabel;

    if (dir >= 0) {
      odoWheel.appendChild(incoming);
      odoTween = gsap.fromTo(
        odoWheel,
        { y: 0 },
        {
          y: -lineH,
          duration: 0.7,
          ease: EASE_OUT,
          onComplete() {
            odoWheel.innerHTML = `<span>${nextLabel}</span>`;
            gsap.set(odoWheel, { y: 0 });
          },
        }
      );
    } else {
      odoWheel.insertBefore(incoming, odoWheel.firstChild);
      gsap.set(odoWheel, { y: -lineH });
      odoTween = gsap.to(odoWheel, {
        y: 0,
        duration: 0.7,
        ease: EASE_OUT,
        onComplete() {
          odoWheel.innerHTML = `<span>${nextLabel}</span>`;
          gsap.set(odoWheel, { y: 0 });
        },
      });
    }
  }

  /* ---------- Content choreography ---------- */
  function getAnimParts() {
    return {
      soft: panel.querySelectorAll("[data-anim]"),
      titles: panel.querySelectorAll("[data-title]"),
    };
  }

  function animateCopy(index, dir) {
    const { soft, titles } = getAnimParts();
    if (copyTween) copyTween.kill();

    spinOdometer(index, dir);

    copyTween = gsap.timeline({ defaults: { ease: EASE_IN } });

    // Exit — titles drop into mask, soft elements fade/up slightly
    copyTween
      .to(
        titles,
        {
          yPercent: dir >= 0 ? -110 : 110,
          duration: DUR_COPY_OUT,
          stagger: 0.04,
          ease: EASE_IN,
        },
        0
      )
      .to(
        soft,
        {
          y: dir >= 0 ? -12 : 12,
          opacity: 0,
          duration: DUR_COPY_OUT * 0.9,
          stagger: 0.03,
          ease: EASE_IN,
        },
        0
      )
      .add(() => applyCopy(index))
      // Enter — titles rise from mask with expo, soft fade in
      .set(titles, { yPercent: dir >= 0 ? 110 : -110 })
      .set(soft, { y: dir >= 0 ? 16 : -16, opacity: 0 })
      .to(
        titles,
        {
          yPercent: 0,
          duration: DUR_COPY_IN,
          stagger: 0.07,
          ease: EASE_OUT,
          clearProps: "transform",
        },
        ">+0.02"
      )
      .to(
        soft,
        {
          y: 0,
          opacity: 1,
          duration: DUR_COPY_IN * 0.85,
          stagger: 0.05,
          ease: EASE_OUT,
          clearProps: "transform",
        },
        "<0.08"
      );
  }

  /* ---------- 3D navigation ---------- */
  function goTo(index, dir) {
    if (busy) return;
    const nextIndex = ((index % TOTAL) + TOTAL) % TOTAL;
    if (nextIndex === current) return;

    if (dir == null) {
      const forward = (nextIndex - current + TOTAL) % TOTAL;
      const backward = (current - nextIndex + TOTAL) % TOTAL;
      dir = forward <= backward ? 1 : -1;
    }

    busy = true;
    resetAuto();

    const from = slides[current];
    const to = slides[nextIndex];
    const imgFrom = from.querySelector("img");
    const imgTo = to.querySelector("img");

    const fromOrigin = dir > 0 ? "50% 0%" : "50% 100%";
    const toOrigin = dir > 0 ? "50% 100%" : "50% 0%";
    const fromEnd = dir > 0 ? -90 : 90;
    const toStart = dir > 0 ? 90 : -90;

    gsap.set(to, {
      rotationX: toStart,
      opacity: 1,
      transformOrigin: toOrigin,
      force3D: true,
    });
    gsap.set(from, {
      rotationX: 0,
      opacity: 1,
      transformOrigin: fromOrigin,
      force3D: true,
    });
    gsap.set(imgTo, { scale: 1.14 });

    from.style.zIndex = "2";
    to.style.zIndex = "1";

    // Content starts leaving immediately; 3D runs full duration
    animateCopy(nextIndex, dir);

    gsap
      .timeline({
        defaults: { ease: EASE_3D, force3D: true },
        onComplete() {
          gsap.set(from, {
            rotationX: 90,
            opacity: 0,
            transformOrigin: "50% 50%",
          });
          gsap.set(to, {
            rotationX: 0,
            opacity: 1,
            transformOrigin: "50% 50%",
          });
          // settle image to resting scale
          gsap.to(imgTo, {
            scale: 1.06,
            duration: 1.4,
            ease: "power2.out",
            overwrite: "auto",
          });
          from.style.zIndex = "";
          to.style.zIndex = "";
          from.setAttribute("aria-hidden", "true");
          to.setAttribute("aria-hidden", "false");
          current = nextIndex;
          busy = false;
        },
      })
      .to(from, { rotationX: fromEnd, duration: DUR_3D }, 0)
      .to(to, { rotationX: 0, duration: DUR_3D }, 0)
      .to(imgFrom, { scale: 1.18, duration: DUR_3D, ease: EASE_3D }, 0)
      .to(imgTo, { scale: 1.08, duration: DUR_3D, ease: EASE_3D }, 0)
      .add(() => {
        from.style.zIndex = "1";
        to.style.zIndex = "2";
      }, DUR_3D * 0.48);
  }

  function next() {
    goTo(current + 1, 1);
  }

  function prev() {
    goTo(current - 1, -1);
  }

  function resetAuto() {
    if (autoId) clearInterval(autoId);
    autoId = setInterval(() => {
      if (!busy && !dragging && !document.hidden) next();
    }, AUTO);
  }

  /* ---------- Input ---------- */
  function onWheel(e) {
    e.preventDefault();
    if (wheelLock || busy || dragging) return;
    wheelLock = true;
    if (e.deltaY > 0) next();
    else prev();
    setTimeout(() => {
      wheelLock = false;
    }, Math.round(DUR_3D * 700));
  }

  function onKey(e) {
    if (busy) return;
    if (e.key === "ArrowDown" || e.key === "ArrowRight") {
      e.preventDefault();
      next();
    } else if (e.key === "ArrowUp" || e.key === "ArrowLeft") {
      e.preventDefault();
      prev();
    }
  }

  function py(e) {
    return e.touches?.[0]?.clientY ?? e.changedTouches?.[0]?.clientY ?? e.clientY;
  }

  function onDown(e) {
    if (busy) return;
    if (e.target.closest("button, a, .panel, .controls")) return;
    dragging = true;
    dragY0 = py(e);
    dragDy = 0;
    hero.classList.add("is-drag");
    if (autoId) clearInterval(autoId);
  }

  function onMove(e) {
    if (!dragging || busy) return;
    dragDy = dragY0 - py(e);
    const h = hero.clientHeight || 1;
    const p = gsap.utils.clamp(-1, 1, dragDy / (h * 0.38));
    gsap.set(slides[current], {
      rotationX: p * -14,
      force3D: true,
    });
  }

  function onUp() {
    if (!dragging) return;
    dragging = false;
    hero.classList.remove("is-drag");
    const h = hero.clientHeight || 1;

    if (Math.abs(dragDy) > h * 0.09) {
      if (dragDy > 0) next();
      else prev();
    } else {
      gsap.to(slides[current], {
        rotationX: 0,
        duration: 0.65,
        ease: EASE_OUT,
        force3D: true,
      });
      resetAuto();
    }
    dragDy = 0;
  }

  /* ---------- Intro ---------- */
  function intro() {
    const { soft, titles } = getAnimParts();
    const controls = document.querySelector(".controls");
    const img = slides[0].querySelector("img");

    gsap.set(soft, { opacity: 0, y: 20 });
    gsap.set(titles, { yPercent: 110 });
    gsap.set(controls, { opacity: 0, y: 16 });
    gsap.set(slides[0], { rotationX: 22, opacity: 0 });
    gsap.set(img, { scale: 1.2 });

    const tl = gsap.timeline({ defaults: { ease: EASE_OUT } });

    tl.to(slides[0], {
      rotationX: 0,
      opacity: 1,
      duration: 1.45,
      ease: EASE_3D,
      force3D: true,
    })
      .to(img, { scale: 1.06, duration: 2.2, ease: "power2.out" }, 0.08)
      .to(
        titles,
        {
          yPercent: 0,
          duration: 1.05,
          stagger: 0.09,
          ease: EASE_OUT,
          clearProps: "transform",
        },
        0.45
      )
      .to(
        soft,
        {
          opacity: 1,
          y: 0,
          duration: 0.9,
          stagger: 0.06,
          ease: EASE_OUT,
          clearProps: "transform",
        },
        0.6
      )
      .to(controls, { opacity: 1, y: 0, duration: 0.75 }, 0.85);
  }

  function init() {
    build();

    prevBtn.addEventListener("click", prev);
    nextBtn.addEventListener("click", next);

    hero.addEventListener("wheel", onWheel, { passive: false });
    window.addEventListener("keydown", onKey);

    hero.addEventListener("mousedown", onDown);
    window.addEventListener("mousemove", onMove);
    window.addEventListener("mouseup", onUp);
    hero.addEventListener("touchstart", onDown, { passive: true });
    window.addEventListener("touchmove", onMove, { passive: true });
    window.addEventListener("touchend", onUp);

    document.addEventListener("visibilitychange", () => {
      if (document.hidden) {
        if (autoId) clearInterval(autoId);
      } else resetAuto();
    });

    intro();
    resetAuto();
  }

  if (document.readyState === "loading") {
    document.addEventListener("DOMContentLoaded", init);
  } else {
    init();
  }
})();
````
