# Radial Cards Slider(GSAP)

> Cards arranged on a radial path and driven by GSAP — distinctive carousel motion for spotlight content.

**Definition:** Radial Cards Slider(GSAP) 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-01
- Canonical page: https://html.bosconvertpro.com/collection/radial-cards-slider-gsap
- Full-page preview: https://html.bosconvertpro.com/collection/radial-cards-slider-gsap/full
- Live demo: https://html.bosconvertpro.com/demos/radial-cards-slider-gsap/index.html
- Markdown mirror: https://html.bosconvertpro.com/collection/radial-cards-slider-gsap/index.md
- License: https://html.bosconvertpro.com/terms

## How to use
1. Open https://html.bosconvertpro.com/collection/radial-cards-slider-gsap/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/radial-cards-slider-gsap.
3. Paste the files into your project and adjust timing, easing, and content as needed.

## FAQ
### What is Radial Cards Slider(GSAP)?

Radial Cards Slider(GSAP) is a sliders UI animation from collections.html. Cards arranged on a radial path and driven by GSAP — distinctive carousel motion for spotlight content.

### What is Radial Cards Slider(GSAP) built with?

Radial Cards Slider(GSAP) 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 Radial Cards Slider(GSAP) in my project?

Open the animation page (https://html.bosconvertpro.com/collection/radial-cards-slider-gsap), 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/radial-cards-slider-gsap/index.html.

### Is Radial Cards Slider(GSAP) free to use?

Yes. The Radial Cards Slider(GSAP) 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>Radial Cards Slider — GSAP</title>
    <link rel="preconnect" href="https://fonts.googleapis.com" />
    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
    <link
      href="https://fonts.googleapis.com/css2?family=DM+Sans:opsz,wght@9..40,500;9..40,600;9..40,700&family=Syne:wght@700;800&display=swap"
      rel="stylesheet"
    />
    <link rel="stylesheet" href="styles.css" />
  </head>
  <body>
    <main class="app">
      <section
        class="radial"
        aria-roledescription="carousel"
        aria-label="Radial cards gallery"
      >
        <div class="radial__stage" id="stage">
          <div class="radial__circle" id="circle" aria-hidden="true"></div>
          <div class="radial__track" id="track"></div>
        </div>

        <div class="radial__controls">
          <button class="nav-btn" id="prevBtn" type="button" aria-label="Previous">
            <svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
              <path
                d="M14.5 6.5 9 12l5.5 5.5"
                stroke="currentColor"
                stroke-width="2.1"
                stroke-linecap="round"
                stroke-linejoin="round"
              />
            </svg>
          </button>

          <div class="dots" id="dots" role="tablist" aria-label="Slides"></div>

          <button class="nav-btn" id="nextBtn" type="button" aria-label="Next">
            <svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
              <path
                d="M9.5 6.5 15 12l-5.5 5.5"
                stroke="currentColor"
                stroke-width="2.1"
                stroke-linecap="round"
                stroke-linejoin="round"
              />
            </svg>
          </button>
        </div>
      </section>
    </main>

    <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
/* ============================================
   Radial Cards Slider — True circle · full width
   ============================================ */

:root {
  --bg: #0a0a0a;
  --bg-deep: #000000;
  --paper: #f4f0ea;
  --paper-edge: #e8e2d9;
  --ink: #1c1f1d;
  --muted: rgba(244, 240, 234, 0.5);
  --dot: rgba(244, 240, 234, 0.3);
  --dot-on: #f4f0ea;
  --shadow: 0 20px 48px rgba(0, 0, 0, 0.45), 0 4px 14px rgba(0, 0, 0, 0.2),
    inset 0 1px 0 rgba(255, 255, 255, 0.22);
  --shadow-on: 0 28px 64px rgba(0, 0, 0, 0.55), 0 10px 28px rgba(0, 0, 0, 0.28),
    0 0 0 1px rgba(255, 255, 255, 0.12), inset 0 1px 0 rgba(255, 255, 255, 0.32);
  --glass: linear-gradient(
    155deg,
    rgba(255, 255, 255, 0.22) 0%,
    rgba(255, 255, 255, 0.1) 42%,
    rgba(255, 255, 255, 0.06) 100%
  );
  --glass-border: rgba(255, 255, 255, 0.28);
  --ease: cubic-bezier(0.22, 1, 0.36, 1);
  --font: "Syne", system-ui, sans-serif;
  --body: "DM Sans", system-ui, sans-serif;
}

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

html,
body {
  width: 100%;
  height: 100%;
}

body {
  font-family: var(--body);
  color: var(--paper);
  background: var(--bg);
  overflow: hidden;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}

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

/* ---------- Full-viewport app ---------- */

.app {
  width: 100vw;
  height: 100%;
  height: 100dvh;
  display: flex;
  align-items: center;
  justify-content: center;
  background:
    radial-gradient(ellipse 90% 70% at 50% 42%, #1a1a1a 0%, transparent 62%),
    linear-gradient(180deg, #141414 0%, var(--bg) 48%, var(--bg-deep) 100%);
}

.radial {
  width: 100%;
  height: 100%;
  display: flex;
  flex-direction: column;
  align-items: stretch;
  justify-content: center;
  gap: 0;
}

/* Stage is full width — circle geometry lives here */
.radial__stage {
  position: relative;
  width: 100%;
  flex: 1 1 auto;
  min-height: 0;
  overflow: hidden;
  touch-action: pan-y;
  user-select: none;
  -webkit-user-select: none;
  cursor: grab;
}

.radial__stage.is-dragging {
  cursor: grabbing;
}

.radial__stage.is-dragging .card {
  cursor: grabbing;
}

/* Optional subtle guide of the true circle (debug-friendly, very faint) */
.radial__circle {
  position: absolute;
  left: 50%;
  top: 0;
  width: 0;
  height: 0;
  pointer-events: none;
  opacity: 0;
}

/* Track origin = top-center of the circle (12 o'clock) */
.radial__track {
  position: absolute;
  left: 50%;
  top: 0;
  width: 0;
  height: 0;
  transform-style: preserve-3d;
}

/* ---------- Glass morphism card ---------- */

.card {
  position: absolute;
  left: 0;
  top: 0;
  width: var(--card-w, 210px);
  height: var(--card-h, 280px);
  margin: 0;
  border-radius: 20px;
  padding: 12px 12px 0;
  display: flex;
  flex-direction: column;
  transform-origin: center center;
  will-change: transform, opacity;
  backface-visibility: hidden;
  -webkit-backface-visibility: hidden;
  cursor: grab;

  /* Frosted glass shell */
  background: var(--glass);
  backdrop-filter: blur(22px) saturate(165%);
  -webkit-backdrop-filter: blur(22px) saturate(165%);
  border: 1px solid var(--glass-border);
  box-shadow: var(--shadow);
  isolation: isolate;
  overflow: hidden;
  transition:
    box-shadow 0.45s var(--ease),
    border-color 0.45s var(--ease),
    background 0.45s var(--ease);
}

.card.is-active {
  background: linear-gradient(
    155deg,
    rgba(255, 255, 255, 0.3) 0%,
    rgba(255, 255, 255, 0.14) 45%,
    rgba(255, 255, 255, 0.08) 100%
  );
  border-color: rgba(255, 255, 255, 0.4);
  box-shadow: var(--shadow-on);
}

/* Specular rim + soft inner glow */
.card::before {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
  background: linear-gradient(
    145deg,
    rgba(255, 255, 255, 0.38) 0%,
    rgba(255, 255, 255, 0.06) 28%,
    transparent 52%,
    rgba(255, 255, 255, 0.04) 100%
  );
  box-shadow:
    inset 0 1px 0 rgba(255, 255, 255, 0.45),
    inset 0 -1px 0 rgba(255, 255, 255, 0.06);
  pointer-events: none;
  z-index: 3;
}

/* Bottom glass highlight under caption */
.card::after {
  content: "";
  position: absolute;
  left: 8%;
  right: 8%;
  bottom: 0;
  height: 42%;
  border-radius: 0 0 18px 18px;
  background: linear-gradient(
    180deg,
    transparent 0%,
    rgba(255, 255, 255, 0.04) 45%,
    rgba(255, 255, 255, 0.1) 100%
  );
  pointer-events: none;
  z-index: 1;
}

.card__frame {
  position: relative;
  flex: 1 1 auto;
  min-height: 0;
  border-radius: 13px;
  overflow: hidden;
  background: rgba(0, 0, 0, 0.25);
  box-shadow:
    0 1px 0 rgba(255, 255, 255, 0.12),
    inset 0 0 0 1px rgba(255, 255, 255, 0.08);
  z-index: 2;
}

.card__frame::after {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
  box-shadow:
    inset 0 0 0 1px rgba(255, 255, 255, 0.14),
    inset 0 12px 28px rgba(255, 255, 255, 0.06);
  pointer-events: none;
  z-index: 2;
}

.card__img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
  transform: scale(1.03);
  transition: transform 0.65s var(--ease);
  pointer-events: none;
}

.card.is-active .card__img {
  transform: scale(1.07);
}

.card__shine {
  position: absolute;
  inset: 0;
  border-radius: inherit;
  background: linear-gradient(
    125deg,
    rgba(255, 255, 255, 0.32) 0%,
    rgba(255, 255, 255, 0.08) 32%,
    transparent 58%
  );
  pointer-events: none;
  z-index: 1;
  mix-blend-mode: soft-light;
}

.card__caption {
  position: relative;
  height: 50px;
  display: flex;
  align-items: center;
  justify-content: center;
  flex-shrink: 0;
  padding: 0 0.35rem 0.15rem;
  z-index: 2;
}

.card__title {
  font-family: var(--font);
  font-weight: 700;
  font-size: clamp(0.72rem, 1.1vw, 0.92rem);
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: rgba(255, 255, 255, 0.94);
  line-height: 1;
  white-space: nowrap;
  text-shadow: 0 1px 12px rgba(0, 0, 0, 0.35);
}

/* ---------- Controls ---------- */

.radial__controls {
  flex: 0 0 auto;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 0.9rem;
  padding: 0.35rem 1rem 1.6rem;
  z-index: 20;
}

.nav-btn {
  width: 48px;
  height: 48px;
  border-radius: 999px;
  display: grid;
  place-items: center;
  background: rgba(244, 240, 234, 0.08);
  border: 1px solid rgba(244, 240, 234, 0.14);
  color: #f4f0ea;
  backdrop-filter: blur(12px);
  -webkit-backdrop-filter: blur(12px);
  box-shadow: 0 10px 28px rgba(0, 0, 0, 0.2);
  transition:
    background 0.28s var(--ease),
    border-color 0.28s var(--ease),
    transform 0.28s var(--ease),
    box-shadow 0.28s var(--ease);
}

.nav-btn svg {
  width: 21px;
  height: 21px;
}

.nav-btn:hover {
  background: rgba(244, 240, 234, 0.16);
  border-color: rgba(244, 240, 234, 0.3);
  transform: translateY(-1px);
  box-shadow: 0 14px 32px rgba(0, 0, 0, 0.26);
}

.nav-btn:active {
  transform: scale(0.96);
}

.nav-btn:focus-visible {
  outline: 2px solid rgba(244, 240, 234, 0.55);
  outline-offset: 3px;
}

.dots {
  display: flex;
  align-items: center;
  gap: 0.48rem;
  min-height: 48px;
  padding: 0 0.25rem;
}

.dot {
  width: 7px;
  height: 7px;
  border-radius: 999px;
  background: var(--dot);
  transition:
    width 0.35s var(--ease),
    background 0.35s var(--ease);
}

.dot.is-active {
  width: 22px;
  background: var(--dot-on);
}

.dot:focus-visible {
  outline: 2px solid rgba(244, 240, 234, 0.55);
  outline-offset: 3px;
}

/* ---------- Responsive cards ---------- */

@media (max-width: 900px) {
  .card {
    border-radius: 16px;
    padding: 10px 10px 0;
  }

  .card__frame {
    border-radius: 10px;
  }

  .card__caption {
    height: 44px;
  }
}

@media (max-width: 560px) {
  .card {
    border-radius: 14px;
    padding: 8px 8px 0;
  }

  .card__frame {
    border-radius: 9px;
  }

  .card__caption {
    height: 40px;
  }

  .card__title {
    letter-spacing: 0.12em;
  }

  .nav-btn {
    width: 44px;
    height: 44px;
  }

  .radial__controls {
    padding-bottom: 1.25rem;
    gap: 0.65rem;
  }
}

@media (prefers-reduced-motion: reduce) {
  .card,
  .card__img,
  .nav-btn,
  .dot {
    transition: none !important;
  }
}
````

## Source — script.js
````javascript
/**
 * Radial Cards Slider — GSAP
 * True circular path (not a fake cosine bowl):
 *
 *   θ = offset × angleStep
 *   x =  R × sin(θ)
 *   y =  R × (1 − cos(θ))     // circle center below; top of arc at θ=0
 *   rotation = θ (degrees)    // tangent to the circle
 *
 * Radius is derived from viewport width so the arc spans full width.
 */

(() => {
  "use strict";

  const DATA = [
    {
      title: "Rádial",
      image:
        "https://images.unsplash.com/photo-1506905925346-21bda4d32df4?auto=format&fit=crop&w=900&q=82",
    },
    {
      title: "Trøpical",
      image:
        "https://images.unsplash.com/photo-1536935338788-846bb9981813?auto=format&fit=crop&w=900&q=82",
    },
    {
      title: "Leåf",
      image:
        "https://images.unsplash.com/photo-1509423350716-97f9360b4e09?auto=format&fit=crop&w=900&q=82",
    },
    {
      title: "Dübai",
      image:
        "https://images.unsplash.com/photo-1512453979798-5ea266f8880c?auto=format&fit=crop&w=900&q=82",
    },
    {
      title: "Yøga",
      image:
        "https://images.unsplash.com/photo-1544367567-0f2fcb009e0b?auto=format&fit=crop&w=900&q=82",
    },
    {
      title: "Nordíc",
      image:
        "https://images.unsplash.com/photo-1464822759023-fed622ff2c3b?auto=format&fit=crop&w=900&q=82",
    },
    {
      title: "Café",
      image:
        "https://images.unsplash.com/photo-1495474472287-4d71bcdd2085?auto=format&fit=crop&w=900&q=82",
    },
  ];

  const TOTAL = DATA.length;
  const DEG = 180 / Math.PI;

  const stage = document.getElementById("stage");
  const track = document.getElementById("track");
  const dotsEl = document.getElementById("dots");
  const prevBtn = document.getElementById("prevBtn");
  const nextBtn = document.getElementById("nextBtn");

  const state = { pos: 0 };
  let destination = 0;
  let cards = [];
  let dots = [];
  let geo = null;
  let dragMoved = false;
  let resizeTimer = null;

  /* ========================================================================
   * Geometry — exact circle, full-width radius
   * ======================================================================== */

  function cardSize(vw) {
    if (vw <= 420) return { w: 132, h: 176 };
    if (vw <= 560) return { w: 148, h: 198 };
    if (vw <= 768) return { w: 168, h: 224 };
    if (vw <= 1100) return { w: 196, h: 262 };
    if (vw <= 1440) return { w: 214, h: 286 };
    return { w: 230, h: 308 };
  }

  /**
   * Build circle metrics from the live stage size.
   *
   * Exact circle path (center BELOW the cards, active card at θ = 0 / 12 o'clock):
   *
   *   x = R · sin(θ)
   *   y = R · (1 − cos(θ))
   *   rotation = θ  (degrees)  → tangent to the circle
   *
   * Angle step is the visual spacing between neighboring cards on that circle.
   * Radius is solved so the visible arc spans the full viewport width.
   */
  function computeGeometry() {
    const rect = stage.getBoundingClientRect();
    const vw = rect.width || window.innerWidth;
    const vh = rect.height || window.innerHeight * 0.78;
    const { w: cardW, h: cardH } = cardSize(vw);

    // How many card-steps from center stay on the arc (edges clip off-screen)
    const visibleSlots = 2.35;

    // Full-width: card centers near left/right edges with slight bleed
    const targetX = vw * 0.5 - cardW * 0.18;

    // Layout padding inside the stage
    const topPad = Math.max(cardH * 0.36, vh * 0.08);
    const bottomReserve = cardH * 0.58;
    const availableDip = Math.max(vh - topPad - bottomReserve, cardH * 0.28);

    /*
     * Solve for a TRUE circle that spans full width.
     *
     * Constraints:
     *   R · sin(θ_edge) = targetX          (full width)
     *   R · (1 − cos(θ_edge)) ≤ availableDip  (fits height)
     *
     * From width:  R = targetX / sin(θ)
     * Dip becomes: targetX · (1 − cos(θ)) / sin(θ) = targetX · tan(θ/2)
     * So pick the largest θ_edge that still fits height, then R from width.
     */
    // Ideal edge angle for a premium radial look (~55–65°)
    let thetaEdge = (58 * Math.PI) / 180;

    // Height limit via half-angle formula: dip = targetX * tan(θ/2)
    const maxThetaFromHeight =
      2 * Math.atan(availableDip / Math.max(targetX, 1));
    // Keep a readable circle (not flat, not too steep)
    const minTheta = (42 * Math.PI) / 180;
    const maxTheta = (72 * Math.PI) / 180;
    thetaEdge = gsap.utils.clamp(
      minTheta,
      Math.min(maxTheta, maxThetaFromHeight || maxTheta),
      thetaEdge
    );

    // Exact radius for full-width circle
    let radius = targetX / Math.sin(thetaEdge);

    // Angular pitch between neighboring cards
    const angleStep = thetaEdge / visibleSlots;

    // Final dip on this exact circle
    const dip = radius * (1 - Math.cos(thetaEdge));

    // Active card (θ = 0) center
    const originY = topPad + cardH * 0.5;

    // If residual height allows, nudge origin slightly for vertical balance
    const leftover = vh - topPad - dip - bottomReserve;
    const originYFinal = originY + Math.max(0, leftover) * 0.25;

    const duration = vw < 560 ? 0.78 : vw < 900 ? 0.88 : 0.96;

    document.documentElement.style.setProperty("--card-w", `${cardW}px`);
    document.documentElement.style.setProperty("--card-h", `${cardH}px`);

    // Track origin = top of circle (12 o'clock), full-width stage center
    track.style.left = "50%";
    track.style.top = `${originYFinal}px`;

    // Chord length of one step → natural drag feel on the circle
    const dragPixels = Math.max(
      2 * radius * Math.sin(angleStep / 2),
      cardW * 0.88
    );

    return {
      vw,
      vh,
      cardW,
      cardH,
      radius,
      angleStep,
      visibleSlots,
      originY: originYFinal,
      duration,
      dragPixels,
    };
  }

  /* ========================================================================
   * DOM
   * ======================================================================== */

  function build() {
    track.innerHTML = "";
    dotsEl.innerHTML = "";
    cards = [];
    dots = [];

    DATA.forEach((item, i) => {
      const card = document.createElement("article");
      card.className = "card";
      card.dataset.index = String(i);
      card.setAttribute("role", "group");
      card.setAttribute("aria-roledescription", "slide");
      card.setAttribute("aria-label", `${i + 1} of ${TOTAL}: ${item.title}`);

      card.innerHTML = `
        <div class="card__frame">
          <img class="card__img" src="${item.image}" alt="${item.title}" draggable="false" loading="${i < 4 ? "eager" : "lazy"}" />
          <span class="card__shine" aria-hidden="true"></span>
        </div>
        <div class="card__caption">
          <h2 class="card__title">${item.title}</h2>
        </div>
      `;

      // GSAP will translate from the card's own center; pin top-left so
      // x/y land on the circle after we offset by half size.
      track.appendChild(card);
      cards.push(card);

      const dot = document.createElement("button");
      dot.type = "button";
      dot.className = "dot";
      dot.setAttribute("role", "tab");
      dot.setAttribute("aria-label", `Go to ${item.title}`);
      dot.dataset.index = String(i);
      dotsEl.appendChild(dot);
      dots.push(dot);
    });
  }

  /* ========================================================================
   * Circular math
   * ======================================================================== */

  /** Shortest signed slot distance on a circle of TOTAL items. */
  function wrapOffset(index, from) {
    let diff = index - from;
    const half = TOTAL / 2;
    while (diff > half) diff -= TOTAL;
    while (diff < -half) diff += TOTAL;
    return diff;
  }

  function modIndex(n) {
    return ((Math.round(n) % TOTAL) + TOTAL) % TOTAL;
  }

  /**
   * Exact circle sample for a continuous slot offset.
   * offset = 0 → top of circle (active).
   */
  function pointOnCircle(offset) {
    const { radius, angleStep, cardW, cardH, visibleSlots } = geo;
    const theta = offset * angleStep; // radians

    // Exact circle (center of circle is at (0, radius) in track space;
    // track origin is the top point of the circle)
    const x = radius * Math.sin(theta);
    const y = radius * (1 - Math.cos(theta));

    // Tangent orientation — card stands perpendicular to the radius
    // (matches the radial fan in the reference)
    const rotation = theta * DEG;

    const abs = Math.abs(offset);

    // Soft scale hierarchy (center slightly larger — premium, not cartoony)
    let scale = 1;
    if (abs <= 1) {
      scale = gsap.utils.interpolate(1, 0.94, abs);
    } else if (abs <= 2) {
      scale = gsap.utils.interpolate(0.94, 0.88, abs - 1);
    } else {
      scale = Math.max(0.82, 0.88 - (abs - 2) * 0.04);
    }

    // Fade as cards leave the visible arc
    let opacity = 1;
    if (abs > visibleSlots - 0.35) {
      opacity = gsap.utils.clamp(
        0,
        1,
        1 - (abs - (visibleSlots - 0.35)) / 0.85
      );
    }
    if (abs > visibleSlots + 0.55) opacity = 0;

    const zIndex = Math.round(300 - abs * 40);

    // GSAP x/y are applied from the element's layout position.
    // Cards are position:absolute at (0,0) of the track origin, so we
    // shift by -half size so the CARD CENTER sits on the circle.
    return {
      x: x - cardW / 2,
      y: y - cardH / 2,
      rotation,
      scale,
      opacity,
      zIndex,
      theta,
      abs,
    };
  }

  /* ========================================================================
   * Render
   * ======================================================================== */

  function render(pos) {
    cards.forEach((card, i) => {
      const offset = wrapOffset(i, pos);
      const p = pointOnCircle(offset);
      const isActive = Math.abs(offset) < 0.42;

      card.classList.toggle("is-active", isActive);
      card.setAttribute("aria-hidden", p.opacity < 0.05 ? "true" : "false");
      card.style.zIndex = String(p.zIndex);
      card.style.pointerEvents = p.opacity < 0.08 ? "none" : "auto";

      gsap.set(card, {
        x: p.x,
        y: p.y,
        rotation: p.rotation,
        scale: p.scale,
        opacity: p.opacity,
        force3D: true,
      });
    });

    updateDots(modIndex(pos));
  }

  function updateDots(index) {
    dots.forEach((dot, i) => {
      const on = i === index;
      dot.classList.toggle("is-active", on);
      dot.setAttribute("aria-selected", on ? "true" : "false");
    });
  }

  /* ========================================================================
   * Navigation (destination tracking — rapid clicks stay correct)
   * ======================================================================== */

  function animateTo(next, duration) {
    destination = next;
    gsap.killTweensOf(state);
    gsap.to(state, {
      pos: destination,
      duration: duration ?? geo.duration,
      ease: "power3.out",
      overwrite: true,
      onUpdate: () => render(state.pos),
    });
  }

  function step(dir) {
    animateTo(destination + dir);
  }

  function goToIndex(index) {
    const current = modIndex(destination);
    const offset = wrapOffset(index, current);
    animateTo(destination + offset);
  }

  /* ========================================================================
   * Drag — horizontal drag maps onto circular slot progress
   * ======================================================================== */

  function setupDrag() {
    let active = false;
    let startX = 0;
    let startPos = 0;
    let lastX = 0;
    let lastT = 0;
    let velocity = 0;
    let pointerId = null;

    const down = (e) => {
      if (e.pointerType === "mouse" && e.button !== 0) return;
      if (e.target.closest(".radial__controls")) return;

      active = true;
      dragMoved = false;
      pointerId = e.pointerId;
      startX = e.clientX;
      lastX = startX;
      lastT = performance.now();
      startPos = destination;
      velocity = 0;

      try {
        stage.setPointerCapture(pointerId);
      } catch (_) {
        /* noop */
      }

      gsap.killTweensOf(state);
      state.pos = destination;
      stage.classList.add("is-dragging");
    };

    const move = (e) => {
      if (!active) return;
      if (pointerId != null && e.pointerId !== pointerId) return;

      const x = e.clientX;
      const now = performance.now();
      const dt = Math.max(8, now - lastT);
      velocity = (x - lastX) / dt;
      lastX = x;
      lastT = now;

      const dx = x - startX;
      if (Math.abs(dx) > 6) dragMoved = true;

      // Finger right → cards rotate previous (content follows finger)
      state.pos = startPos - dx / geo.dragPixels;
      destination = state.pos;
      render(state.pos);
    };

    const up = (e) => {
      if (!active) return;
      if (pointerId != null && e.pointerId !== pointerId) return;

      active = false;
      pointerId = null;
      stage.classList.remove("is-dragging");

      const flick = gsap.utils.clamp(-1.75, 1.75, -velocity * 150);
      const next = Math.round(state.pos + flick);
      destination = next;
      animateTo(destination, geo.duration * 0.9);
    };

    stage.addEventListener("pointerdown", down);
    window.addEventListener("pointermove", move);
    window.addEventListener("pointerup", up);
    window.addEventListener("pointercancel", up);

    track.addEventListener("click", (e) => {
      if (dragMoved) return;
      const card = e.target.closest(".card");
      if (!card) return;
      const idx = Number(card.dataset.index);
      if (Number.isNaN(idx)) return;
      if (idx === modIndex(destination)) return;
      goToIndex(idx);
    });
  }

  /* ========================================================================
   * Controls / keyboard / resize
   * ======================================================================== */

  function setupControls() {
    prevBtn.addEventListener("click", () => step(-1));
    nextBtn.addEventListener("click", () => step(1));
    dots.forEach((dot) => {
      dot.addEventListener("click", () => goToIndex(Number(dot.dataset.index)));
    });
  }

  function setupKeyboard() {
    window.addEventListener("keydown", (e) => {
      if (e.key === "ArrowRight") {
        e.preventDefault();
        step(1);
      } else if (e.key === "ArrowLeft") {
        e.preventDefault();
        step(-1);
      }
    });
  }

  function setupResize() {
    const ro = new ResizeObserver(() => {
      clearTimeout(resizeTimer);
      resizeTimer = setTimeout(() => {
        geo = computeGeometry();
        gsap.killTweensOf(state);
        state.pos = destination;
        render(state.pos);
      }, 60);
    });
    ro.observe(stage);

    window.addEventListener("orientationchange", () => {
      setTimeout(() => {
        geo = computeGeometry();
        state.pos = destination;
        render(state.pos);
      }, 180);
    });
  }

  /* ========================================================================
   * Intro — cards rise onto the circle
   * ======================================================================== */

  function intro() {
    cards.forEach((card, i) => {
      const offset = wrapOffset(i, 0);
      const p = pointOnCircle(offset);
      gsap.set(card, {
        x: p.x * 1.08,
        y: p.y + geo.cardH * 0.55,
        rotation: p.rotation * 1.15,
        scale: 0.78,
        opacity: 0,
      });
    });

    const order = cards
      .map((card, i) => ({ card, i, o: Math.abs(wrapOffset(i, 0)) }))
      .sort((a, b) => a.o - b.o);

    order.forEach(({ card, i }, rank) => {
      const offset = wrapOffset(i, 0);
      const p = pointOnCircle(offset);
      card.style.zIndex = String(p.zIndex);
      card.classList.toggle("is-active", Math.abs(offset) < 0.42);

      gsap.to(card, {
        x: p.x,
        y: p.y,
        rotation: p.rotation,
        scale: p.scale,
        opacity: p.opacity,
        duration: 1.05,
        delay: 0.1 + rank * 0.055,
        ease: "power3.out",
        force3D: true,
        onComplete: () => {
          if (rank === order.length - 1) {
            state.pos = 0;
            destination = 0;
            render(0);
          }
        },
      });
    });

    updateDots(0);
  }

  /* ========================================================================
   * Boot
   * ======================================================================== */

  function init() {
    if (typeof gsap === "undefined") {
      console.error("GSAP failed to load.");
      return;
    }

    gsap.config({ force3D: true });
    build();
    geo = computeGeometry();
    setupControls();
    setupDrag();
    setupKeyboard();
    setupResize();
    intro();
  }

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