Innovations
heroes /

Disc Trio (Restaurant / Menu / Product)

Three counter-rotating circular photographs over a blurred, darkened room, with the autoplay timer drawn as a yellow arc around the middle one. The discs never travel — the trio swells and settles while the images cross-fade under it. Centred wordmark nav, prev/CTA/next along the bottom. Measured off Slider Revolution

Preview

Source

tsx
"use client";

import { useCallback, useEffect, useRef, useState } from "react";

/**
 * Disc Trio — three counter-rotating circular photographs over a blurred room,
 * with the autoplay timer drawn as an arc around the middle one.
 *
 * Measured off sliderrevolution.com's "Modern Restaurant Slider", not eyeballed.
 * What the live DOM actually says, and what is worth keeping:
 *
 *   · THE DISCS COUNTER-ROTATE. The middle one turns clockwise at 4 deg/sec and
 *     the outer two turn anticlockwise at the same rate. Sampled off the live
 *     transforms over two seconds. Everything spinning together looks like a
 *     turntable; opposed, it reads as three separate objects and the middle one
 *     as the subject.
 *   · THE YELLOW ARC IS THE AUTOPLAY TIMER, not decoration. It is a canvas in
 *     the original (class sr7-pbar) sweeping a ring around the middle disc as
 *     the slide runs out. Here it is a conic-gradient with a radial mask, which
 *     needs no canvas, no rAF and no resize handling.
 *   · THE DISCS DO NOT SLIDE. All three hold their x positions through the
 *     change; the trio scales UP to about 1.22 and back while the images cross-
 *     fade under it. Sampled at 110ms intervals: 324 -> 395 -> 280 outgoing,
 *     280 -> 393 -> 300 incoming, opacity crossing near the peak. It reads as a
 *     swell rather than a carousel, which is the whole character of the thing.
 *   · The copy leaves UPWARD (30px) and arrives from below, offset from the
 *     discs so the two movements do not compete.
 *
 * Nothing here is their code and no asset of theirs is bundled: the mechanics
 * were measured, the implementation is ours, and the pictures are yours to
 * supply.
 *
 * ── THE STATIC STATE IS THE DEFAULT.
 * Rotation, autoplay and the swell all live under `.dt.is-motion`, added by the
 * effect and nowhere else. With no JS, a thrown error, or prefers-reduced-
 * motion you get the first slide, whole and readable, with working links. The
 * arrows still change slides under reduced motion — they just do it instantly.
 */

export interface DiscTrioDish {
  /** Square photograph, cropped to a circle. Null for a placeholder disc. */
  src?: string | null;
  alt?: string;
  /** Shown in the hover card above the disc — the dish. */
  name?: string;
  /** Second line of the hover card — dietary notes, origin, price, whatever
   *  the one extra fact is. Omit both and no card is rendered. */
  note?: string;
}

export interface DiscTrioSlide {
  title: string;
  body?: string;
  /** Room behind this slide. Falls back to the component-level `background`.
   *  The reference changes it per slide and cross-fades — verified by eye,
   *  because Slider Revolution paints slide backgrounds to a canvas and the
   *  URL is not readable from CSS. */
  background?: string | null;
  /** Exactly three reads best — the timer ring is centred on the middle one. */
  dishes: DiscTrioDish[];
}

export interface DiscTrioHeroProps {
  /** Room behind everything. It is blurred and darkened, so a busy photograph
   *  is fine here — it is atmosphere, not subject. Null for a plain ground. */
  background?: string | null;
  /** How hard the background is pushed back, in px of blur. */
  backgroundBlur?: number;
  /** 0 to 1. The copy is white and sits on top of this. */
  veil?: number;

  brandTop?: string;
  brandMain?: string;
  navLeft?: { label: string; href: string }[];
  navRight?: { label: string; href: string }[];

  slides?: DiscTrioSlide[];

  cta?: { label: string; href: string } | null;
  /** Bottom-left marker. Two short lines, stacked — a year, an est., a city. */
  marker?: [string, string] | null;
  social?: { label: string; href: string } | null;

  /** Milliseconds a slide holds before advancing. 0 turns autoplay off and the
   *  timer ring with it. */
  interval?: number;
  /** Degrees per second. The reference runs 4. */
  spin?: number;
  /** Disc diameter in px at full size. */
  disc?: number;
  gap?: number;

  accent?: string;
  ink?: string;
}

/* Demo dishes. Shot top-down on white and TRIMMED TO THE RIM before they got
   here, which is the step that matters: the circular mask cuts at the edge of
   the square, so an untrimmed plate floating in a white margin shows a pale
   ring around itself against the dark room. Trim first, then square, then mask. */
const BG_DINNER = "/heroes/disc-trio/bg-dinner.webp";
const BG_GARDEN = "/heroes/disc-trio/bg-garden.webp";
const BG_FEAST = "/heroes/disc-trio/bg-feast.webp";
const P1 = {
  src: "/heroes/disc-trio/plate1.webp",
  alt: "Spaghetti bolognese, seen from above",
  name: "Slow Beef Ragu, Hand-Rolled Spaghetti",
  note: "Locally-Sourced / 48-Hour Braise",
};
const P2 = {
  src: "/heroes/disc-trio/plate2.webp",
  alt: "Prawn and cucumber salad on a dark plate, seen from above",
  name: "Charred Prawn & Cucumber Ribbons",
  note: "Gluten-Free / High-Protein / Day-Boat",
};

const P3 = {
  src: "/heroes/disc-trio/plate3.webp",
  alt: "Beef and potato stew in a dark wooden bowl, seen from above",
  name: "Braised Beef & New Potato Stew",
  note: "Slow-Cooked / Locally-Sourced / Dill",
};

const PLACEHOLDER = [
  "radial-gradient(circle at 38% 32%, #6b6560, #47423d 62%, #2e2a26)",
  "radial-gradient(circle at 62% 38%, #746d66, #4e4842 62%, #322e2a)",
  "radial-gradient(circle at 45% 60%, #635d58, #433e3a 62%, #2a2724)",
];

export default function DiscTrioHero({
  background = BG_DINNER,
  backgroundBlur = 8,
  veil = 0.74,

  brandTop = "Cookbook",
  brandMain = "Ravenous",
  navLeft = [
    { label: "About", href: "#" },
    { label: "Recipes", href: "#" },
  ],
  navRight = [
    { label: "Carnivore Diet", href: "#" },
    { label: "Contact", href: "#" },
  ],

  slides = [
    {
      title: "Seasonal Delights",
      body: "Experience the best of each season with our menu, featuring fresh, locally sourced ingredients. Savor the vibrant tastes of nature's bounty.",
      background: BG_DINNER,
      dishes: [P1, P2, P3],
    },
    {
      title: "A Symphony of Flavors",
      body: "Every plate is built around one idea and finished by hand. Come hungry, stay late, and let the kitchen decide the order.",
      background: BG_GARDEN,
      dishes: [P3, P1, P2],
    },
    {
      title: "Chef's Creations",
      body: "A short menu that changes when the market does. Ask what came in this morning and order that.",
      background: BG_FEAST,
      dishes: [P2, P3, P1],
    },
  ],

  cta = { label: "Find recipes", href: "#" },
  marker = ["20", "26"],
  social = { label: "Instagram", href: "#" },

  interval = 7000,
  spin = 4,
  disc = 300,
  gap = 26,

  accent = "#ffcc00",
  ink = "#0c0c0c",
}: DiscTrioHeroProps) {
  const rootRef = useRef<HTMLElement | null>(null);
  const [idx, setIdx] = useState(0);
  const [prev, setPrev] = useState<number | null>(null);
  const [motion, setMotion] = useState(false);

  const count = slides.length;
  const go = useCallback(
    (next: number) => {
      setIdx((cur) => {
        const n = (next + count) % count;
        if (n === cur) return cur;
        setPrev(cur);
        return n;
      });
    },
    [count]
  );

  useEffect(() => {
    const root = rootRef.current;
    if (!root) return;
    if (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) return;
    // is-motion LAST, so a throw above leaves the readable static slide.
    root.classList.add("is-motion");
    setMotion(true);
    return () => {
      root.classList.remove("is-motion");
      setMotion(false);
    };
  }, []);

  // Autoplay. Paused while the pointer is over the hero, while the tab is
  // hidden, and whenever the hero is off-screen — a timer advancing a carousel
  // nobody is looking at is just churn, and it means the ring is never wrong
  // about how long is left.
  const ringRef = useRef<HTMLDivElement | null>(null);
  useEffect(() => {
    const root = rootRef.current;
    if (!root || !motion || !interval || count < 2) return;

    let start = performance.now();
    let raf = 0;
    let paused = false;
    let visible = true;

    const io = new IntersectionObserver(([e]) => (visible = e.isIntersecting), { threshold: 0.15 });
    io.observe(root);
    const onEnter = () => (paused = true);
    const onLeave = () => (paused = false);
    root.addEventListener("pointerenter", onEnter);
    root.addEventListener("pointerleave", onLeave);

    let last = performance.now();
    const tick = (now: number) => {
      const dt = now - last;
      last = now;
      if (paused || !visible || document.hidden) start += dt;
      const p = Math.min(1, (now - start) / interval);
      root.style.setProperty("--p", String(p));
      if (p >= 1) {
        start = now;
        go(idx + 1);
      }
      raf = requestAnimationFrame(tick);
    };
    raf = requestAnimationFrame(tick);

    return () => {
      cancelAnimationFrame(raf);
      io.disconnect();
      root.removeEventListener("pointerenter", onEnter);
      root.removeEventListener("pointerleave", onLeave);
    };
  }, [motion, interval, idx, count, go]);

  const vars = {
    "--accent": accent,
    "--ink": ink,
    "--disc-max": `${disc}px`,
    "--gap-max": `${gap}px`,
    "--spin-ms": `${Math.round((360 / Math.max(0.2, spin)) * 1000)}ms`,
    "--blur": `${backgroundBlur}px`,
    "--veil": String(veil),
  } as React.CSSProperties;

  return (
    <section className="dt" ref={rootRef} style={vars}>
      <style>{CSS}</style>

      {/* One layer per slide, cross-fading. The room changing with the menu is
          half of why the reference feels like a different place each time. */}
      <div className="dt__bg" aria-hidden="true">
        {slides.map((s, i) => {
          const src = s.background ?? background;
          return (
            <div
              key={i}
              className="dt__bg-layer"
              data-state={i === idx ? "on" : i === prev ? "off" : "idle"}
            >
              {src ? <img src={src} alt="" /> : <div className="dt__bg-fill" />}
            </div>
          );
        })}
      </div>
      <div className="dt__veil" aria-hidden="true" />

      <header className="dt__nav">
        <nav className="dt__navside">
          {navLeft.map((l) => (
            <a key={l.label} href={l.href}>
              {l.label}
            </a>
          ))}
        </nav>
        <a className="dt__brand" href="#">
          <span className="dt__brand-top">
            <i aria-hidden="true" />
            {brandTop}
            <i aria-hidden="true" />
          </span>
          <span className="dt__brand-main">{brandMain}</span>
        </a>
        <nav className="dt__navside dt__navside--r">
          {navRight.map((l) => (
            <a key={l.label} href={l.href}>
              {l.label}
            </a>
          ))}
        </nav>
      </header>

      <div className="dt__stage">
        {slides.map((s, i) => {
          const state = i === idx ? "on" : i === prev ? "off" : "idle";
          return (
            <article
              key={i}
              className="dt__slide"
              data-state={state}
              aria-hidden={i === idx ? undefined : true}
            >
              <h1 className="dt__title">{s.title}</h1>

              <div className="dt__discs">
                {s.dishes.slice(0, 3).map((d, j) => {
                  const hasCard = Boolean(d.name || d.note);
                  return (
                    <div className="dt__slot" key={j}>
                      {/* The disc CLIPS; the card must not be clipped by it, so
                          they are siblings rather than parent and child. */}
                      <figure
                        className={j === 1 ? "dt__disc dt__disc--mid" : "dt__disc"}
                        tabIndex={hasCard ? 0 : undefined}
                      >
                        <div className="dt__burst">
                          {d.src ? (
                            <img src={d.src} alt={d.alt ?? ""} />
                          ) : (
                            <div className="dt__disc-fill" style={{ background: PLACEHOLDER[j % 3] }} />
                          )}
                        </div>
                      </figure>
                      {hasCard && (
                        <div className="dt__tip">
                          {d.name && <b>{d.name}</b>}
                          {d.note && <em>{d.note}</em>}
                        </div>
                      )}
                    </div>
                  );
                })}
                {/* The timer lives with the discs so it is always centred on the
                    middle one without measuring anything. */}
                <div className="dt__ring" ref={i === idx ? ringRef : undefined} aria-hidden="true" />
              </div>

              {s.body && <p className="dt__body">{s.body}</p>}
            </article>
          );
        })}
      </div>

      <footer className="dt__foot">
        {marker && (
          <span className="dt__marker" aria-hidden="true">
            <b>{marker[0]}</b>
            <b>{marker[1]}</b>
          </span>
        )}

        <div className="dt__controls">
          <button className="dt__arrow" onClick={() => go(idx - 1)} aria-label="Previous slide">
            <svg viewBox="0 0 24 24" aria-hidden="true">
              <path d="M15 5l-7 7 7 7" fill="none" stroke="currentColor" strokeWidth="1.8" />
            </svg>
          </button>
          {cta && (
            <a className="dt__cta" href={cta.href}>
              {cta.label}
            </a>
          )}
          <button className="dt__arrow" onClick={() => go(idx + 1)} aria-label="Next slide">
            <svg viewBox="0 0 24 24" aria-hidden="true">
              <path d="M9 5l7 7-7 7" fill="none" stroke="currentColor" strokeWidth="1.8" />
            </svg>
          </button>
        </div>

        {social && (
          <a className="dt__social" href={social.href} aria-label={social.label}>
            <svg viewBox="0 0 24 24" aria-hidden="true">
              <rect x="3" y="3" width="18" height="18" rx="5" fill="none" stroke="currentColor" strokeWidth="1.6" />
              <circle cx="12" cy="12" r="4" fill="none" stroke="currentColor" strokeWidth="1.6" />
              <circle cx="17.2" cy="6.8" r="1.2" fill="currentColor" />
            </svg>
          </a>
        )}
      </footer>
    </section>
  );
}

const CSS = `
.dt {
  --p: 0;
  /* THE PROPS ARE MAXIMA, NOT SIZES, and they have to be — they arrive as an
     inline style attribute, which outranks every media query in this
     stylesheet. Setting --disc inline directly meant the responsive rules below
     could never touch it and three 300px discs stayed 300px on a 390px phone,
     blowing the grid column out and shoving the headline off-screen. Sizing off
     the viewport instead is also simply better: the row fits at every width
     without a breakpoint deciding for it. */
  --gap: clamp(8px, 2vw, var(--gap-max));
  --disc: min(var(--disc-max), calc((100vw - 48px - 2 * var(--gap)) / 3));
  --display: 'Poppins', 'Inter', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
  position: relative;
  min-height: 100vh; min-height: 100svh;
  display: flex; flex-direction: column;
  overflow: hidden;
  background: #14110f;
  color: #fff;
  font-family: var(--display);
  -webkit-font-smoothing: antialiased;
}
.dt *, .dt *::before, .dt *::after { box-sizing: border-box; }
.dt a { color: inherit; text-decoration: none; }

/* ── Ground. Blurred and darkened, so a busy room photograph is fine: it is
      atmosphere, not subject. The scale is because blur eats its own edges. ── */
.dt__bg { position: absolute; inset: 0; }
.dt__bg-layer { position: absolute; inset: 0; }
.dt__bg-layer[data-state="idle"] { opacity: 0; }
.dt__bg-layer[data-state="off"]  { opacity: 0; }
.dt__bg-layer[data-state="on"]   { opacity: 1; }
/* Slower than the discs on purpose. The room should feel like it dissolved
   while you were looking at the food, not like a second thing that moved. */
.dt.is-motion .dt__bg-layer { transition: opacity 1500ms ease; }
.dt__bg img, .dt__bg-fill {
  width: 100%; height: 100%; object-fit: cover; display: block;
  /* scale past the frame because blur eats its own edges, and because it
     pushes a watermarked corner off the canvas. */
  transform: scale(1.08);
  filter: blur(var(--blur));
}
/* Bright enough to still read as a ROOM once the veil is over it. The first
   pass was tuned without the veil on and went to near-black the moment it was,
   which loses the one thing the background is there for — a sense of place
   behind the discs. */
.dt__bg-fill {
  background:
    radial-gradient(58% 44% at 18% 12%, #b98f5d, transparent 68%),
    radial-gradient(52% 48% at 84% 68%, #4e7f8c, transparent 70%),
    radial-gradient(44% 40% at 62% 22%, #d8b98c, transparent 66%),
    linear-gradient(158deg, #6b5844, #2b2521 72%);
}
/* A GRADIENT, NOT A FLAT WASH. The reference runs 0.35 at the top down to a
   heavier floor — read straight off its overlay layer. Flat at the average
   loses the top of the room, which is where the string lights and the ceiling
   are, and those are the whole reason for using a photograph of a room. */
.dt__veil {
  position: absolute; inset: 0;
  background: linear-gradient(
    to bottom,
    rgba(10, 9, 8, calc(var(--veil) * 0.5)) 0%,
    rgba(10, 9, 8, calc(var(--veil) * 0.95)) 52%,
    rgba(10, 9, 8, var(--veil)) 100%
  );
}

/* ── Nav ─────────────────────────────────────────────────────────────────── */
.dt__nav {
  position: relative; z-index: 3;
  display: grid; grid-template-columns: 1fr auto 1fr; align-items: center;
  gap: 24px; padding: 34px 56px 0;
}
.dt__navside { display: flex; gap: 44px; font-size: 13px; letter-spacing: 0.16em; text-transform: uppercase; }
.dt__navside--r { justify-content: flex-end; }
.dt__navside a { opacity: 0.86; }
.dt__navside a:hover { opacity: 1; }
.dt__brand { display: flex; flex-direction: column; align-items: center; gap: 3px; text-align: center; }
.dt__brand-top {
  display: inline-flex; align-items: center; gap: 9px;
  font-size: 12px; letter-spacing: 0.24em; text-transform: uppercase; opacity: 0.9;
}
.dt__brand-top i { width: 7px; height: 7px; border-radius: 50%; background: var(--accent); }
.dt__brand-main {
  font-size: 30px; font-weight: 700; letter-spacing: 0.02em; text-transform: uppercase; line-height: 1;
}

/* ── Stage. Slides are stacked; only one is on. ──────────────────────────── */
.dt__stage {
  position: relative; z-index: 2; flex: 1;
  display: grid; place-items: center;
  padding: 26px 24px 8px;
}
.dt__slide {
  grid-area: 1 / 1; min-width: 0;
  display: flex; flex-direction: column; align-items: center;
  width: 100%; max-width: 1180px;
}
.dt__title {
  /* The row swells to 1.2 mid-change, which reaches ~30px above its resting
     top. This gap is sized so the peak clears the headline rather than putting
     a bright ring through white type for a few frames. */
  margin: 0 0 38px; text-align: center;
  font-size: clamp(38px, 5vw, 62px); font-weight: 700; letter-spacing: -0.01em; line-height: 1.05;
}
.dt__body {
  margin: 38px auto 0; max-width: 54ch; text-align: center;
  font-size: 16px; line-height: 1.6; opacity: 0.84;
}

/* ── The discs. Sized by WIDTH with aspect-ratio, so the row keeps its shape
      at any --disc and the circles never go oval. ────────────────────────── */
.dt__discs { position: relative; display: flex; align-items: center; gap: var(--gap); }
/* The card overflows the row upward, so the row must not clip and must not be
   under the copy above it. */
.dt__discs { overflow: visible; }
.dt__disc {
  position: relative; margin: 0; flex: none;
  width: var(--disc); aspect-ratio: 1; border-radius: 50%; overflow: hidden;
  box-shadow: 0 26px 60px rgba(0, 0, 0, 0.45);
}
.dt__slot { position: relative; flex: none; }
.dt__disc { cursor: pointer; transition: transform 260ms cubic-bezier(.22,.61,.36,1); }
.dt__disc:hover, .dt__disc:focus-visible { transform: scale(1.04); }

/* ── THE HOVER CARD. 280 wide, 10px radius, on the accent, with a rotated
      square for the tail — read off the reference's own layers.

      It is a SIBLING of the disc, not a child, because the disc clips to a
      circle and a card inside it would be sliced into a crescent. And it is
      driven by :hover on the DISC rather than on the slot: border-radius does
      limit hit-testing, so hovering the disc means hovering the circle, while
      hovering the slot would fire in the square's dead corners where there is
      no plate.

      visibility is transitioned with a delay on the way out and none on the way
      in, which is the trick that keeps the card out of the accessibility tree
      and off the pointer while still letting opacity animate. */
.dt__tip {
  position: absolute; left: 50%; bottom: calc(100% + 18px);
  width: 280px; max-width: 76vw;
  padding: 15px 18px 16px;
  border-radius: 10px;
  background: var(--accent); color: var(--ink);
  text-align: left;
  /* It lands over the headline — the reference's does too — so it needs to read
     as floating above rather than colliding with it. */
  box-shadow: 0 18px 40px rgba(0, 0, 0, 0.45);
  transform: translate(-50%, 7px);
  opacity: 0; visibility: hidden; pointer-events: none;
  transition: opacity 220ms ease, transform 220ms cubic-bezier(.22,.61,.36,1), visibility 0s linear 220ms;
  z-index: 4;
}
.dt__disc:hover + .dt__tip,
.dt__disc:focus-visible + .dt__tip {
  opacity: 1; visibility: visible; transform: translate(-50%, 0);
  transition-delay: 0s, 0s, 0s;
}
.dt__tip::after {
  content: ""; position: absolute; left: 50%; top: 100%;
  width: 18px; height: 18px; margin: -10px 0 0 -9px;
  background: var(--accent); border-radius: 3px; transform: rotate(45deg);
}
.dt__tip b { display: block; font-size: 17px; font-weight: 700; line-height: 1.24; }
.dt__tip em {
  display: block; margin-top: 7px; font-style: normal;
  font-size: 11px; font-weight: 600; letter-spacing: 0.07em; text-transform: uppercase; opacity: 0.66;
}

.dt__burst { width: 100%; height: 100%; }
.dt__disc img, .dt__disc-fill { display: block; width: 100%; height: 100%; object-fit: cover; }

/* THE SPIN, AND IT IS OPPOSED ON PURPOSE. All three turning the same way reads
   as one turntable; the middle one against the other two reads as three objects
   with a subject in the middle. Longhands rather than the animation
   shorthand: this repo has been bitten once by a minifier folding an animation
   longhand into the shorthand and the browser dropping the whole declaration. */
@keyframes dt-spin { to { transform: rotate(360deg); } }
/* THE SPIN BURST. Two rotations composed on nested elements, because they are
   two different things: the ambient drift never stops, and on top of it each
   slide change throws the discs through very nearly a full turn.

   Measured, not invented: sampling the reference's middle disc every 90ms
   through a change gives 0 -> 24 -> 142 -> 390 -> 820 deg/sec at ~820ms and
   back to 0 by ~1365ms. Integrated, that is about one revolution in 1.4s, and
   the peak is roughly 200x the ambient rate. It is the single thing that makes
   the change feel like plates being swapped rather than pictures cross-fading.

   Both the arriving and the leaving trio get it, and the sides run it in
   reverse like their drift, so nothing in the row ever agrees with anything
   else — which is what stops three circles reading as one wheel. */
@keyframes dt-burst { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
.dt.is-motion .dt__slide[data-state="on"] .dt__burst,
.dt.is-motion .dt__slide[data-state="off"] .dt__burst {
  animation-name: dt-burst;
  animation-duration: 1400ms;
  /* SPIN AND ZOOM SHARE THIS CURVE, and that is the point of it. A harder
     pinch reaches the reference's 820 deg/sec peak, but it also leaves the
     discs almost still for the first quarter-second while the zoom has already
     set off — the two read as separate events. Symmetric and only lightly
     eased, the rotation is visibly underway from the first frames of the zoom
     in, fastest exactly where the photographs trade, and winding down through
     the zoom out. One gesture: out, over, back. */
  animation-timing-function: cubic-bezier(.5, 0, .5, 1);
  animation-fill-mode: both;
}
.dt.is-motion .dt__disc:not(.dt__disc--mid) .dt__burst { animation-direction: reverse; }

.dt.is-motion .dt__disc img,
.dt.is-motion .dt__disc-fill {
  animation-name: dt-spin;
  animation-duration: var(--spin-ms);
  animation-timing-function: linear;
  animation-iteration-count: infinite;
  animation-direction: reverse;
}
.dt.is-motion .dt__disc--mid img,
.dt.is-motion .dt__disc--mid .dt__disc-fill { animation-direction: normal; }

/* ── The timer. A conic sweep masked into a ring, which is the whole of it —
      no canvas, no rAF redraw, no resize handling. Centred on the middle disc
      by living inside the row rather than by being measured into place. ──── */
.dt__ring {
  position: absolute; left: 50%; top: 50%;
  width: calc(var(--disc) + 26px); height: calc(var(--disc) + 26px);
  transform: translate(-50%, -50%);
  border-radius: 50%;
  background: conic-gradient(var(--accent) calc(var(--p) * 360deg), rgba(255, 255, 255, 0.14) 0);
  -webkit-mask: radial-gradient(closest-side, transparent calc(100% - 3px), #000 calc(100% - 3px));
          mask: radial-gradient(closest-side, transparent calc(100% - 3px), #000 calc(100% - 3px));
  pointer-events: none;
}
.dt:not(.is-motion) .dt__ring { display: none; }

/* ── The change. The discs DO NOT travel: the trio swells to about 1.22 and
      settles while the images cross-fade under it, which is what makes it read
      as a swell rather than as a carousel. Copy leaves upward and arrives from
      below, offset from the discs so the two do not compete. ─────────────── */
/* THE OUTGOING SLIDE MUST STAY VISIBLE WHILE IT LEAVES. This used to drop to
   opacity 0 the instant its state changed, which killed the old content before
   its own out-animation could play — that is the blink. Nothing here fades the
   slide as a whole any more; every child owns its own exit, and the slide is
   just a container that stops taking clicks. Its children end their animations
   at opacity 0 with fill-mode both, so it leaves nothing behind. */
.dt__slide[data-state="idle"] { opacity: 0; pointer-events: none; visibility: hidden; }
.dt__slide[data-state="on"]   { opacity: 1; }
.dt__slide[data-state="off"]  { opacity: 1; pointer-events: none; }

/* THE SWAP HAPPENS AT PEAK SPIN, AND THAT IS THE WHOLE POINT OF THE SEQUENCE.
   The old plate spins up and grows; at the top of the move — fastest rotation,
   largest scale — the photograph changes over ~110ms, which at roughly 800
   deg/sec is not readable as a fade; then the new plate spins down and settles.
   One object turning, not two objects dissolving.

   Both trios sit at exactly scale 1.2 across the 42-54% crossover, and both bursts
   started on the same frame with the same easing, so the two are at the same
   size AND the same angle at the moment they trade places. Move either
   keyframe without moving the other and the swap steps sideways. */
@keyframes dt-discs-in {
  0%   { transform: scale(0.94); opacity: 0; }
  42%  { opacity: 0; }
  54%  { transform: scale(1.2);  opacity: 1; }
  100% { transform: scale(1);    opacity: 1; }
}
@keyframes dt-discs-out {
  0%   { transform: scale(1);    opacity: 1; }
  42%  { opacity: 1; }
  54%  { transform: scale(1.2);  opacity: 0; }
  100% { transform: scale(0.94); opacity: 0; }
}
@keyframes dt-copy-in  { from { transform: translateY(26px); opacity: 0; } to { transform: none; opacity: 1; } }
@keyframes dt-copy-out { from { transform: none; opacity: 1; } to { transform: translateY(-30px); opacity: 0; } }

.dt.is-motion .dt__slide[data-state="on"] .dt__discs {
  animation-name: dt-discs-in; animation-duration: 1400ms;
  animation-timing-function: cubic-bezier(.5, 0, .5, 1); animation-fill-mode: both;
}
.dt.is-motion .dt__slide[data-state="off"] .dt__discs {
  animation-name: dt-discs-out; animation-duration: 1400ms;
  animation-timing-function: cubic-bezier(.5, 0, .5, 1); animation-fill-mode: both;
}
.dt.is-motion .dt__slide[data-state="on"] .dt__title,
.dt.is-motion .dt__slide[data-state="on"] .dt__body {
  animation-name: dt-copy-in; animation-duration: 620ms; animation-delay: 700ms;
  animation-timing-function: cubic-bezier(.22,.61,.36,1); animation-fill-mode: both;
}
.dt.is-motion .dt__slide[data-state="off"] .dt__title,
.dt.is-motion .dt__slide[data-state="off"] .dt__body {
  animation-name: dt-copy-out; animation-duration: 560ms;
  animation-timing-function: cubic-bezier(.4,0,1,1); animation-fill-mode: both;
}

/* ── Foot ────────────────────────────────────────────────────────────────── */
.dt__foot {
  position: relative; z-index: 3;
  display: grid; grid-template-columns: 1fr auto 1fr; align-items: center;
  gap: 24px; padding: 8px 56px 44px;
}
.dt__marker { display: flex; flex-direction: column; line-height: 1.06; font-size: 19px; font-weight: 600; opacity: 0.75; }
.dt__controls { display: flex; align-items: center; gap: 18px; }
.dt__arrow {
  width: 58px; height: 58px; flex: none; border-radius: 50%;
  border: 1px solid rgba(255, 255, 255, 0.24); background: rgba(255, 255, 255, 0.09);
  color: #fff; cursor: pointer; display: grid; place-items: center;
  transition: background 180ms ease, border-color 180ms ease;
}
.dt__arrow:hover { background: rgba(255, 255, 255, 0.18); border-color: rgba(255, 255, 255, 0.4); }
.dt__arrow svg { width: 22px; height: 22px; }
.dt__cta {
  display: inline-flex; align-items: center; min-height: 58px; padding: 0 34px;
  border-radius: 999px; background: var(--accent); color: var(--ink);
  font-size: 13px; font-weight: 700; letter-spacing: 0.14em; text-transform: uppercase;
  transition: filter 180ms ease;
}
.dt__cta:hover { filter: brightness(1.08); }
.dt__social { justify-self: end; opacity: 0.8; }
.dt__social svg { width: 27px; height: 27px; display: block; }
.dt__social:hover { opacity: 1; }

.dt :focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }

@media (max-width: 1080px) {
  .dt__nav, .dt__foot { padding-left: 24px; padding-right: 24px; }
  .dt__navside { gap: 24px; }
}
@media (max-width: 720px) {
  .dt__navside { display: none; }
  .dt__nav { grid-template-columns: 1fr; justify-items: center; }
  .dt__foot { grid-template-columns: 1fr; justify-items: center; gap: 18px; }
  .dt__marker, .dt__social { display: none; }
}

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

CLI Install

npx innovations add disc-trio

Where to use it

Use this for restaurants, bakeries, coffee roasters, bars and menus — and for anything else whose subject is ROUND or crops happily to a circle: plated food, watches, candles, ceramics, wheels, lenses, cosmetics jars. It is the wrong hero for a subject with corners. A building, a laptop, a business card or a person standing up all lose their meaning inside a circle, and no amount of object-position saves them. In Astro: --- import DiscTrioHero from '../components/innovations/heroes/disc-trio'; --- <DiscTrioHero client:load /> Needs client:load or client:visible. Server-rendered alone you get the first slide, whole and readable, with working links — see the static note at the end. WHY IT IS PLAIN CSS AND NOT TAILWIND. Every component in this registry carries its own stylesheet in a template literal so that 'innovations add disc-trio' drops ONE file into any project and it looks right — Tailwind or not, v3 or v4, whatever the host's config says about container widths and font stacks. That portability is the point of the registry. Style it by changing the props and the custom properties at the top of the CSS, not by rewriting it into utility classes. THE THREE THINGS THAT MAKE IT LOOK LIKE THE REFERENCE, each measured off the live DOM rather than guessed: 1. THE DISCS COUNTER-ROTATE. Middle clockwise at 4 deg/sec, outer two anticlockwise at the same rate — sampled off the live transforms over two seconds. Spin them all the same way and it reads as one turntable; opposed, it reads as three objects with a subject in the middle. The 'spin' prop is degrees per second and 4 is the reference. Above about 10 it stops being ambient and starts being a fidget spinner. 2. THE YELLOW ARC IS THE AUTOPLAY TIMER, not an ornament. The original draws it on a canvas; here it is a conic-gradient masked into a ring, which needs no canvas, no redraw loop and no resize handling. It lives INSIDE the disc row rather than being positioned against the stage, which is what keeps it centred on the middle disc at every breakpoint without measuring anything. Set interval={0} and the autoplay and the ring both go. 3. THE DISCS DO NOT SLIDE. All three hold their x positions through the change; the trio scales up to about 1.22 and settles while the images cross-fade under it. Sampled at 110ms: outgoing 324 -> 395 -> 280, incoming 280 -> 393 -> 300, opacity crossing near the peak. Give in and animate them left-to-right and you have an ordinary carousel — the swell is the entire character of this hero. FOUR MORE THINGS THAT ARE IN THE REFERENCE AND EASY TO MISS: · A SPIN BURST ON EVERY CHANGE. On top of the ambient drift, each slide change throws the discs through very nearly a full turn. Sampling the reference's middle disc every 90ms gives 0 -> 24 -> 142 -> 390 -> 820 deg/sec peaking at ~820ms and back to 0 by ~1365ms — about one revolution in 1.4s, and a peak roughly 200x the ambient rate. It is the single thing that makes a change feel like plates being swapped rather than pictures cross-fading. Built as two rotations on nested elements, because they are two different things and the ambient one must not stop. · THE BACKGROUND CHANGES PER SLIDE and cross-fades, slower than the discs (1.5s) so the room feels like it dissolved while you were looking at the food rather than like a second thing that moved. Put it on the slide as 'background'; the component-level prop is the fallback. · A HOVER CARD ON EACH DISC — 280 wide, 10px radius, on the accent, dish name over a dietary line, with a rotated square for the tail. Give a dish a 'name' and/or a 'note' and it appears; omit both and no card is rendered. Two implementation notes worth keeping: it is a SIBLING of the disc rather than a child, because the disc clips to a circle and a card inside it comes out a crescent; and it is driven by :hover on the DISC, because border-radius limits hit-testing, so that means the circle, whereas hovering the slot would fire in the square's dead corners where there is no plate. The discs are focusable so the cards are reachable from the keyboard. · THE VEIL IS A GRADIENT, not a flat wash — 0.35 at the top down to a heavier floor, read off the reference's own overlay. Flat at the average loses the top of the room, which is where the lights and the ceiling are, and those are the reason for using a photograph of a room at all. The copy is offset from the discs on purpose: it leaves UPWARD by 30px and arrives from below, 260ms after the discs start. Two things moving on the same beat in the same direction read as one heavy lump. GIVE IT EXACTLY THREE DISHES PER SLIDE. The timer ring is centred on the row, so with two or four it lands between discs instead of around one. The component slices to three; it does not rearrange itself for other counts, and that is deliberate rather than unfinished. TRIM THE PLATE TO ITS RIM BEFORE YOU USE IT. Food photography arrives as a plate floating in a white studio margin, and the circular mask cuts at the edge of the SQUARE — so an untrimmed shot shows a pale ring of leftover background between the plate and the mask, against a dark room, which looks like a rendering fault. Trim to the rim, square it off, then let the mask land on the plate edge. The demo dishes were processed exactly that way.\n\nPhotographs: square, and shot or cropped so the subject survives a circular mask — centre it and leave air at the corners, because the corners are what the mask takes. Pass src: null on any dish for a placeholder disc, which is on-palette and needs no asset, but this hero is three photographs in a row and placeholders cannot carry it. The background is blurred and darkened, so a busy room shot is exactly right there — it is atmosphere, not subject. Autoplay pauses on pointer-enter, when the tab is hidden, and when the hero scrolls out of view, and the ring tracks the real remaining time rather than restarting — a timer running against a carousel nobody is watching makes the ring a lie the moment they look back. THE STATIC STATE IS THE DEFAULT. Rotation, autoplay and the swell all live under '.dt.is-motion', which the effect adds LAST. With no JS, a thrown error, or prefers-reduced-motion you get the first slide whole, with working links and a working CTA. Under reduced motion the arrows still change slides — they just do it instantly, because taking someone's navigation away is not an accessibility win.