# Showreel wheel hero

> A hero with your work on a turning film wheel: drag, swipe or use the keys to spin it, with a live caption and a scrolling client row.

A shadcn/ui block from the [@craftui](https://www.craftui.space/?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/craftui/hero-section-02).

- Page, with a live preview: https://shadcn.exchange/r/craftui/hero-section-02
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/craftui/hero-section-02-light-518ba0f5d1096427.png
- Registry item: https://www.craftui.space/r/hero-section-02.json
- Categories: portfolio
- Listed: 2026-10-24, source last changed 2026-10-24

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/craftui/hero-section-02.json
```

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

## Dependencies

- npm packages: `lucide-react`, `motion`

## Files

### `registry/sections/hero-section-02.tsx`

```tsx
import { ArrowRight } from "lucide-react";
import type { ComponentProps, CSSProperties, ReactNode } from "react";

import { cn } from "@/lib/utils";

import {
  AirtableMark,
  AsanaMark,
  AtlassianMark,
  CloudflareMark,
  FigmaMark,
  GitLabMark,
  InstagramMark,
  LinkedInMark,
  SlackMark,
  SupabaseMark,
  WebflowMark,
  XMark,
  YouTubeMark,
} from "./hero-section-02-logos";
import { LAYOUT, NARROW, WIDE } from "./hero-section-02-shared";
import type {
  ShowreelCategory,
  ShowreelReel,
  ShowreelViews,
} from "./hero-section-02-shared";
import { ShowreelWheel } from "./hero-section-02-wheel";

export type {
  ShowreelCategory,
  ShowreelReel,
  ShowreelViews,
} from "./hero-section-02-shared";

/* ─────────────────────────────────────────────────────────────────────────
 * Showreel hero. A server component: the copy, actions and client marks
 * render as plain HTML, and only the wheel (hero-section-02-wheel.tsx) and
 * its caption hydrate.
 *
 * Layout is CSS only, so the server's HTML is already in its final place:
 *
 *   --reel-s      the wheel's scale, stepped by container width
 *   @5xl          which wheel shows: WIDE beside the copy, or NARROW lying
 *                 across the top (both have 16:9 frames on screen)
 *
 * The copy column starts where the wheel's geometry says (LAYOUT, in design
 * units, times --reel-s), so the spokes band the copy instead of running
 * through it. Every size follows the section's own width (container
 * queries), not the viewport, so it's right in a sidebar layout or a
 * preview pane too.
 * ───────────────────────────────────────────────────────────────────────── */

/* Keyframes the utilities can't express, rendered as a <style> inside the
 * section (nothing goes in globals.css), and all of it off for reduced
 * motion. */
const CSS = `
@keyframes showreel-spin-in { from { opacity: 0; transform: rotate(-14deg); } }
@keyframes showreel-rise { from { opacity: 0; transform: translateY(12px); filter: blur(4px); } }
@keyframes showreel-marquee { to { transform: translateX(-50%); } }
.showreel-drum { animation: showreel-spin-in 1.6s cubic-bezier(0.16, 1, 0.3, 1) both; }
.showreel-rise { animation: showreel-rise 0.9s cubic-bezier(0.16, 1, 0.3, 1) both; animation-delay: calc(var(--i, 0) * 100ms + 250ms); }
.showreel-marquee { animation: showreel-marquee 36s linear infinite; }
.showreel-logos:hover .showreel-marquee { animation-play-state: paused; }
@media (prefers-reduced-motion: reduce) {
  .showreel-drum, .showreel-rise, .showreel-marquee { animation: none; }
}
`;

export interface ShowreelAction {
  href: string;
  label: string;
}

export interface ShowreelLogo {
  name: string;
  /** The client's mark, shown before the name at 20px. */
  logo?: ReactNode;
}

export interface ShowreelHeroProps extends Omit<
  ComponentProps<"section">,
  "title"
> {
  eyebrow?: ReactNode;
  title?: ReactNode;
  /** A second line, in the muted colour. */
  titleMuted?: ReactNode;
  description?: ReactNode;
  primaryAction?: ShowreelAction | null;
  secondaryAction?: ShowreelAction | null;
  logosLabel?: string;
  /** Scroll by in a loop under the actions. Pass [] to hide the row. */
  logos?: ShowreelLogo[];
  reels?: ShowreelReel[];
  /** Names for the reels' `category` ids, shown above each description. */
  categories?: ShowreelCategory[];
  initialIndex?: number;
  /** Advances while the section is on screen and nobody's using it. */
  autoplay?: boolean;
  /** Milliseconds between autoplay steps. */
  autoplayInterval?: number;
  /** 1 when this is the page's hero; 2 when the page has its own h1. */
  headingLevel?: 1 | 2;
}

/* ── Focus ────────────────────────────────────────────────────────────────
 * Four stacked backdrop blurs, 1 → 8px. Each is masked by an eased ramp that
 * starts further from the crest than the one before, so the crest frame and
 * its caption stay sharp and the blur builds smoothly outward instead of
 * stepping. Distances are design units past the edge of the crest's own
 * band (half a frame), scaled by --reel-s like the wheel. */
const BLUR_LAYERS = [
  { blur: 1, from: 0, to: 70 },
  { blur: 2, from: 40, to: 150 },
  { blur: 4, from: 110, to: 260 },
  { blur: 8, from: 200, to: 400 },
];

/** Sine ease-in-out, sampled at nine points: enough that no stop shows. */
const EASE = Array.from({ length: 9 }, (_, i) => i / 8);
const ease = (t: number) => (1 - Math.cos(Math.PI * t)) / 2;

const focusMask = (
  direction: "to bottom" | "to right",
  band: number,
  from: number,
  to: number
) => {
  const start = band + from;
  const end = band + to;
  // Opacity at each eased step, d design units from the crest line.
  const stops = EASE.map((t) => ({
    alpha: ease(t).toFixed(3),
    d: start + (end - start) * t,
  }));
  const before = stops
    .toReversed()
    .map(
      ({ alpha, d }) =>
        `rgb(0 0 0 / ${alpha}) calc(50% - ${d}px * var(--reel-s))`
    );
  const after = stops.map(
    ({ alpha, d }) => `rgb(0 0 0 / ${alpha}) calc(50% + ${d}px * var(--reel-s))`
  );
  return `linear-gradient(${direction}, #000 0%, ${before.join(", ")}, ${after.join(", ")}, #000 100%)`;
};

const FocusBlur = ({
  band,
  className,
  direction,
}: {
  band: number;
  className: string;
  direction: "to bottom" | "to right";
}) => (
  <div
    aria-hidden
    className={cn("pointer-events-none absolute z-[25]", className)}
  >
    {BLUR_LAYERS.map((layer) => {
      const mask = focusMask(direction, band, layer.from, layer.to);
      return (
        <div
          key={layer.blur}
          className="absolute inset-0"
          style={{
            WebkitBackdropFilter: `blur(${layer.blur}px)`,
            WebkitMaskImage: mask,
            backdropFilter: `blur(${layer.blur}px)`,
            maskImage: mask,
          }}
        />
      );
    })}
  </div>
);

const unsplash = (id: string) =>
  `https://images.unsplash.com/photo-${id}?auto=format&fit=crop&w=480&q=75`;

const PLATFORMS = {
  instagram: { icon: <InstagramMark />, platform: "Instagram" },
  linkedin: { icon: <LinkedInMark />, platform: "LinkedIn" },
  x: { icon: <XMark />, platform: "X" },
  youtube: { icon: <YouTubeMark />, platform: "YouTube" },
};

type Platform = keyof typeof PLATFORMS;

/** `views(["youtube", "1.2M"], ["x", "88K"])`: biggest first reads best. */
const views = (...counts: [Platform, string][]): ShowreelViews[] =>
  counts.map(([platform, value]) => ({ ...PLATFORMS[platform], value }));

const DEFAULT_CATEGORIES: ShowreelCategory[] = [
  { id: "launch", label: "Launch film" },
  { id: "feature", label: "Feature video" },
  { id: "demo", label: "Product demo" },
  { id: "teaser", label: "Teaser" },
];

const DEFAULT_REELS: ShowreelReel[] = [
  {
    alt: "Analytics dashboard with line and bar charts on a dark screen",
    category: "launch",
    description:
      "The main launch film for an analytics platform's 2.0 release.",
    src: unsplash("1551288049-bebda4e38f71"),
    views: views(["youtube", "1.2M"], ["x", "520K"], ["linkedin", "410K"]),
  },
  {
    alt: "Laptop on a glass desk showing a finance dashboard",
    category: "launch",
    description:
      "A launch film for a finance dashboard, with a cut for each channel.",
    src: unsplash("1460925895917-afdab827c52f"),
    views: views(["linkedin", "1.1M"], ["youtube", "860K"], ["x", "190K"]),
  },
  {
    alt: "Retro consoles and keyboards lit in pink and blue",
    category: "launch",
    description: "The launch trailer for an indie game studio's first title.",
    src: unsplash("1550745165-9bc0b252726f"),
    views: views(["youtube", "2.4M"], ["instagram", "640K"], ["x", "310K"]),
  },
  {
    alt: "Design tool open on a large monitor with a website layout",
    category: "feature",
    description: "A feature video introducing AI layouts in a design tool.",
    src: unsplash("1559028012-481c04fa702d"),
    views: views(["youtube", "340K"], ["linkedin", "96K"], ["x", "72K"]),
  },
  {
    alt: "Code editor on a laptop screen in a dark room",
    category: "feature",
    description: "A feature video showing deploys straight from the terminal.",
    src: unsplash("1555066931-4365d14bab8c"),
    views: views(["youtube", "210K"], ["x", "150K"], ["linkedin", "48K"]),
  },
  {
    alt: "Hand holding a phone in front of an open laptop",
    category: "feature",
    description: "A feature video for a new mobile app, filmed on the phone.",
    src: unsplash("1563986768609-322da13575f3"),
    views: views(["instagram", "1.8M"], ["youtube", "420K"], ["x", "96K"]),
  },
  {
    alt: "Hand sketching app wireframes on paper",
    category: "demo",
    description:
      "A product demo that walks new users through their first project.",
    src: unsplash("1581291518857-4e27b48ff24e"),
    views: views(["youtube", "120K"], ["linkedin", "35K"]),
  },
  {
    alt: "Team working on laptops around a wooden table",
    category: "demo",
    description: "A product demo of a team workspace, made for sales calls.",
    src: unsplash("1522071820081-009f0129c71c"),
    views: views(["linkedin", "140K"], ["youtube", "88K"]),
  },
  {
    alt: "Half-open laptop glowing in the dark",
    category: "demo",
    description:
      "A product demo of a writing tool, cut into short help videos.",
    src: unsplash("1531297484001-80022131f5a1"),
    views: views(["youtube", "64K"], ["x", "22K"]),
  },
  {
    alt: "Clapperboard held up against a desert sky",
    category: "teaser",
    description: "A short teaser released the week before launch.",
    src: unsplash("1485846234645-a62644f84728"),
    views: views(["youtube", "410K"], ["instagram", "260K"]),
  },
  {
    alt: "Camera body and two lenses on a dark surface",
    category: "teaser",
    description: "Three teasers that built a waitlist ahead of launch day.",
    src: unsplash("1516035069371-29a1b244cc32"),
    views: views(["instagram", "120K"], ["youtube", "88K"]),
  },
  {
    alt: "Video editing timeline on a monitor",
    category: "teaser",
    description: "A countdown teaser cut from the main launch film.",
    src: unsplash("1574717024653-61fd2cf4d44d"),
    views: views(["youtube", "120K"], ["x", "54K"], ["linkedin", "31K"]),
  },
];

const DEFAULT_LOGOS: ShowreelLogo[] = [
  { logo: <FigmaMark />, name: "Figma" },
  { logo: <SlackMark />, name: "Slack" },
  { logo: <SupabaseMark />, name: "Supabase" },
  { logo: <CloudflareMark />, name: "Cloudflare" },
  { logo: <AtlassianMark />, name: "Atlassian" },
  { logo: <AirtableMark />, name: "Airtable" },
  { logo: <AsanaMark />, name: "Asana" },
  { logo: <GitLabMark />, name: "GitLab" },
  { logo: <WebflowMark />, name: "Webflow" },
];

/** The marks, twice over, travelling exactly -50% so the loop has no seam. */
const LogoMarquee = ({
  label,
  logos,
}: {
  label: string;
  logos: ShowreelLogo[];
}) => (
  <div
    className="showreel-rise mt-10 flex w-full flex-col items-center @5xl:items-start"
    style={{ "--i": 6 } as CSSProperties}
  >
    <p className="text-muted-foreground text-xs font-medium tracking-[0.14em] uppercase">
      {label}
    </p>
    <div
      aria-hidden
      className="showreel-logos relative mt-4 w-full overflow-hidden [mask-image:linear-gradient(90deg,transparent,#000_8%,#000_92%,transparent)]"
    >
      <div className="showreel-marquee flex w-max items-center">
        {[0, 1].map((copy) =>
          logos.map((logo) => (
            <span
              key={`${copy}-${logo.name}`}
              className="text-muted-foreground flex items-center gap-2 pr-10 text-[15px] font-semibold tracking-tight whitespace-nowrap [&_svg]:size-5 [&_svg]:shrink-0"
            >
              {logo.logo}
              {logo.name}
            </span>
          ))
        )}
      </div>
    </div>
    <p className="sr-only">
      {label}: {logos.map((logo) => logo.name).join(", ")}.
    </p>
  </div>
);

/* Plain links styled with the theme's tokens rather than <Button asChild>:
 * they're server-rendered, and need no client code to be links. */
/* Both actions: 44px tall on phones and 48px from @lg (over the 40px hit
 * area either way), and a 0.96 press so a click feels physical.
 * Transitions name their properties. */
const ACTION =
  "focus-visible:ring-ring/50 inline-flex h-11 items-center justify-center gap-2 rounded-xl text-sm font-medium @lg:h-12 @lg:text-[15px] whitespace-nowrap outline-none transition-[translate,scale,background-color,color,box-shadow,filter] duration-200 ease-out focus-visible:ring-[3px] active:translate-y-0 active:scale-[0.96] motion-reduce:active:scale-100 [&_svg]:size-4 [&_svg]:shrink-0";

/* The primary action, lit from above: a soft white sheen over the brand
 * colour, a hairline edge a shade darker than the fill, a white highlight
 * on the top inner edge and a faint shade on the bottom one. No drop shadow
 * or glow: it sits flat on the page. Hover brightens and lifts it; press
 * sets it down into its own shadow. */
const PRIMARY_ACTION = cn(
  // The arrow side gets 2px less padding, so the label doesn't look pushed
  // left by the icon's empty space.
  "group bg-primary text-primary-foreground pr-3.5 pl-4 @lg:pr-5.5 @lg:pl-6 [text-shadow:0_1px_0_rgb(0_0_0/0.18)]",
  "bg-linear-to-b from-white/20 to-white/0",
  "shadow-[0_0_0_1px_color-mix(in_oklab,var(--primary)_72%,black),inset_0_1px_0_0_rgb(255_255_255/0.35),inset_0_-1px_0_0_rgb(0_0_0/0.16)]",
  "hover:-translate-y-px hover:brightness-110",
  "hover:shadow-[0_0_0_1px_color-mix(in_oklab,var(--primary)_72%,black),inset_0_1px_0_0_rgb(255_255_255/0.4),inset_0_-1px_0_0_rgb(0_0_0/0.16)]",
  "active:brightness-95 active:shadow-[0_0_0_1px_color-mix(in_oklab,var(--primary)_72%,black),inset_0_1px_3px_0_rgb(0_0_0/0.25)]"
);

/* The secondary action is edged with a shadow, not a border: a 1px ring,
 * a little lift and some ambient depth on light pages, a single white ring
 * on dark ones, where depth shadows don't show. Shadows sit on any
 * background; a solid border only suits the one it was picked for. */
const SECONDARY_ACTION = cn(
  "bg-background hover:bg-accent hover:text-accent-foreground px-4 @lg:px-5",
  "shadow-[0_0_0_1px_rgba(0,0,0,0.08),0_1px_2px_-1px_rgba(0,0,0,0.08),0_2px_4px_0_rgba(0,0,0,0.04)]",
  "hover:shadow-[0_0_0_1px_rgba(0,0,0,0.12),0_1px_2px_-1px_rgba(0,0,0,0.1),0_2px_4px_0_rgba(0,0,0,0.06)]",
  "dark:bg-input/30 dark:hover:bg-input/50 dark:shadow-[0_0_0_1px_rgba(255,255,255,0.1)] dark:hover:shadow-[0_0_0_1px_rgba(255,255,255,0.16)]"
);

/** The primary action carries the brand colour; the secondary stays quiet. */
const Actions = ({
  primary,
  secondary,
}: {
  primary?: ShowreelAction | null;
  secondary?: ShowreelAction | null;
}) =>
  primary || secondary ? (
    <div
      className="showreel-rise mt-8 flex flex-wrap items-center justify-center gap-2 @lg:mt-9 @lg:gap-3 @5xl:justify-start"
      style={{ "--i": 5 } as CSSProperties}
    >
      {primary ? (
        <a className={cn(ACTION, PRIMARY_ACTION)} href={primary.href}>
          {primary.label}
          <ArrowRight className="transition-transform duration-200 ease-out group-hover:translate-x-0.5" />
        </a>
      ) : null}
      {secondary ? (
        <a className={cn(ACTION, SECONDARY_ACTION)} href={secondary.href}>
          {secondary.label}
        </a>
      ) : null}
    </div>
  ) : null;

export const ShowreelHero = ({
  autoplay = true,
  autoplayInterval = 5000,
  categories = DEFAULT_CATEGORIES,
  className,
  description = "We script, film and edit launch videos for software products, from the main film to short cuts for every channel.",
  eyebrow,
  headingLevel = 1,
  initialIndex = 0,
  logos = DEFAULT_LOGOS,
  logosLabel = "Trusted by",
  primaryAction = { href: "#contact", label: "Book a call" },
  reels = DEFAULT_REELS,
  secondaryAction = { href: "#process", label: "See how we work" },
  style,
  title = "Product launch videos",
  titleMuted = "that show what you built.",
  ...props
}: ShowreelHeroProps) => {
  const Heading = headingLevel === 2 ? "h2" : "h1";

  return (
    <section
      className={cn(
        // antialiased here, not only at the app root: a project that installs
        // just this block gets the same crisp text on macOS.
        "bg-background text-foreground @container relative isolate w-full overflow-clip antialiased",
        className
      )}
      data-slot="showreel-hero"
      style={
        {
          "--reel-caption-w": `${LAYOUT.captionWidth}px`,
          "--reel-copy-x": `${LAYOUT.copyX}px`,
          "--reel-narrow-step": `${NARROW.config.tangential}px`,
          "--reel-rim": `${LAYOUT.narrowRim}px`,
          ...style,
        } as CSSProperties
      }
      {...props}
    >
      <style>{CSS}</style>

      <div
        className={cn(
          // Narrow: the wheel above (mirrored), caption and copy below it.
          "relative flex flex-col pt-[calc(var(--reel-rim)*var(--reel-s)+7rem)] pb-14 @lg:pb-16",
          "[--reel-s:0.8] @3xl:[--reel-s:0.86]",
          // Wide: the wheel on the left edge, the copy beside it.
          "@5xl:min-h-[44rem] @5xl:flex-row @5xl:items-center @5xl:py-20 @7xl:min-h-[48rem]",
          "@5xl:[--reel-s:0.74] @6xl:[--reel-s:0.82] @7xl:[--reel-s:0.9] @[90rem]:[--reel-s:1]"
        )}
      >
        <ShowreelWheel
          autoplay={autoplay}
          autoplayInterval={autoplayInterval}
          categories={categories}
          initialIndex={initialIndex}
          label="Showreel"
          reels={reels}
        />
        {/* Wide: over the wheel and its captions, stopping short of the
            copy. Narrow: over the strip, running sideways. */}
        <FocusBlur
          band={WIDE.config.tangential / 2}
          className="inset-y-0 left-0 hidden w-[calc(var(--reel-copy-x)*var(--reel-s)-2rem)] @5xl:block"
          direction="to bottom"
        />
        <FocusBlur
          band={NARROW.config.tangential / 2}
          className="inset-x-0 top-0 h-[calc(var(--reel-rim)*var(--reel-s)+7rem)] @5xl:hidden"
          direction="to right"
        />

        {/* min-w-0: the looping logo track is thousands of pixels wide, and
            without it that width would hold the column open past the edge. */}
        <div className="relative z-30 flex min-w-0 flex-1 justify-center px-6 pt-8 @lg:pt-10 @5xl:justify-start @5xl:pt-0 @5xl:pr-12 @5xl:pl-0 @5xl:ml-[calc(var(--reel-copy-x)*var(--reel-s))]">
          <div className="flex w-full max-w-xl flex-col items-center text-center @5xl:max-w-[34rem] @5xl:items-start @5xl:text-left">
            {eyebrow ? (
              <p
                className="showreel-rise text-primary text-xs font-medium tracking-[0.16em] uppercase"
                style={{ "--i": 2 } as CSSProperties}
              >
                {eyebrow}
              </p>
            ) : null}

            {/* One sentence, the muted half inline, so text-balance can split
                it into two even lines. The size follows the column (about
                1/11.6 of its width, the full width on narrow stages and the
                space beside the wheel on wide ones), which keeps the default
                title to two lines at every size. */}
            <Heading
              className={cn(
                "showreel-rise text-[clamp(1.625rem,7.4cqw,3rem)] leading-[1.08] font-semibold tracking-[-0.025em] text-balance @5xl:text-[clamp(1.75rem,2.9cqw,2.875rem)]",
                eyebrow ? "mt-5" : null
              )}
              style={{ "--i": 3 } as CSSProperties}
            >
              {title}
              {titleMuted ? (
                <>
                  {" "}
                  <span className="text-muted-foreground">{titleMuted}</span>
                </>
              ) : null}
            </Heading>

            {description ? (
              <p
                className="showreel-rise text-muted-foreground mt-5 max-w-[30rem] text-[15px] leading-relaxed text-pretty @lg:mt-6 @lg:text-base"
                style={{ "--i": 4 } as CSSProperties}
              >
                {description}
              </p>
            ) : null}

            <Actions primary={primaryAction} secondary={secondaryAction} />

            {logos.length > 0 ? (
              <LogoMarquee label={logosLabel} logos={logos} />
            ) : null}
          </div>
        </div>
      </div>
    </section>
  );
};

export default ShowreelHero;
```

### `registry/sections/hero-section-02-wheel.tsx`

```tsx
"use client";

import { ArrowUpRight } from "lucide-react";
import {
  animate,
  motion,
  useInView,
  useMotionValue,
  useMotionValueEvent,
  useReducedMotion,
  useTransform,
} from "motion/react";
import type {
  AnimationPlaybackControls,
  MotionValue,
  Transition,
} from "motion/react";
import { useCallback, useEffect, useId, useRef, useState } from "react";
import type { KeyboardEvent, PointerEvent } from "react";

import { cn } from "@/lib/utils";

import { NARROW, SPAN, WIDE } from "./hero-section-02-shared";
import type {
  ShowreelCategory,
  ShowreelReel,
  Wheel,
} from "./hero-section-02-shared";

/* The wheel itself: frames, rims and their captions. Both wheels (WIDE
 * beside the copy, NARROW mirrored above it) are rendered, and a container
 * query shows one, so the server's HTML is already right at any width. Each
 * is drawn in design units inside an anchor the section scales (--reel-s).
 *
 * One motion value, `position`, drives every frame: a float index that can
 * run past the ends. Each frame's offset is its index minus `position`,
 * wrapped into the ring, so the strip is an endless loop. A frame only
 * jumps ends where it's fully faded out, never across the visible arc. */

const SPRING: Transition = {
  bounce: 0.12,
  type: "spring",
  visualDuration: 0.7,
};

/** Fewer reels than this are repeated round the ring so it never has gaps. */
const MIN_FRAMES = 12;

/** Spokes per side of the crest, and their length. */
const FAN = 7;
const FAN_LEN = 1600;

/** Pixels a press can wander before it becomes a drag. */
const TAP_SLOP = 6;

/** Seconds of release velocity projected forward when a drag is flung. */
const FLING = 0.16;

const KEY_STEPS: Record<string, number> = {
  ArrowDown: 1,
  ArrowLeft: -1,
  ArrowRight: 1,
  ArrowUp: -1,
};

const mod = (value: number, n: number) => ((value % n) + n) % n;

/** `value` wrapped into [-n/2, n/2): a signed distance round the ring. */
const wrap = (value: number, n: number) => mod(value + n / 2, n) - n / 2;

/**
 * Frames recede with distance from the crest, and are fully gone by the
 * point where they wrap to the other end.
 */
const frameOpacity = (offset: number, count: number) => {
  const away = Math.abs(offset);
  const depth = 1 - Math.min(away / 8, 1) * 0.7;
  const edge = Math.min(Math.max((count / 2 - away) / 1.25, 0), 1);
  return depth * edge;
};

/* The bezel is the next ring out: foreground mixed into the page colour, so
 * it reads as a darker band on light and a lighter one on dark. The first
 * stop is the crease where bezel meets picture. */
const bezelFill = (lift: number) =>
  `linear-gradient(90deg, color-mix(in oklab, var(--foreground) 28%, transparent) 0 3px, color-mix(in oklab, var(--foreground) ${lift}%, var(--background)) 3px, color-mix(in oklab, var(--foreground) ${lift * 0.62}%, var(--background)) 62%, color-mix(in oklab, var(--foreground) ${lift * 0.4}%, var(--background)) 100%)`;
const BEZEL_FILL = bezelFill(7);
const BEZEL_FILL_CREST = bezelFill(11);

interface FrameProps {
  count: number;
  flip: boolean;
  index: number;
  isCrest: boolean;
  onSelect: (index: number) => void;
  position: MotionValue<number>;
  reel: ShowreelReel;
  setRef: (element: HTMLButtonElement | null) => void;
  wheel: Wheel;
}

const Frame = ({
  count,
  flip,
  index,
  isCrest,
  onSelect,
  position,
  reel,
  setRef,
  wheel,
}: FrameProps) => {
  const { at, bezel, bezelAt, pic, step } = wheel;
  const offset = useTransform(position, (p) => wrap(index - p, count));
  const angle = useTransform(offset, (o) => o * step);
  const x = useTransform(angle, (a) => at(a, pic.rc).x);
  const y = useTransform(angle, (a) => at(a, pic.rc).y);
  const opacity = useTransform(offset, (o) => frameOpacity(o, count));
  const shade = useTransform(offset, (o) => Math.min(Math.abs(o) * 0.08, 0.45));
  const zIndex = useTransform(offset, (o) => 40 - Math.round(Math.abs(o)));

  return (
    <motion.button
      ref={setRef}
      aria-current={isCrest || undefined}
      aria-label={reel.description}
      className={cn(
        "group/frame absolute top-0 left-0 cursor-grab outline-none select-none active:cursor-grabbing",
        // Narrow: the band runs sideways, so vertical swipes still scroll.
        flip ? "touch-pan-y" : "touch-none"
      )}
      data-crest={isCrest || undefined}
      onClick={() => onSelect(index)}
      style={{
        height: pic.h,
        marginLeft: -pic.w / 2,
        marginTop: -pic.h / 2,
        opacity,
        rotate: angle,
        width: pic.w,
        x,
        y,
        zIndex,
      }}
      tabIndex={isCrest ? 0 : -1}
      type="button"
    >
      {/* The picture, cut to its sector. */}
      <span
        className="bg-muted absolute inset-0"
        style={{ clipPath: pic.clip }}
      >
        {/* Upright, 16:9 on screen. On the mirrored wheel the box is laid
            the other way round, and the same diagonal flip, applied twice,
            cancels out. */}
        <span
          className="absolute top-1/2 left-1/2 block"
          style={{
            height: flip ? pic.w : pic.h,
            transform: flip
              ? "translate(-50%, -50%) matrix(0, 1, 1, 0, 0, 0)"
              : "translate(-50%, -50%)",
            width: flip ? pic.h : pic.w,
          }}
        >
          {/* oxlint-disable-next-line nextjs/no-img-element -- Any URL the host passes, with no next.config image domains to set up. */}
          <img
            alt={reel.alt}
            className="size-full object-cover"
            decoding="async"
            draggable={false}
            loading="lazy"
            src={reel.src}
          />
        </span>
        {/* Everything off the crest sits back, toward the page colour. */}
        <motion.span
          aria-hidden
          className="bg-background pointer-events-none absolute inset-0"
          style={{ opacity: shade }}
        />
      </span>

      {/* The bezel: the next ring out, over the same two angles. Its box is
          taller than the picture's (the same angle subtends more arc further
          out), so it's placed by bezelAt rather than butted on. */}
      <span
        aria-hidden
        className="pointer-events-none absolute transition-[background] duration-300"
        style={{
          background: isCrest ? BEZEL_FILL_CREST : BEZEL_FILL,
          clipPath: bezel.clip,
          height: bezel.h,
          left: bezelAt.left,
          top: bezelAt.top,
          width: bezel.w,
        }}
      />

      {/* A 1px outline on the picture, traced on the sector like the focus
          ring below: pure black at 10% on light pages, pure white on dark,
          so its edge holds against any frame or background. */}
      <svg
        aria-hidden
        className="pointer-events-none absolute inset-0 overflow-visible"
        height={pic.h}
        width={pic.w}
      >
        <path
          className="stroke-black/10 dark:stroke-white/10"
          d={pic.d}
          fill="none"
          strokeWidth={1}
          vectorEffect="non-scaling-stroke"
        />
      </svg>

      {/* Focus ring traced on the sector itself, not its square box. */}
      <svg
        aria-hidden
        className="pointer-events-none absolute inset-0 overflow-visible opacity-0 group-focus-visible/frame:opacity-100"
        height={pic.h}
        width={pic.w}
      >
        <path
          d={pic.d}
          fill="none"
          stroke="var(--ring)"
          strokeWidth={3}
          vectorEffect="non-scaling-stroke"
        />
      </svg>
    </motion.button>
  );
};

interface ArcCaptionProps {
  category?: string;
  count: number;
  flip: boolean;
  index: number;
  isCrest: boolean;
  position: MotionValue<number>;
  reel: ShowreelReel;
  wheel: Wheel;
}

/* Undo the anchor's scale (text stays its real size at every width), then
 * hang the caption off its point on the arc: beside it on the wide wheel,
 * centred under it on the mirrored one. */
const CAPTION_WIDE =
  "w-[calc(var(--reel-caption-w)*var(--reel-s))] items-start text-left [transform:scale(calc(1/var(--reel-s)))_translate(0,-50%)]";
const CAPTION_NARROW =
  "w-[calc(var(--reel-narrow-step)*var(--reel-s)-1rem)] items-center text-center [transform:matrix(0,1,1,0,0,0)_scale(calc(1/var(--reel-s)))_translate(-50%,0)]";

/**
 * A reel's details, riding the arc just past the rim at the same angle as
 * its frame, so they turn with it. Every caption stays on the arc, dimming
 * with distance as the frames do; the section's focus blur does the rest.
 * Only the crest's can be clicked or tabbed to. On the mirrored wheel each
 * is one frame wide, so neighbours sit side by side.
 */
const ArcCaption = ({
  category,
  count,
  flip,
  index,
  isCrest,
  position,
  reel,
  wheel,
}: ArcCaptionProps) => {
  const { at, config, rimR, step } = wheel;
  const r = rimR + config.captionGap;
  const offset = useTransform(position, (p) => wrap(index - p, count));
  const angle = useTransform(offset, (o) => o * step);
  const x = useTransform(angle, (a) => at(a, r).x);
  const y = useTransform(angle, (a) => at(a, r).y);
  const opacity = useTransform(offset, (o) =>
    Math.max(0.3, 1 - Math.abs(o) * 0.28)
  );

  return (
    <motion.div
      aria-hidden={!isCrest}
      className="pointer-events-none absolute top-0 left-0"
      inert={!isCrest}
      style={{ opacity, rotate: angle, x, y }}
    >
      <div
        className={cn(
          "absolute top-0 left-0 flex origin-top-left flex-col",
          isCrest && "pointer-events-auto",
          flip ? CAPTION_NARROW : CAPTION_WIDE
        )}
      >
        {category ? (
          <p
            className={cn(
              "text-foreground leading-tight font-semibold tracking-tight",
              flip ? "text-[15px]" : "text-lg"
            )}
          >
            {category}
          </p>
        ) : null}
        <p
          className={cn(
            "text-muted-foreground mt-1.5 w-full leading-snug text-pretty",
            // A frame-wide caption gets a third line before it truncates.
            flip ? "line-clamp-3 text-xs" : "line-clamp-2 text-sm"
          )}
        >
          {reel.href ? (
            <a
              className="focus-visible:ring-ring/50 hover:text-foreground rounded-sm outline-none transition-colors duration-150 hover:underline hover:underline-offset-4 focus-visible:ring-[3px]"
              href={reel.href}
            >
              {reel.description}
              <ArrowUpRight
                aria-hidden
                className="ml-0.5 inline size-3.5 align-[-0.125em]"
              />
            </a>
          ) : (
            reel.description
          )}
        </p>
        {reel.views && reel.views.length > 0 ? (
          <dl
            className={cn(
              "text-foreground flex flex-wrap items-center font-medium tabular-nums",
              flip
                ? "mt-2 justify-center gap-x-2 gap-y-1 text-[11px]"
                : "mt-3 gap-x-4 gap-y-1.5 text-xs"
            )}
          >
            {reel.views.map((view) => (
              <div key={view.platform} className="flex items-center gap-1.5">
                <dt className="sr-only">{view.platform} views</dt>
                <span className="[&_svg]:size-3.5 [&_svg]:shrink-0">
                  {view.icon}
                </span>
                <dd className="whitespace-nowrap">{view.value}</dd>
              </div>
            ))}
          </dl>
        ) : null}
      </div>
    </motion.div>
  );
};

/** Rims, lip, spokes and the content side's surface: one static SVG. */
const Drum = ({ wheel }: { wheel: Wheel }) => {
  const uid = useId().replaceAll(/[^\w-]/gu, "");
  const { arc, at, config, lipR, picInner, picOuter, rimR, step } = wheel;
  const crest = at(0, rimR);
  const top = at(-SPAN, rimR);
  const bottom = at(SPAN, rimR);
  const spokes = Array.from({ length: FAN * 2 + 2 }, (_, i) => i - FAN - 1);

  return (
    <svg
      aria-hidden
      className="text-foreground pointer-events-none absolute top-0 left-0 overflow-visible"
      height={1}
      width={1}
    >
      <defs>
        {/* The content side is its own surface: a touch off the page colour
            at the curve, falling back to it. */}
        <linearGradient
          gradientUnits="userSpaceOnUse"
          id={`${uid}-panel`}
          x1={crest.x}
          x2={crest.x + 900}
          y1={0}
          y2={0}
        >
          <stop offset="0" stopColor="currentColor" stopOpacity={0.045} />
          <stop offset="0.45" stopColor="currentColor" stopOpacity={0.015} />
          <stop offset="1" stopColor="currentColor" stopOpacity={0} />
        </linearGradient>
        {/* Spokes fade along their length and are gone before the copy. */}
        <linearGradient
          gradientUnits="userSpaceOnUse"
          id={`${uid}-spoke`}
          x1={crest.x}
          x2={crest.x + config.spokeFade}
          y1={0}
          y2={0}
        >
          <stop offset="0" stopColor="currentColor" stopOpacity={0.16} />
          <stop offset="0.55" stopColor="currentColor" stopOpacity={0.07} />
          <stop offset="1" stopColor="currentColor" stopOpacity={0} />
        </linearGradient>
      </defs>

      {/* The content side, its leading edge the rim arc itself, so the page
          surface and the band share one curve. */}
      <path
        d={`${arc(rimR, -SPAN, SPAN)} L 4000 ${bottom.y} L 4000 ${top.y} Z`}
        fill={`url(#${uid}-panel)`}
      />
      {/* Back lip: the back of the drum. */}
      <path
        d={arc(lipR, -SPAN, SPAN)}
        fill="none"
        stroke="currentColor"
        strokeOpacity={0.035}
        strokeWidth={config.lipWidth}
      />
      {/* A clean gap between the lip and the strip. */}
      <path
        d={arc(picInner - 6, -SPAN, SPAN)}
        fill="none"
        stroke="var(--background)"
        strokeWidth={22}
      />
      {/* Backing for the picture band and the bezel, so the band still
          reads as one ring past where the frames fade out. */}
      <path
        d={arc((picInner + picOuter) / 2, -SPAN, SPAN)}
        fill="none"
        stroke="currentColor"
        strokeOpacity={0.025}
        strokeWidth={config.radial}
      />
      <path
        d={arc(picOuter + config.stripW / 2, -SPAN, SPAN)}
        fill="none"
        stroke="currentColor"
        strokeOpacity={0.05}
        strokeWidth={config.stripW}
      />
      {/* Spokes leave the rim at the seams between frames, so each slab on
          the content side continues exactly one frame. The crest's own two
          edges are brightest: they bracket the caption. */}
      {spokes.map((k) => {
        const a = (k + 0.5) * step;
        const p = at(a, rimR + 2);
        const q = at(a, rimR + FAN_LEN);
        const isCrestEdge = Math.abs(k + 0.5) < 1;
        return (
          <line
            key={k}
            opacity={isCrestEdge ? 1 : Math.max(0.2, 1 - Math.abs(k) * 0.11)}
            stroke={`url(#${uid}-spoke)`}
            strokeWidth={1}
            vectorEffect="non-scaling-stroke"
            x1={p.x}
            x2={q.x}
            y1={p.y}
            y2={q.y}
          />
        );
      })}
      {/* Front rim: the accent hairline where the band meets the page. */}
      <path
        d={arc(rimR, -SPAN, SPAN)}
        fill="none"
        stroke="var(--primary)"
        strokeOpacity={0.8}
        strokeWidth={1.5}
        vectorEffect="non-scaling-stroke"
      />
      {/* Crest tick: the strip's reading line. */}
      <path
        d={arc(rimR, -step / 2, step / 2)}
        fill="none"
        stroke="var(--primary)"
        strokeLinecap="round"
        strokeWidth={4}
      />
    </svg>
  );
};

interface WheelViewProps {
  categories: Map<string, string>;
  className: string;
  crestIndex: number;
  flip: boolean;
  handlers: {
    onKeyDown: (event: KeyboardEvent<HTMLDivElement>) => void;
    onPointerCancel: (event: PointerEvent<HTMLDivElement>) => void;
    onPointerDown: (event: PointerEvent<HTMLDivElement>) => void;
    onPointerMove: (event: PointerEvent<HTMLDivElement>) => void;
    onPointerUp: (event: PointerEvent<HTMLDivElement>) => void;
  };
  hold: Record<
    "onBlur" | "onFocus" | "onPointerEnter" | "onPointerLeave",
    () => void
  >;
  label: string;
  onSelect: (index: number) => void;
  position: MotionValue<number>;
  reels: ShowreelReel[];
  ringCount: number;
  setRef: (index: number, element: HTMLButtonElement | null) => void;
  wheel: Wheel;
}

/** One wheel: a zero-size anchor at its reference point, scaled by --reel-s. */
const WheelView = ({
  categories,
  className,
  crestIndex,
  flip,
  handlers,
  hold,
  label,
  onSelect,
  position,
  reels,
  ringCount,
  setRef,
  wheel,
}: WheelViewProps) => (
  <div
    aria-label={label}
    aria-roledescription="carousel"
    className={cn("absolute z-20 origin-top-left", className)}
    role="region"
    {...hold}
  >
    <div
      className="showreel-drum"
      style={{ transformOrigin: `${wheel.cx}px 0px` }}
    >
      <Drum wheel={wheel} />
      {/* oxlint-disable-next-line jsx-a11y/no-static-element-interactions -- The frames inside are the focusable controls; this only routes their keys and drags. */}
      <div
        className="absolute top-0 left-0"
        data-axis={flip ? "x" : "y"}
        data-step={wheel.config.tangential}
        {...handlers}
      >
        {Array.from({ length: ringCount }, (_, i) => (
          <Frame
            key={i}
            count={ringCount}
            flip={flip}
            index={i}
            isCrest={crestIndex === i}
            onSelect={onSelect}
            position={position}
            reel={reels[i % reels.length]}
            setRef={(element) => setRef(i, element)}
            wheel={wheel}
          />
        ))}
      </div>
      {Array.from({ length: ringCount }, (_, i) => {
        const reel = reels[i % reels.length];
        return (
          <ArcCaption
            key={i}
            category={reel.category ? categories.get(reel.category) : undefined}
            count={ringCount}
            flip={flip}
            index={i}
            isCrest={crestIndex === i}
            position={position}
            reel={reel}
            wheel={wheel}
          />
        );
      })}
    </div>
  </div>
);

// How big the wheel is drawn comes from the CSS the section set, so a drag
// always matches what's on screen.
const readScale = (element: Element) =>
  Number.parseFloat(getComputedStyle(element).getPropertyValue("--reel-s")) ||
  1;

export interface ShowreelWheelProps {
  autoplay: boolean;
  autoplayInterval: number;
  categories?: ShowreelCategory[];
  initialIndex: number;
  label: string;
  reels: ShowreelReel[];
}

export const ShowreelWheel = ({
  autoplay,
  autoplayInterval,
  categories,
  initialIndex,
  label,
  reels,
}: ShowreelWheelProps) => {
  const count = reels.length;
  const ringCount = count * Math.max(1, Math.ceil(MIN_FRAMES / count));
  const reduced = useReducedMotion();

  const position = useMotionValue(initialIndex);
  const target = useRef(initialIndex);
  const controls = useRef<AnimationPlaybackControls | null>(null);
  // Both wheels' frames, keyed "w3" / "n3"; only one wheel is ever shown.
  const frames = useRef(new Map<string, HTMLButtonElement | null>());
  const focusCrest = useRef(false);

  // The frame nearest the crest, as the wheel turns (not just where it'll
  // land): it takes focus, the playback bar and the readable caption.
  const [crestRing, setCrestRing] = useState(initialIndex);
  useMotionValueEvent(position, "change", (p) => setCrestRing(Math.round(p)));
  const reel = reels[mod(crestRing, count)];
  const categoryLabels = new Map(
    categories?.map((category) => [category.id, category.label])
  );

  const [interacted, setInteracted] = useState(false);
  const [paused, setPaused] = useState(false);
  const [dragging, setDragging] = useState(false);
  // Covers the stage, so autoplay can tell when the wheel is on screen.
  const probeRef = useRef<HTMLDivElement>(null);
  const inView = useInView(probeRef, { margin: "-10% 0px" });

  const settle = useCallback(
    (next: number, velocity = 0) => {
      controls.current?.stop();
      target.current = next;
      if (reduced) {
        position.set(next);
        return;
      }
      controls.current = animate(position, next, { ...SPRING, velocity });
    },
    [position, reduced]
  );

  const go = useCallback(
    (direction: number) => settle(target.current + direction),
    [settle]
  );

  const selectFrame = (index: number) => {
    setInteracted(true);
    settle(target.current + wrap(index - target.current, ringCount));
  };

  /* ---- drag: 1:1 under the finger, flung to the nearest frame ---- */

  const drag = useRef<{
    axis: "x" | "y";
    from: number;
    id: number;
    last: number;
    moved: boolean;
    origin: number;
    perFrame: number;
    time: number;
    velocity: number;
  } | null>(null);

  const onPointerDown = (event: PointerEvent<HTMLDivElement>) => {
    if (event.pointerType === "mouse" && event.button !== 0) {
      return;
    }
    const { axis, step } = event.currentTarget.dataset;
    drag.current = {
      axis: axis === "x" ? "x" : "y",
      from: position.get(),
      id: event.pointerId,
      last: position.get(),
      moved: false,
      origin: axis === "x" ? event.clientX : event.clientY,
      // Screen pixels of drag that move the wheel by one frame.
      perFrame: readScale(event.currentTarget) * Number(step),
      time: event.timeStamp,
      velocity: 0,
    };
  };

  const onPointerMove = (event: PointerEvent<HTMLDivElement>) => {
    const state = drag.current;
    if (!state || state.id !== event.pointerId) {
      return;
    }
    const delta =
      (state.axis === "x" ? event.clientX : event.clientY) - state.origin;
    if (!state.moved) {
      if (Math.abs(delta) < TAP_SLOP) {
        return;
      }
      // Now it's a drag: capture, so the release can't land as a click.
      state.moved = true;
      controls.current?.stop();
      event.currentTarget.setPointerCapture(event.pointerId);
      setDragging(true);
      setInteracted(true);
    }
    const next = state.from - delta / state.perFrame;
    const seconds = (event.timeStamp - state.time) / 1000;
    if (seconds > 0) {
      state.velocity =
        0.8 * ((next - state.last) / seconds) + 0.2 * state.velocity;
    }
    state.last = next;
    state.time = event.timeStamp;
    position.set(next);
  };

  const onPointerUp = (event: PointerEvent<HTMLDivElement>) => {
    const state = drag.current;
    drag.current = null;
    if (!state?.moved) {
      return;
    }
    setDragging(false);
    // A pause before letting go means no fling.
    const velocity = event.timeStamp - state.time < 80 ? state.velocity : 0;
    settle(Math.round(position.get() + velocity * FLING), velocity);
  };

  const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
    const step = KEY_STEPS[event.key];
    if (step === undefined) {
      return;
    }
    event.preventDefault();
    setInteracted(true);
    focusCrest.current = true;
    go(step);
  };

  // Keyboard moves keep focus on whichever frame is at the crest.
  useEffect(() => {
    if (focusCrest.current) {
      focusCrest.current = false;
      const index = mod(crestRing, ringCount);
      const shown = [
        frames.current.get(`w${index}`),
        frames.current.get(`n${index}`),
      ].find((element) => element?.offsetParent);
      shown?.focus({ preventScroll: true });
    }
  }, [crestRing, ringCount]);

  /* ---- autoplay: advances while nobody's using the wheel and it's on
     screen. Any move restarts the count, so it never steps right after
     one. ---- */

  useEffect(() => {
    if (!autoplay || reduced || paused || dragging || !inView) {
      return;
    }
    const timer = window.setInterval(() => {
      if (!document.hidden) {
        go(1);
      }
    }, autoplayInterval);
    return () => window.clearInterval(timer);
  }, [
    autoplay,
    autoplayInterval,
    crestRing,
    dragging,
    go,
    inView,
    paused,
    reduced,
  ]);

  const hold = {
    onBlur: () => setPaused(false),
    onFocus: () => setPaused(true),
    onPointerEnter: () => setPaused(true),
    onPointerLeave: () => setPaused(false),
  };

  if (count === 0) {
    return null;
  }

  const handlers = {
    onKeyDown,
    onPointerCancel: onPointerUp,
    onPointerDown,
    onPointerMove,
    onPointerUp,
  };

  return (
    <>
      <WheelView
        className="top-1/2 left-0 hidden [transform:scale(var(--reel-s))] @5xl:block"
        crestIndex={mod(crestRing, ringCount)}
        flip={false}
        handlers={handlers}
        hold={hold}
        categories={categoryLabels}
        label={label}
        onSelect={selectFrame}
        position={position}
        reels={reels}
        ringCount={ringCount}
        setRef={(index, element) => frames.current.set(`w${index}`, element)}
        wheel={WIDE}
      />
      {/* Mirrored across the diagonal: the same wheel, lying across the top
          and opening downward. */}
      <WheelView
        className="top-0 left-1/2 [transform:scale(var(--reel-s))_matrix(0,1,1,0,0,0)] @5xl:hidden"
        crestIndex={mod(crestRing, ringCount)}
        flip
        handlers={handlers}
        hold={hold}
        categories={categoryLabels}
        label={label}
        onSelect={selectFrame}
        position={position}
        reels={reels}
        ringCount={ringCount}
        setRef={(index, element) => frames.current.set(`n${index}`, element)}
        wheel={NARROW}
      />

      <div
        ref={probeRef}
        aria-hidden
        className="pointer-events-none absolute inset-0"
      />
      {/* The captions are drawn on the arc; this is what's read out. */}
      <p aria-live={interacted ? "polite" : "off"} className="sr-only">
        {reel.description}
      </p>
    </>
  );
};
```

### `registry/sections/hero-section-02-logos.tsx`

```tsx
/* ─────────────────────────────────────────────────────────────────────────
 * Brand marks for the showreel hero: the clients in the "Trusted by" row and
 * the platforms beside each reel's view counts. Plain SVG, no "use client":
 * the section renders them on the server, so they add no JavaScript.
 *
 * The client marks are the full-colour versions from tech-stack-icons (MIT),
 * inlined so the block needs no extra package. Every one has colour on both
 * light and dark pages. Their ids are prefixed per mark, since the same
 * mark appears twice in the looping row.
 * ───────────────────────────────────────────────────────────────────────── */

export const FigmaMark = () => (
  <svg aria-hidden fill="none" viewBox="0 0 100 100">
    <g clipPath="url(#showreel-figma-a)">
      <path
        fill="#0acf83"
        d="M33.333 100C42.533 100 50 92.533 50 83.333V66.667H33.333c-9.2 0-16.666 7.466-16.666 16.666S24.133 100 33.333 100"
      />
      <path
        fill="#a259ff"
        d="M16.667 50c0-9.2 7.466-16.667 16.666-16.667H50v33.334H33.333c-9.2 0-16.666-7.467-16.666-16.667"
      />
      <path
        fill="#f24e1e"
        d="M16.667 16.667C16.667 7.467 24.133 0 33.333 0H50v33.333H33.333c-9.2 0-16.666-7.466-16.666-16.666"
      />
      <path
        fill="#ff7262"
        d="M50 0h16.667c9.2 0 16.666 7.467 16.666 16.667s-7.466 16.666-16.666 16.666H50z"
      />
      <path
        fill="#1abcfe"
        d="M83.333 50c0 9.2-7.466 16.667-16.666 16.667S50 59.2 50 50s7.467-16.667 16.667-16.667S83.333 40.8 83.333 50"
      />
    </g>
    <defs>
      <clipPath id="showreel-figma-a">
        <path fill="#fff" d="M0 0h100v100H0z" />
      </clipPath>
    </defs>
  </svg>
);

export const SlackMark = () => (
  <svg aria-hidden fill="none" viewBox="0 0 100 100">
    <g fillRule="evenodd" clipPath="url(#showreel-slack-a)" clipRule="evenodd">
      <path
        fill="#36c5f0"
        d="M36.591-.001c-5.517.004-9.981 4.481-9.977 9.998-.004 5.517 4.464 9.994 9.981 9.998h9.982V10C46.58 4.484 42.112.007 36.59 0q.006 0 0 0m0 26.667H9.981C4.466 26.67-.003 31.146 0 36.664c-.008 5.516 4.46 9.993 9.978 10.002H36.59c5.517-.004 9.986-4.481 9.982-9.998.004-5.521-4.465-9.998-9.982-10.002"
      />
      <path
        fill="#2eb67d"
        d="M99.8 36.664c.004-5.517-4.465-9.994-9.981-9.998-5.517.004-9.986 4.48-9.982 9.998v10.002h9.981c5.517-.004 9.986-4.481 9.982-10.002m-26.614 0V9.997c.005-5.513-4.46-9.99-9.977-9.998-5.517.004-9.986 4.481-9.982 9.998v26.667c-.008 5.516 4.461 9.993 9.978 10.002 5.517-.004 9.986-4.481 9.981-10.002"
      />
      <path
        fill="#ecb22e"
        d="M63.205 99.999c5.517-.004 9.986-4.481 9.982-9.998.004-5.517-4.465-9.994-9.982-9.998h-9.982V90c-.004 5.513 4.465 9.99 9.982 9.998m0-26.67h26.614c5.516-.005 9.985-4.482 9.981-9.999.008-5.517-4.46-9.994-9.977-10.002H63.209c-5.517.004-9.986 4.481-9.982 9.998-.004 5.521 4.461 9.998 9.978 10.002"
      />
      <path
        fill="#e01e5a"
        d="M0 63.33c-.004 5.517 4.465 9.994 9.982 9.998 5.517-.004 9.986-4.481 9.982-9.998v-9.998H9.982C4.465 53.336-.004 57.813 0 63.33m26.614 0v26.667c-.008 5.516 4.46 9.993 9.978 10.002 5.516-.004 9.985-4.481 9.981-9.998V63.338c.008-5.517-4.46-9.994-9.977-10.002-5.521 0-9.986 4.477-9.982 9.994"
      />
    </g>
    <defs>
      <clipPath id="showreel-slack-a">
        <path fill="#fff" d="M0 0h100v100H0z" />
      </clipPath>
    </defs>
  </svg>
);

export const SupabaseMark = () => (
  <svg aria-hidden fill="none" viewBox="0 0 100 100">
    <g clipPath="url(#showreel-supabase-a)">
      <path
        fill="url(#showreel-supabase-b)"
        d="M57.894 98.338c-2.554 3.217-7.734 1.454-7.796-2.654l-.9-60.082h40.4c7.316 0 11.397 8.452 6.847 14.182z"
      />
      <path
        fill="url(#showreel-supabase-c)"
        fillOpacity=".2"
        d="M57.894 98.338c-2.554 3.217-7.734 1.454-7.796-2.654l-.9-60.082h40.4c7.316 0 11.397 8.452 6.847 14.182z"
      />
      <path
        fill="#3ecf8e"
        d="M41.464 1.66c2.555-3.217 7.735-1.454 7.796 2.654l.395 60.082H9.76c-7.318 0-11.399-8.452-6.848-14.182z"
      />
    </g>
    <defs>
      <linearGradient
        id="showreel-supabase-b"
        x1="49.198"
        x2="85.103"
        y1="48.924"
        y2="63.982"
        gradientUnits="userSpaceOnUse"
      >
        <stop stopColor="#249361" />
        <stop offset="1" stopColor="#3ecf8e" />
      </linearGradient>
      <linearGradient
        id="showreel-supabase-c"
        x1="33.279"
        x2="49.654"
        y1="27.128"
        y2="57.953"
        gradientUnits="userSpaceOnUse"
      >
        <stop />
        <stop offset="1" stopOpacity="0" />
      </linearGradient>
      <clipPath id="showreel-supabase-a">
        <path fill="#fff" d="M0 0h100v100H0z" />
      </clipPath>
    </defs>
  </svg>
);

export const CloudflareMark = () => (
  <svg aria-hidden fill="none" viewBox="0 0 100 100">
    <g clipPath="url(#showreel-cloudflare-a)">
      <path
        fill="#fff"
        d="m79.046 46.295-2.075-.83c-9.75 21.939-48.539 8.601-50.873 15.128-.39 4.408 21.182.838 36.604 1.585 4.702.228 7.06 3.778 5.064 9.564l3.933.012C76.236 57.61 90.716 64.83 91.32 60.16c-.994-3.069-16.64 0-12.275-13.865"
      />
      <path
        fill="#f4811f"
        d="M68.88 69.323c.622-2.074.415-4.149-.623-5.394-1.037-1.245-2.49-2.074-4.357-2.282l-36.1-.415c-.207 0-.414-.207-.622-.207q-.31-.312 0-.623c.208-.415.415-.622.83-.622l36.307-.415c4.357-.207 8.922-3.735 10.581-7.884l2.075-5.394c0-.208.207-.415 0-.623C74.69 34.885 65.145 27 53.941 27c-10.373 0-19.294 6.639-22.406 15.975-2.075-1.452-4.564-2.282-7.469-2.074-4.979.414-8.92 4.563-9.543 9.543-.208 1.245 0 2.49.207 3.734C6.64 54.386 0 61.025 0 69.323c0 .83 0 1.453.207 2.283 0 .415.415.622.623.622h66.597c.415 0 .83-.207.83-.622z"
      />
      <path
        fill="#faad3f"
        d="M80.29 46.087h-1.037c-.207 0-.415.207-.622.415l-1.453 4.98c-.622 2.074-.414 4.149.623 5.393 1.037 1.245 2.49 2.075 4.357 2.283l7.676.415c.207 0 .415.207.623.207q.31.311 0 .622c-.208.416-.415.623-.83.623l-7.884.415c-4.357.207-8.921 3.734-10.58 7.883l-.416 1.868c-.207.207 0 .622.415.622h27.386q.622 0 .622-.622c.415-1.66.83-3.527.83-5.395 0-10.788-8.921-19.709-19.71-19.709"
      />
    </g>
    <defs>
      <clipPath id="showreel-cloudflare-a">
        <path fill="#fff" d="M0 0h100v100H0z" />
      </clipPath>
    </defs>
  </svg>
);

export const AtlassianMark = () => (
  <svg aria-hidden fill="none" viewBox="0 0 100 100">
    <g clipPath="url(#showreel-atlassian-a)">
      <path
        fill="url(#showreel-atlassian-b)"
        d="M29.634 46.195a2.853 2.853 0 0 0-3.99-.538 2.96 2.96 0 0 0-.835 1.041L.31 95.713a2.93 2.93 0 0 0 2.619 4.239h34.135a2.81 2.81 0 0 0 2.62-1.62c7.367-15.217 2.9-38.353-10.05-52.137"
      />
      <path
        fill="#2684ff"
        d="M47.642 1.59a64.66 64.66 0 0 0-3.777 63.826l16.458 32.916a2.93 2.93 0 0 0 2.62 1.62H97.07a2.92 2.92 0 0 0 2.929-2.93 3 3 0 0 0-.303-1.309L52.625 1.577a2.756 2.756 0 0 0-4.983 0z"
      />
    </g>
    <defs>
      <linearGradient
        id="showreel-atlassian-b"
        x1="43.121"
        x2="17.249"
        y1="53.686"
        y2="98.497"
        gradientUnits="userSpaceOnUse"
      >
        <stop stopColor="#0052cc" />
        <stop offset=".92" stopColor="#2684ff" />
      </linearGradient>
      <clipPath id="showreel-atlassian-a">
        <path fill="#fff" d="M0 0h100v100H0z" />
      </clipPath>
    </defs>
  </svg>
);

export const AirtableMark = () => (
  <svg aria-hidden fill="none" viewBox="0 0 100 100">
    <g clipPath="url(#showreel-airtable-a)">
      <path
        fill="#fcb400"
        d="M44.654 9.056 7.37 24.483c-2.073.858-2.052 3.804.034 4.63l37.44 14.847a13.9 13.9 0 0 0 10.242 0l37.439-14.847c2.086-.826 2.108-3.771.034-4.63L55.277 9.057a13.9 13.9 0 0 0-10.624 0"
      />
      <path
        fill="#18bfff"
        d="M53.287 52.067v37.089a2.498 2.498 0 0 0 3.418 2.322l41.718-16.193A2.5 2.5 0 0 0 100 72.963V35.875a2.498 2.498 0 0 0-3.418-2.322L54.864 49.745a2.5 2.5 0 0 0-1.577 2.322"
      />
      <path
        fill="#f82b60"
        d="m43.545 53.98-12.381 5.98-1.257.607L3.77 73.09C2.115 73.889 0 72.681 0 70.841V36.03c0-.666.341-1.24.8-1.673q.282-.282.632-.475c.624-.375 1.515-.475 2.273-.175L43.337 49.41c2.014.8 2.172 3.622.208 4.571"
      />
      <path
        fill="#000"
        fillOpacity=".25"
        d="m43.545 53.98-12.381 5.98L.799 34.356q.284-.281.633-.474c.624-.375 1.515-.475 2.273-.175L43.337 49.41c2.014.8 2.172 3.622.208 4.571"
      />
    </g>
    <defs>
      <clipPath id="showreel-airtable-a">
        <path fill="#fff" d="M0 0h100v100H0z" />
      </clipPath>
    </defs>
  </svg>
);

export const AsanaMark = () => (
  <svg aria-hidden fill="none" viewBox="0 0 100 100">
    <path
      fill="#f06a6a"
      fillRule="evenodd"
      d="M78.252 52.614c-12.011 0-21.749 9.364-21.749 20.916 0 11.55 9.738 20.914 21.749 20.914S100 85.081 100 73.53s-9.737-20.916-21.748-20.916m-56.504.002C9.738 52.616 0 61.978 0 73.53c0 11.55 9.737 20.914 21.748 20.914s21.75-9.363 21.75-20.914-9.738-20.914-21.75-20.914m50-26.146c0 11.552-9.737 20.916-21.748 20.916S28.252 38.022 28.252 26.47c0-11.55 9.737-20.914 21.748-20.914S71.748 14.92 71.748 26.47"
      clipRule="evenodd"
    />
  </svg>
);

export const GitLabMark = () => (
  <svg aria-hidden fill="none" viewBox="0 0 100 100">
    <path fill="#e24329" d="m50 96.118 18.413-56.67H31.587z" />
    <path fill="#fc6d26" d="M50 96.117 31.587 39.448H5.782z" />
    <path
      fill="#fca326"
      d="M5.782 39.448.187 56.67a3.81 3.81 0 0 0 1.384 4.262L50 96.117z"
    />
    <path
      fill="#e24329"
      d="M5.782 39.449h25.805L20.497 5.317c-.57-1.756-3.055-1.756-3.625 0z"
    />
    <path fill="#fc6d26" d="m50 96.117 18.413-56.669h25.805z" />
    <path
      fill="#fca326"
      d="m94.218 39.448 5.595 17.222a3.81 3.81 0 0 1-1.385 4.262L50 96.117z"
    />
    <path
      fill="#e24329"
      d="M94.218 39.449H68.413l11.09-34.132c.57-1.756 3.055-1.756 3.626 0z"
    />
  </svg>
);

export const WebflowMark = () => (
  <svg aria-hidden fill="none" viewBox="0 0 100 100">
    <g clipPath="url(#showreel-webflow-a)">
      <path
        fill="#4353ff"
        d="M.777.777v98.447h98.447V.777zm56.187 32.59s3.116 23.481 3.116 24.565l4.52-14.156h-.032c1.994-5.36 5.921-10.409 13.4-10.409L67.372 58.953a14.3 14.3 0 0 1-5.21 7.231 14.46 14.46 0 0 1-8.5 2.776s-3.117-21.994-3.117-22.49L45.87 58.552C43.783 63.91 39.733 68.96 32.254 68.96l-5.86-35.56a11.7 11.7 0 0 1 7.663 3.113 11.56 11.56 0 0 1 3.65 7.386s.779 13.042.779 14.063l5.516-14.187c1.994-5.36 5.795-10.408 12.962-10.408"
      />
    </g>
    <defs>
      <clipPath id="showreel-webflow-a">
        <path fill="#fff" d="M0 0h100v100H0z" />
      </clipPath>
    </defs>
  </svg>
);

/* Platform marks for the view counts, drawn at 24 × 24 in each brand's own
 * colour. X has none, so it takes the text colour. Solid fills only: a
 * gradient (Instagram's) would need an SVG id, and ids inside the hidden
 * wheel's copy of a caption don't resolve. Pass your own `icon` to override. */
export const YouTubeMark = () => (
  <svg aria-hidden viewBox="0 0 24 24">
    <path
      d="M23 7.2a3 3 0 0 0-2.1-2.1C19 4.6 12 4.6 12 4.6s-7 0-8.9.5A3 3 0 0 0 1 7.2 31 31 0 0 0 .5 12a31 31 0 0 0 .5 4.8 3 3 0 0 0 2.1 2.1c1.9.5 8.9.5 8.9.5s7 0 8.9-.5a3 3 0 0 0 2.1-2.1 31 31 0 0 0 .5-4.8 31 31 0 0 0-.5-4.8Z"
      fill="#FF0033"
    />
    <path d="M9.75 15.02V8.98L15.5 12l-5.75 3.02Z" fill="#FFFFFF" />
  </svg>
);

export const LinkedInMark = () => (
  <svg aria-hidden viewBox="0 0 24 24">
    <rect fill="#0A66C2" height="24" rx="4" width="24" />
    <path
      d="M19.2 19.2h-2.9v-4.6c0-1.1 0-2.5-1.5-2.5s-1.8 1.2-1.8 2.4v4.7h-2.9V9.9h2.8v1.3c.4-.7 1.3-1.5 2.8-1.5 3 0 3.5 2 3.5 4.5v5ZM6.9 8.6a1.7 1.7 0 1 1 0-3.4 1.7 1.7 0 0 1 0 3.4Zm1.4 10.6H5.4V9.9h2.9v9.3Z"
      fill="#FFFFFF"
    />
  </svg>
);

export const XMark = () => (
  <svg aria-hidden fill="currentColor" viewBox="0 0 24 24">
    <path d="M18.9 2H22l-6.8 7.8L23.2 22h-6.3l-4.9-6.4L6.4 22H3.3l7.3-8.3L2.9 2h6.4l4.4 5.8L18.9 2Zm-1.1 18.1h1.7L8.3 3.8H6.5l11.3 16.3Z" />
  </svg>
);

export const InstagramMark = () => (
  <svg
    aria-hidden
    fill="none"
    stroke="#E1306C"
    strokeWidth={2.2}
    viewBox="0 0 24 24"
  >
    <rect height="18" rx="5" width="18" x="3" y="3" />
    <circle cx="12" cy="12" r="4" />
    <circle cx="17.5" cy="6.5" fill="#E1306C" r="1.1" stroke="none" />
  </svg>
);
```

### `registry/sections/hero-section-02-shared.ts`

```ts
import type { ReactNode } from "react";

/* ─────────────────────────────────────────────────────────────────────────
 * Showreel hero: types and wheel geometry, shared by the server section
 * (hero-section-02.tsx) and the client wheel (hero-section-02-wheel.tsx).
 * Plain values only, no "use client": the server reads the layout offsets
 * from here, the client reads the geometry.
 *
 * One circle carries three concentric things:
 *
 *   back lip      a thick, quiet arc inside the band: the back of the drum
 *   image strip   the reels, each frame an annular sector of the band, so
 *                 neighbours share an exact radial edge and the band tiles
 *                 with no gaps and no overlap at any radius
 *   front rim     an accent hairline where the band meets the content side,
 *                 with spokes fanning out from the seams between frames
 *
 * Everything is drawn in DESIGN UNITS on a wheel whose centre sits off the
 * leading edge (the arc opens to the right). A point is always
 *
 *     x = cx + r·cos θ        y = r·sin θ
 *
 * and a frame is always `translate(x, y) rotate(θ)`.
 *
 * There are two wheels. WIDE stands on the left of the copy; NARROW is drawn
 * the same way and then mirrored across the diagonal (x ↔ y), so it lies
 * across the top and opens downward. Mirroring swaps which side of a frame
 * runs along the band, so each wheel sizes its frames for its own
 * orientation: both show a 16:9 picture on screen.
 * ───────────────────────────────────────────────────────────────────────── */

/** Views on one platform: a mark, and the count. */
export interface ShowreelViews {
  /** Read by screen readers ("YouTube views"); the icon stands in for it. */
  platform: string;
  value: string;
  icon?: ReactNode;
}

export interface ShowreelReel {
  /** Image URL, ideally 16:9 (a video still). It's cropped to the frame. */
  src: string;
  alt: string;
  /** What the piece is, in a line or two. Also names the frame. */
  description: string;
  /** Matches a `ShowreelCategory.id`, whose label shows above it. */
  category?: string;
  /** Makes the description a link (a case study, the film itself). */
  href?: string;
  /** Where it was watched, and how much. Two or three read best. */
  views?: ShowreelViews[];
}

/** Names a `ShowreelReel.category` id, shown above its description. */
export interface ShowreelCategory {
  id: string;
  label: string;
}

export const DEG = Math.PI / 180;

/** Half the arc the rims are drawn over; the stage clips beyond it. */
export const SPAN = 44;

/**
 * A sliver of angle on each side of a sector, so neighbours overlap by a
 * hair instead of meeting on an exact edge where antialiasing would let the
 * background through as a seam.
 */
const BLEED = 0.05;

/** Two decimals: enough for a path, short enough to read in devtools. */
const fixed = (value: number) => value.toFixed(2);

interface WheelConfig {
  /** How far past the rim each frame's caption hangs. */
  captionGap: number;
  /** Where the crest frame's centre sits, from the stage's leading edge. */
  crestX: number;
  /** Frame size: `radial` across the band, `tangential` along it. */
  radial: number;
  tangential: number;
  /** Radius to the centre line of the image band. */
  radius: number;
  /** The bezel riding on the outer side of every frame. */
  stripW: number;
  /** How far inside the band the back lip's centre line sits, and its width. */
  lipInset: number;
  lipWidth: number;
  /** How far past the rim the spokes fade out. */
  spokeFade: number;
}

const makeWheel = (config: WheelConfig) => {
  const cx = config.crestX - config.radius;
  const picOuter = config.radius + config.radial / 2;
  const picInner = picOuter - config.radial;
  const rimR = picOuter + config.stripW;

  /**
   * Angular width of one frame in degrees, and so the step between frames.
   * Arc length is s = R·θ: a frame that runs `tangential` along the band
   * spans `tangential / radius`.
   */
  const step = config.tangential / config.radius / DEG;
  const half = (step / 2 + BLEED) * DEG;

  /**
   * One ring of the band between radii r0 and r1, over ±half: the box it's
   * cut from, and the annular sector itself as an SVG path in that box's
   * own coordinates (identical for every frame, so it's built once). The
   * sector's leftmost points are where the inner arc ends (r0·cos half),
   * its rightmost the outer arc's midpoint (r1).
   */
  const ring = (r0: number, r1: number) => {
    const xMin = r0 * Math.cos(half);
    const yMax = r1 * Math.sin(half);
    const point = (r: number, phi: number) =>
      `${fixed(r * Math.cos(phi) - xMin)} ${fixed(r * Math.sin(phi) + yMax)}`;
    const d = `M ${point(r1, -half)} A ${r1} ${r1} 0 0 1 ${point(r1, half)} L ${point(r0, half)} A ${r0} ${r0} 0 0 0 ${point(r0, -half)} Z`;
    return {
      clip: `path("${d}")`,
      d,
      h: 2 * yMax,
      /** Radius of the box's centre, which is what a frame is placed by. */
      rc: (xMin + r1) / 2,
      w: r1 - xMin,
      xMin,
      yMax,
    };
  };

  const pic = ring(picInner, picOuter);
  const bezel = ring(picOuter, rimR);

  /** A point on the wheel at angle `a` (degrees from the crest), radius `r`. */
  const at = (a: number, r: number) => ({
    x: cx + r * Math.cos(a * DEG),
    y: r * Math.sin(a * DEG),
  });

  /** An arc of radius `r` from `a0` to `a1`, as a path command. */
  const arc = (r: number, a0: number, a1: number) => {
    const p = at(a0, r);
    const q = at(a1, r);
    return `M ${fixed(p.x)} ${fixed(p.y)} A ${r} ${r} 0 0 1 ${fixed(q.x)} ${fixed(q.y)}`;
  };

  return {
    arc,
    at,
    bezel,
    /** The bezel's box, in the picture box's coordinates. */
    bezelAt: { left: bezel.xMin - pic.xMin, top: pic.yMax - bezel.yMax },
    config,
    cx,
    lipR: picInner - config.lipInset,
    pic,
    picInner,
    picOuter,
    rimR,
    /** Where the rim crosses the crest line, from the leading edge. */
    rimX: cx + rimR,
    step,
  };
};

export type Wheel = ReturnType<typeof makeWheel>;

/** Beside the copy: frames 224 wide (across the band) by 126 tall (along it). */
export const WIDE = makeWheel({
  captionGap: 36,
  crestX: 300,
  lipInset: 96,
  lipWidth: 160,
  radial: 224,
  radius: 1000,
  spokeFade: 350,
  stripW: 40,
  tangential: 126,
});

/**
 * Above the copy, mirrored: frames run 224 along the band (on screen, their
 * width) by 126 across it. A larger radius keeps the wider frames from
 * bending too hard.
 */
export const NARROW = makeWheel({
  captionGap: 30,
  crestX: 230,
  lipInset: 70,
  lipWidth: 120,
  radial: 126,
  radius: 1100,
  spokeFade: 260,
  stripW: 32,
  tangential: 224,
});

/** Gap from the wide wheel's rim to the copy column. */
const COPY_GAP = 390;

/**
 * Where things sit along the stage's main axis, in design units. The section
 * multiplies these by --reel-s in CSS, so the copy lines up with the wheel
 * before any JavaScript runs.
 */
export const LAYOUT = {
  /** Room for a caption between the rim and the copy. */
  captionWidth: COPY_GAP - WIDE.config.captionGap - 40,
  copyX: WIDE.rimX + COPY_GAP,
  /** The narrow wheel's rim, from the top: its captions hang below it. */
  narrowRim: NARROW.rimX,
};
```
