# Carousel

> Scroll-snap carousel with mouse drag, loop, autoplay that pauses itself, and dots that fill with the timer.

Category: Layout. Docs: https://neonkit.heyaadi.com/components/carousel

## Installation

```bash
npx shadcn@latest add https://neonkit.heyaadi.com/r/carousel.json
```

With the `@neonkit` namespace configured: `npx shadcn@latest add @neonkit/carousel`.

npm dependencies: none. Also needs `lib/utils.ts` (`cn`) and `lib/neon.ts` (`neonVars`), which the CLI adds.

Builds on: [Icons](https://neonkit.heyaadi.com/components/icons.md). The CLI installs them too.

## Usage

```tsx
import { Carousel, CarouselContent, CarouselDots, CarouselItem, CarouselNext, CarouselPrevious } from "@/components/ui/carousel"

<Carousel aria-label="Releases" className="sm:[--slides:2] lg:[--slides:3]">
  <CarouselContent>
    <CarouselItem>…</CarouselItem>
  </CarouselContent>
  <CarouselDots />
  <CarouselPrevious />
  <CarouselNext />
</Carousel>
```

## Examples

### Release cards

One card on phones, two on tablets, three on desktop, set with --slides. Drag with the mouse or swipe.

```tsx
"use client";

import {
  Carousel,
  CarouselContent,
  CarouselDots,
  CarouselItem,
  CarouselNext,
  CarouselPrevious,
} from "@/components/ui/carousel";

const releases = [
  { version: "1.6", date: "Oct 9", title: "Data Table", body: "Sorting, filters, selection and pagination in one component. Fast at 1,000 rows." },
  { version: "1.5", date: "Sep 24", title: "Drawer", body: "Snap points, velocity-aware flicks and nested drawers that peek out from under each other." },
  { version: "1.4", date: "Sep 3", title: "Menubar", body: "The full APG keyboard model, hover switching and a filament that follows the open menu." },
  { version: "1.3", date: "Aug 14", title: "Tree View", body: "Typeahead, lazy folders and a lit guide down the branch you're in." },
  { version: "1.2", date: "Jul 22", title: "Glow Chart", body: "Lines that read like tubes, with a crosshair that lights the values it crosses." },
  { version: "1.1", date: "Jun 30", title: "Command", body: "Fuzzy search with ranked groups, nested pages and recent items." },
];

export default function CarouselDemo() {
  return (
    <Carousel aria-label="Recent releases" className="w-full max-w-4xl gap-5 sm:[--slides:2] lg:[--slides:3]">
      <CarouselContent>
        {releases.map((release) => (
          <CarouselItem key={release.version}>
            <article className="grid h-full content-start gap-3 rounded-xl border border-white/10 bg-white/[0.02] p-5">
              <p className="flex items-baseline justify-between text-[13px] text-white/50">
                <span className="font-mono text-white/70">v{release.version}</span>
                {release.date}
              </p>
              <h3 className="text-lg font-medium tracking-[-0.01em] text-white/90">{release.title}</h3>
              <p className="text-sm leading-6 text-white/55">{release.body}</p>
            </article>
          </CarouselItem>
        ))}
      </CarouselContent>
      <div className="flex items-center justify-between gap-4">
        <CarouselDots />
        <div className="flex gap-2">
          <CarouselPrevious />
          <CarouselNext />
        </div>
      </div>
    </Carousel>
  );
}
```

### Autoplay

The active dot is a tube that fills with the timer. It pauses on hover, focus and hidden tabs, keeps its place, and never runs under reduced motion.

```tsx
"use client";

import {
  Carousel,
  CarouselContent,
  CarouselDots,
  CarouselItem,
  CarouselPlayToggle,
} from "@/components/ui/carousel";

const quotes = [
  {
    quote: "We replaced four internal table wrappers with one component and deleted 3,100 lines.",
    name: "Hana Okafor",
    role: "Staff engineer, Ledgerline",
  },
  {
    quote: "Our on-call dashboard finally reads at 3am. The live dots tell you what's moving without shouting.",
    name: "Tomás Reyes",
    role: "SRE lead, Pelican Air",
  },
  {
    quote: "Designers stopped filing tickets about focus rings. That has never happened before.",
    name: "Ines Lindqvist",
    role: "Design systems, Coral",
  },
];

export default function CarouselAutoplay() {
  return (
    <Carousel aria-label="What teams say" autoplay={6000} loop className="w-full max-w-xl gap-6">
      <CarouselContent>
        {quotes.map((q) => (
          <CarouselItem key={q.name}>
            <figure className="grid gap-5 px-1">
              <blockquote className="text-xl leading-8 tracking-[-0.01em] text-white/90 sm:text-2xl sm:leading-9">
                “{q.quote}”
              </blockquote>
              <figcaption className="text-sm text-white/55">
                <span className="text-white/80">{q.name}</span> · {q.role}
              </figcaption>
            </figure>
          </CarouselItem>
        ))}
      </CarouselContent>
      <div className="flex items-center gap-3">
        <CarouselPlayToggle className="size-8" />
        <CarouselDots />
      </div>
    </Carousel>
  );
}
```

### Gallery with progress

Fractional --slides shows the next card peeking in. The lit segment tracks the scroll, drags included.

```tsx
"use client";

import {
  Carousel,
  CarouselContent,
  CarouselItem,
  CarouselNext,
  CarouselPrevious,
  CarouselProgress,
} from "@/components/ui/carousel";
import { NeonSign } from "@/components/ui/neon-sign";

const signs = [
  { text: "OPEN", place: "Bar Alfama, Lisbon", time: "01:12", color: "orange" },
  { text: "RAMEN", place: "Shibuya, Tokyo", time: "22:40", color: "pink" },
  { text: "24H", place: "Laundromat, Reykjavík", time: "03:58", color: "cyan" },
  { text: "TACOS", place: "Roma Norte, Mexico City", time: "19:44", color: "lime" },
  { text: "HOTEL", place: "Mong Kok, Hong Kong", time: "02:17", color: "red" },
  { text: "VINYL", place: "Bjørvika, Oslo", time: "20:31", color: "violet" },
  { text: "BAR", place: "Kreuzberg, Berlin", time: "00:05", color: "amber" },
];

// Drag with the mouse, swipe on touch; the tube under the strip follows the scroll.
export default function CarouselGallery() {
  return (
    <Carousel aria-label="Signs from night walks" className="w-full max-w-4xl gap-4 [--gap:12px] [--slides:1.2] sm:[--slides:2.4]">
      <CarouselContent>
        {signs.map((sign) => (
          <CarouselItem key={sign.place}>
            <figure className="grid aspect-[4/3] grid-rows-[1fr_auto] overflow-hidden rounded-xl border border-white/10 bg-[#07080c]">
              <div className="grid place-items-center">
                <NeonSign color={sign.color} size="sm" flicker="clean">
                  {sign.text}
                </NeonSign>
              </div>
              <figcaption className="flex items-baseline justify-between gap-3 border-t border-white/[0.06] px-4 py-3">
                <span className="truncate text-sm text-white/80">{sign.place}</span>
                <span className="font-mono text-xs text-white/50">{sign.time}</span>
              </figcaption>
            </figure>
          </CarouselItem>
        ))}
      </CarouselContent>
      <div className="flex items-center gap-4">
        <CarouselProgress className="flex-1" />
        <CarouselPrevious className="size-8" />
        <CarouselNext className="size-8" />
      </div>
    </Carousel>
  );
}
```

### Vertical

orientation="vertical" scrolls on the y axis; give the viewport a height.

```tsx
"use client";

import {
  Carousel,
  CarouselContent,
  CarouselDots,
  CarouselItem,
  CarouselNext,
  CarouselPrevious,
} from "@/components/ui/carousel";

const steps = [
  { title: "Connect your repo", body: "Pick a GitHub org. We read the lockfile to find your framework and Node version." },
  { title: "Set environment variables", body: "Paste a .env file or pull from Vault. Secrets never show in build logs." },
  { title: "Choose regions", body: "Frankfurt and Virginia by default. Add Tokyo for under 60ms in APAC." },
  { title: "Ship the first deploy", body: "Every push to main goes live; every pull request gets its own preview URL." },
];

export default function CarouselVertical() {
  return (
    <Carousel aria-label="Setup steps" orientation="vertical" className="w-full max-w-md grid-cols-[1fr_auto] gap-5">
      <CarouselContent viewportClassName="h-44">
        {steps.map((step, i) => (
          <CarouselItem key={step.title} className="grid content-center gap-2">
            <p className="text-[13px] tabular-nums text-white/50">Step {i + 1} of {steps.length}</p>
            <h3 className="text-xl font-medium tracking-[-0.01em] text-white/90">{step.title}</h3>
            <p className="text-sm leading-6 text-white/55">{step.body}</p>
          </CarouselItem>
        ))}
      </CarouselContent>
      <div className="flex flex-col items-center justify-center gap-2">
        <CarouselPrevious className="size-8" />
        <CarouselDots />
        <CarouselNext className="size-8" />
      </div>
    </Carousel>
  );
}
```

## API reference

### Carousel

Renders a section with aria-roledescription="carousel". Give it an aria-label. Accepts every native section attribute.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `orientation` | `"horizontal" \| "vertical"` | `"horizontal"` | Scroll axis. Arrow keys follow it. |
| `loop` | `boolean` | `false` | Previous and next wrap around at the ends. |
| `autoplay` | `boolean \| number` | `false` | Advances on a timer; a number is the delay in ms (5000 by default). Always wraps. |
| `defaultIndex` | `number` | `0` | Starting position, jumped to without animation. |
| `onSlideChange` | `(index: number) => void` | — | Called when the snap position changes, by any means. |
| `apiRef` | `Ref<CarouselApi>` | — | scrollTo(i), next(), prev(), getIndex(), getCount(), play() and pause(). |
| `color` | `NeonColor` | `inherited` | Palette name or hex. Falls back to --neon-accent, then cyan. |

### CSS variables

Set on Carousel, with responsive variants, e.g. className="[--slides:1] md:[--slides:3]".

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `--slides` | `number` | `1` | Slides in view. Fractions let the next one peek in. |
| `--gap` | `length` | `16px` | Space between slides. |

### CarouselContent

The scroll-snap viewport and its track. Accepts every native div attribute for the track.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `viewportClassName` | `string` | — | Classes for the scrolling viewport. Vertical carousels need a height here. |

### CarouselItem

One slide: role="group", aria-roledescription="slide", labelled "2 of 6" unless you pass aria-label.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |

### CarouselPrevious, CarouselNext

Icon buttons, disabled at the ends unless loop is on. Pass children to replace the chevron.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |

### CarouselDots, CarouselProgress, CarouselPlayToggle

Dots are one button per snap position. Progress is a decorative track with a lit segment. The play toggle only renders when autoplay is on.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |

## Accessibility

- Built on native scroll snap: touch, trackpads and the mouse wheel all scroll it, and nothing is measured per frame.
- The viewport is a polite live region, so assistive tech hears the new slide after next or previous. It goes quiet while autoplay runs.
- Autoplay stops for hover, keyboard focus and hidden tabs, and the pause button stops it for good. WCAG 2.2.2 needs that button, so render it.
- Arrow keys move between slides when focus is on a dot, a button or the viewport.
- A drag never fires the click it ended on, so slides can hold links.
- Under prefers-reduced-motion, scrolling jumps instead of gliding and autoplay is off.

## Source

`components/ui/carousel.tsx`

```tsx
"use client";

import * as React from "react";
import { ChevronLeft, ChevronRight, Pause, Play } from "@/components/ui/icons";

import { cn } from "@/lib/utils";
import { neonVars, type NeonColor } from "@/lib/neon";

type Orientation = "horizontal" | "vertical";

const DEFAULT_DELAY = 5000;
const DRAG_THRESHOLD = 6;

const subscribeReducedMotion = (callback: () => void) => {
  const query = window.matchMedia?.("(prefers-reduced-motion: reduce)");
  query?.addEventListener?.("change", callback);
  return () => query?.removeEventListener?.("change", callback);
};
const getReducedMotion = () => window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false;

const FOCUSABLE =
  'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),summary,iframe,[contenteditable="true"],[tabindex]:not([tabindex="-1"])';

function useReducedMotion() {
  return React.useSyncExternalStore(subscribeReducedMotion, getReducedMotion, () => false);
}

export interface CarouselApi {
  scrollTo: (index: number) => void;
  next: () => void;
  prev: () => void;
  getIndex: () => number;
  /** Snap positions, which is fewer than slides when several show at once. */
  getCount: () => number;
  play: () => void;
  pause: () => void;
}

interface CarouselContextValue {
  orientation: Orientation;
  trackId: string;
  setViewport: (node: HTMLDivElement | null) => void;
  index: number;
  count: number;
  canPrev: boolean;
  canNext: boolean;
  scrollTo: (index: number) => void;
  next: () => void;
  prev: () => void;
  autoplay: boolean;
  playing: boolean;
  setUserPaused: (paused: boolean) => void;
  live: "off" | "polite";
  onDragStart: (event: React.PointerEvent<HTMLDivElement>) => void;
  onScroll: () => void;
}

const CarouselContext = React.createContext<CarouselContextValue | null>(null);

function useCarousel(component: string) {
  const context = React.useContext(CarouselContext);
  if (!context) throw new Error(`<${component}> must be rendered inside <Carousel>.`);
  return context;
}

export interface CarouselProps extends Omit<React.HTMLAttributes<HTMLElement>, "color"> {
  /** Palette name or any hex color. Inherits `--neon-accent` when omitted. */
  color?: NeonColor;
  orientation?: Orientation;
  /** Previous and next wrap around at the ends. */
  loop?: boolean;
  /** Advances on its own; a number sets the delay in ms. Pauses on hover, focus and hidden tabs; off under reduced motion. */
  autoplay?: boolean | number;
  defaultIndex?: number;
  onSlideChange?: (index: number) => void;
  /** Imperative control: scrollTo, next, prev, play, pause. */
  apiRef?: React.Ref<CarouselApi>;
}

const Carousel = React.forwardRef<HTMLElement, CarouselProps>(
  (
    {
      className,
      style,
      color,
      orientation = "horizontal",
      loop = false,
      autoplay = false,
      defaultIndex = 0,
      onSlideChange,
      apiRef,
      onKeyDown,
      children,
      ...props
    },
    ref,
  ) => {
    const trackId = React.useId();
    const rootRef = React.useRef<HTMLElement>(null);
    React.useImperativeHandle(ref, () => rootRef.current as HTMLElement);
    const viewportRef = React.useRef<HTMLDivElement>(null);
    const snaps = React.useRef<number[]>([0]);
    const target = React.useRef<number | null>(null);
    const dragging = React.useRef(false);
    const elapsed = React.useRef(0);
    const reduceMotion = useReducedMotion();
    const horizontal = orientation === "horizontal";

    const [index, setIndex] = React.useState(0);
    const [count, setCount] = React.useState(1);
    const [userPaused, setUserPaused] = React.useState(false);
    const [hovered, setHovered] = React.useState(false);
    const [focused, setFocused] = React.useState(false);
    const [hidden, setHidden] = React.useState(false);

    const delay = typeof autoplay === "number" ? autoplay : DEFAULT_DELAY;
    const autoplayOn = autoplay !== false && !reduceMotion;
    const playing = autoplayOn && !userPaused;
    const running = playing && !hovered && !focused && !hidden;

    const setVar = (name: string, value: number) => rootRef.current?.style.setProperty(name, String(value));

    const measure = React.useCallback(() => {
      const viewport = viewportRef.current;
      if (!viewport) return;
      const items = Array.from(viewport.querySelectorAll<HTMLElement>("[data-carousel-item]")).filter(
        (item) => item.closest("[data-carousel-viewport]") === viewport,
      );
      const view = viewport.getBoundingClientRect();
      const scroll = horizontal ? viewport.scrollLeft : viewport.scrollTop;
      const max = horizontal ? viewport.scrollWidth - viewport.clientWidth : viewport.scrollHeight - viewport.clientHeight;
      const positions: number[] = [];
      for (const item of items) {
        const rect = item.getBoundingClientRect();
        const pos = Math.min(Math.max(0, (horizontal ? rect.left - view.left : rect.top - view.top) + scroll), Math.max(0, max));
        if (!positions.length || pos - positions[positions.length - 1] > 2) positions.push(pos);
      }
      snaps.current = positions.length ? positions : [0];
      items.forEach((item, i) => {
        if (!item.hasAttribute("data-labelled")) item.setAttribute("aria-label", `${i + 1} of ${items.length}`);
      });
      const size = horizontal ? viewport.scrollWidth : viewport.scrollHeight;
      setVar("--carousel-visible", size ? (horizontal ? viewport.clientWidth : viewport.clientHeight) / size : 1);
      setCount(snaps.current.length);
    }, [horizontal]);

    const nearest = (pos: number) => {
      let best = 0;
      snaps.current.forEach((snap, i) => {
        if (Math.abs(snap - pos) < Math.abs(snaps.current[best] - pos)) best = i;
      });
      return best;
    };

    const onScroll = () => {
      const viewport = viewportRef.current;
      if (!viewport) return;
      const pos = horizontal ? viewport.scrollLeft : viewport.scrollTop;
      const max = horizontal ? viewport.scrollWidth - viewport.clientWidth : viewport.scrollHeight - viewport.clientHeight;
      setVar("--carousel-progress", max > 0 ? Math.min(1, Math.max(0, pos / max)) : 0);
      if (target.current !== null) {
        if (Math.abs(pos - snaps.current[target.current]) > 2) return;
        target.current = null;
        if (!dragging.current) viewport.style.scrollSnapType = "";
      }
      if (dragging.current) return;
      setIndex(nearest(pos));
    };

    const scrollTo = React.useCallback(
      (next: number, instant = false) => {
        const viewport = viewportRef.current;
        if (!viewport) return;
        const last = snaps.current.length - 1;
        const i = Math.min(Math.max(0, next), last);
        target.current = i;
        setIndex(i);
        elapsed.current = 0;
        const position = snaps.current[i];
        viewport.scrollTo({
          [horizontal ? "left" : "top"]: position,
          behavior: instant || reduceMotion ? "auto" : "smooth",
        });
        // Release the lock if the browser never reports reaching the snap (e.g. already there).
        const current = horizontal ? viewport.scrollLeft : viewport.scrollTop;
        if (Math.abs(current - position) < 2) {
          target.current = null;
          viewport.style.scrollSnapType = "";
        }
      },
      [horizontal, reduceMotion],
    );

    const indexRef = React.useRef(index);
    React.useLayoutEffect(() => {
      indexRef.current = index;
    });
    const next = React.useCallback(() => {
      const i = indexRef.current;
      const last = snaps.current.length - 1;
      scrollTo(i >= last ? (loop ? 0 : last) : i + 1);
    }, [loop, scrollTo]);
    const prev = React.useCallback(() => {
      const i = indexRef.current;
      const last = snaps.current.length - 1;
      scrollTo(i <= 0 ? (loop ? last : 0) : i - 1);
    }, [loop, scrollTo]);

    React.useImperativeHandle(
      apiRef,
      () => ({
        scrollTo: (i: number) => scrollTo(i),
        next,
        prev,
        getIndex: () => indexRef.current,
        getCount: () => snaps.current.length,
        play: () => setUserPaused(false),
        pause: () => setUserPaused(true),
      }),
      [scrollTo, next, prev],
    );

    // Measure on mount and whenever the viewport or track changes size.
    React.useEffect(() => {
      const viewport = viewportRef.current;
      if (!viewport) return;
      measure();
      if (defaultIndex > 0) scrollTo(defaultIndex, true);
      if (typeof ResizeObserver === "undefined") return;
      const observer = new ResizeObserver(() => measure());
      observer.observe(viewport);
      if (viewport.firstElementChild) observer.observe(viewport.firstElementChild);
      return () => observer.disconnect();
      // defaultIndex only applies on mount.
      // eslint-disable-next-line react-hooks/exhaustive-deps
    }, [measure]);

    const onSlideChangeRef = React.useRef(onSlideChange);
    React.useEffect(() => {
      onSlideChangeRef.current = onSlideChange;
    });
    const reported = React.useRef(index);
    React.useEffect(() => {
      elapsed.current = 0;
      if (reported.current === index) return;
      reported.current = index;
      onSlideChangeRef.current?.(index);
    }, [index]);

    React.useEffect(() => {
      const update = () => setHidden(document.visibilityState === "hidden");
      document.addEventListener("visibilitychange", update);
      return () => document.removeEventListener("visibilitychange", update);
    }, []);

    // The autoplay clock. It keeps its place through a pause, and the active dot fills with it.
    React.useEffect(() => {
      if (!playing) {
        setVar("--carousel-autoplay", 1);
        return;
      }
      setVar("--carousel-autoplay", elapsed.current / delay);
      if (!running) return;
      let frame = 0;
      let last = performance.now();
      const tick = (now: number) => {
        elapsed.current += now - last;
        last = now;
        if (elapsed.current >= delay) {
          elapsed.current = 0;
          const i = indexRef.current;
          scrollTo(i >= snaps.current.length - 1 ? 0 : i + 1);
        }
        setVar("--carousel-autoplay", Math.min(1, elapsed.current / delay));
        frame = requestAnimationFrame(tick);
      };
      frame = requestAnimationFrame(tick);
      return () => cancelAnimationFrame(frame);
    }, [playing, running, delay, scrollTo]);

    const onDragStart = (event: React.PointerEvent<HTMLDivElement>) => {
      const viewport = viewportRef.current;
      if (!viewport || event.pointerType !== "mouse" || event.button !== 0) return;
      const origin = horizontal ? event.clientX : event.clientY;
      const start = horizontal ? viewport.scrollLeft : viewport.scrollTop;
      let moved = false;
      let lastPos = origin;
      let lastTime = performance.now();
      let velocity = 0;

      const move = (e: PointerEvent) => {
        const pos = horizontal ? e.clientX : e.clientY;
        if (!moved && Math.abs(pos - origin) < DRAG_THRESHOLD) return;
        if (!moved) {
          moved = true;
          dragging.current = true;
          target.current = null;
          viewport.style.scrollSnapType = "none";
          viewport.dataset.dragging = "";
        }
        const now = performance.now();
        velocity = (pos - lastPos) / Math.max(1, now - lastTime);
        lastPos = pos;
        lastTime = now;
        if (horizontal) viewport.scrollLeft = start - (pos - origin);
        else viewport.scrollTop = start - (pos - origin);
      };
      const up = () => {
        window.removeEventListener("pointermove", move);
        window.removeEventListener("pointerup", up);
        window.removeEventListener("pointercancel", up);
        if (!moved) return;
        dragging.current = false;
        delete viewport.dataset.dragging;
        // Swallow the click that ends a drag, so dragging across a link doesn't open it.
        const block = (e: MouseEvent) => {
          e.preventDefault();
          e.stopPropagation();
        };
        window.addEventListener("click", block, { capture: true, once: true });
        setTimeout(() => window.removeEventListener("click", block, { capture: true }), 0);

        const pos = horizontal ? viewport.scrollLeft : viewport.scrollTop;
        let i = nearest(pos - velocity * 180);
        const from = indexRef.current;
        // A deliberate flick always moves at least one slide.
        if (i === from && Math.abs(velocity) > 0.3) i = from + (velocity < 0 ? 1 : -1);
        scrollTo(i);
      };
      window.addEventListener("pointermove", move);
      window.addEventListener("pointerup", up);
      window.addEventListener("pointercancel", up);
    };

    const setViewport = React.useCallback((node: HTMLDivElement | null) => {
      viewportRef.current = node;
    }, []);

    const last = count - 1;
    const context: CarouselContextValue = {
      orientation,
      trackId,
      setViewport,
      index,
      count,
      canPrev: loop || index > 0,
      canNext: loop || index < last,
      scrollTo,
      next,
      prev,
      autoplay: autoplayOn,
      playing,
      setUserPaused,
      live: running ? "off" : "polite",
      onDragStart,
      onScroll,
    };

    return (
      <CarouselContext.Provider value={context}>
        <section
          ref={rootRef}
          aria-roledescription="carousel"
          data-orientation={orientation}
          style={neonVars(color, style)}
          onPointerEnter={(event) => event.pointerType === "mouse" && setHovered(true)}
          onPointerLeave={() => setHovered(false)}
          onFocus={() => setFocused(true)}
          onBlur={(event) => {
            if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setFocused(false);
          }}
          onKeyDown={(event) => {
            onKeyDown?.(event);
            if (event.defaultPrevented) return;
            const from = event.target as HTMLElement;
            if (!from.closest("[data-carousel-control],[data-carousel-viewport]")) return;
            const back = horizontal ? "ArrowLeft" : "ArrowUp";
            const forward = horizontal ? "ArrowRight" : "ArrowDown";
            if (event.key === back) prev();
            else if (event.key === forward) next();
            else if (event.key === "Home" && from.closest("[data-carousel-viewport]")) scrollTo(0);
            else if (event.key === "End" && from.closest("[data-carousel-viewport]")) scrollTo(count - 1);
            else return;
            event.preventDefault();
          }}
          className={cn(
            "[--neon:var(--neon-accent,34_211_238)] [--slides:1] [--gap:16px]",
            "relative grid min-w-0",
            className,
          )}
          {...props}
        >
          {children}
        </section>
      </CarouselContext.Provider>
    );
  },
);
Carousel.displayName = "Carousel";

export interface CarouselContentProps extends React.HTMLAttributes<HTMLDivElement> {
  /** Classes for the scrolling viewport around the track. Give it a height in vertical carousels. */
  viewportClassName?: string;
}

const CarouselContent = React.forwardRef<HTMLDivElement, CarouselContentProps>(
  ({ className, viewportClassName, ...props }, ref) => {
    const { setViewport, live, onScroll, onDragStart, trackId, orientation } = useCarousel("CarouselContent");
    const horizontal = orientation === "horizontal";
    const viewportRef = React.useRef<HTMLDivElement | null>(null);
    const viewport = React.useCallback(
      (node: HTMLDivElement | null) => {
        viewportRef.current = node;
        setViewport(node);
      },
      [setViewport],
    );
    // Slides with their own links or buttons already give the keyboard a way in, so the viewport only takes a
    // tab stop when they don't.
    const subscribe = React.useCallback((callback: () => void) => {
      const node = viewportRef.current;
      if (!node || typeof MutationObserver === "undefined") return () => {};
      const observer = new MutationObserver(callback);
      observer.observe(node, { childList: true, subtree: true, attributes: true, attributeFilter: ["href", "disabled", "tabindex"] });
      return () => observer.disconnect();
    }, []);
    const tabbable = React.useSyncExternalStore(
      subscribe,
      () => !viewportRef.current?.querySelector(FOCUSABLE),
      () => true,
    );
    return (
      <div
        ref={viewport}
        data-carousel-viewport=""
        // Focusable so the track scrolls from the keyboard: arrows, Home and End are handled on the root.
        tabIndex={tabbable ? 0 : -1}
        aria-live={live}
        onScroll={onScroll}
        onPointerDown={onDragStart}
        onDragStart={(event) => event.preventDefault()}
        className={cn(
          "min-h-0 min-w-0 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
          // Only the scrolling axis is contained, so a vertical wheel over a horizontal carousel still moves the page.
          horizontal
            ? "snap-x snap-mandatory overflow-x-auto overflow-y-hidden overscroll-x-contain"
            : "snap-y snap-mandatory overflow-y-auto overflow-x-hidden overscroll-y-contain",
          "cursor-grab data-[dragging]:cursor-grabbing data-[dragging]:select-none",
          "outline-none focus-visible:ring-2 focus-visible:ring-[rgb(var(--neon)/0.6)] focus-visible:ring-offset-2 focus-visible:ring-offset-[#05060a]",
          viewportClassName,
        )}
      >
        <div
          ref={ref}
          id={trackId}
          className={cn("flex gap-[var(--gap)]", horizontal ? "flex-row" : "h-full flex-col", className)}
          {...props}
        />
      </div>
    );
  },
);
CarouselContent.displayName = "CarouselContent";

const CarouselItem = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
  ({ className, "aria-label": ariaLabel, ...props }, ref) => (
    <div
      ref={ref}
      role="group"
      aria-roledescription="slide"
      aria-label={ariaLabel}
      data-labelled={ariaLabel ? "" : undefined}
      data-carousel-item=""
      className={cn(
        "min-h-0 min-w-0 shrink-0 grow-0 snap-start basis-[calc((100%-(var(--slides)-1)*var(--gap))/var(--slides))]",
        className,
      )}
      {...props}
    />
  ),
);
CarouselItem.displayName = "CarouselItem";

const arrowClass = cn(
  "inline-flex size-9 shrink-0 cursor-pointer items-center justify-center rounded-full border border-white/10 bg-[#0a0c12]/80 text-white/70 outline-none",
  "transition-[background-color,border-color,color,box-shadow,transform,opacity] duration-150 ease-out active:scale-[0.94]",
  "hover:border-[rgb(var(--neon)/0.55)] hover:text-[rgb(var(--neon))] hover:shadow-[0_0_14px_rgb(var(--neon)/CALC(0.25*var(--neon-glow,1)))]",
  "focus-visible:ring-2 focus-visible:ring-[rgb(var(--neon)/0.6)] focus-visible:ring-offset-2 focus-visible:ring-offset-[#05060a]",
  "disabled:pointer-events-none disabled:opacity-30 [&_svg]:size-[18px]",
);

type ArrowProps = React.ButtonHTMLAttributes<HTMLButtonElement>;

const CarouselPrevious = React.forwardRef<HTMLButtonElement, ArrowProps>(({ className, onClick, children, ...props }, ref) => {
  const carousel = useCarousel("CarouselPrevious");
  return (
    <button
      ref={ref}
      type="button"
      data-carousel-control=""
      aria-label="Previous slide"
      aria-controls={carousel.trackId}
      disabled={!carousel.canPrev}
      onClick={(event) => {
        onClick?.(event);
        if (!event.defaultPrevented) carousel.prev();
      }}
      className={cn(arrowClass, className)}
      {...props}
    >
      {children ?? <ChevronLeft aria-hidden className={cn(carousel.orientation === "vertical" && "rotate-90")} />}
    </button>
  );
});
CarouselPrevious.displayName = "CarouselPrevious";

const CarouselNext = React.forwardRef<HTMLButtonElement, ArrowProps>(({ className, onClick, children, ...props }, ref) => {
  const carousel = useCarousel("CarouselNext");
  return (
    <button
      ref={ref}
      type="button"
      data-carousel-control=""
      aria-label="Next slide"
      aria-controls={carousel.trackId}
      disabled={!carousel.canNext}
      onClick={(event) => {
        onClick?.(event);
        if (!event.defaultPrevented) carousel.next();
      }}
      className={cn(arrowClass, className)}
      {...props}
    >
      {children ?? <ChevronRight aria-hidden className={cn(carousel.orientation === "vertical" && "rotate-90")} />}
    </button>
  );
});
CarouselNext.displayName = "CarouselNext";

/** One button per snap position. The active one is a tube that fills with the autoplay clock. */
const CarouselDots = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
  ({ className, ...props }, ref) => {
    const carousel = useCarousel("CarouselDots");
    const vertical = carousel.orientation === "vertical";
    return (
      <div
        ref={ref}
        role="group"
        aria-label="Choose slide"
        className={cn("flex items-center", vertical && "flex-col", className)}
        {...props}
      >
        {Array.from({ length: carousel.count }, (_, i) => {
          const active = i === carousel.index;
          return (
            <button
              key={i}
              type="button"
              data-carousel-control=""
              aria-label={`Slide ${i + 1} of ${carousel.count}`}
              aria-controls={carousel.trackId}
              aria-current={active || undefined}
              onClick={() => carousel.scrollTo(i)}
              className={cn(
                "group/dot flex cursor-pointer items-center justify-center outline-none",
                vertical ? "w-6 py-[3px]" : "h-6 px-[3px]",
              )}
            >
              <span
                aria-hidden
                className={cn(
                  "relative block overflow-hidden rounded-full",
                  "transition-[width,height,background-color,box-shadow] duration-300 ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-none",
                  "group-focus-visible/dot:ring-2 group-focus-visible/dot:ring-[rgb(var(--neon)/0.6)] group-focus-visible/dot:ring-offset-2 group-focus-visible/dot:ring-offset-[#05060a]",
                  vertical ? "w-1.5" : "h-1.5",
                  active
                    ? cn(vertical ? "h-6" : "w-6", "bg-[rgb(var(--neon)/0.2)] shadow-[inset_0_0_0_1px_rgb(var(--neon)/0.35)]")
                    : cn(vertical ? "h-1.5" : "w-1.5", "bg-white/25 group-hover/dot:bg-white/50"),
                )}
              >
                {active && (
                  <span
                    className={cn(
                      "absolute inset-0 rounded-full bg-[rgb(var(--neon))] shadow-[0_0_6px_rgb(var(--neon)/CALC(0.9*var(--neon-glow,1))),0_0_14px_rgb(var(--neon)/CALC(0.5*var(--neon-glow,1)))]",
                      vertical
                        ? "origin-top scale-y-[var(--carousel-autoplay,1)]"
                        : "origin-left scale-x-[var(--carousel-autoplay,1)]",
                    )}
                  />
                )}
              </span>
            </button>
          );
        })}
      </div>
    );
  },
);
CarouselDots.displayName = "CarouselDots";

/** A thin track with a lit segment that follows the scroll position, drag included. */
const CarouselProgress = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
  ({ className, ...props }, ref) => {
    const carousel = useCarousel("CarouselProgress");
    const vertical = carousel.orientation === "vertical";
    return (
      <div
        ref={ref}
        aria-hidden
        className={cn("relative overflow-hidden rounded-full bg-white/[0.08]", vertical ? "h-full w-[2px]" : "h-[2px] w-full", className)}
        {...props}
      >
        <span
          className={cn(
            "absolute rounded-full bg-[rgb(var(--neon))] shadow-[0_0_6px_rgb(var(--neon)/CALC(0.9*var(--neon-glow,1)))]",
            vertical
              ? "inset-x-0 top-0 h-[calc(var(--carousel-visible,1)*100%)] translate-y-[calc(var(--carousel-progress,0)*(1/var(--carousel-visible,1)-1)*100%)]"
              : "inset-y-0 left-0 w-[calc(var(--carousel-visible,1)*100%)] translate-x-[calc(var(--carousel-progress,0)*(1/var(--carousel-visible,1)-1)*100%)]",
          )}
        />
      </div>
    );
  },
);
CarouselProgress.displayName = "CarouselProgress";

/** Pause and play for autoplaying carousels. Renders nothing when autoplay is off. */
const CarouselPlayToggle = React.forwardRef<HTMLButtonElement, ArrowProps>(({ className, onClick, ...props }, ref) => {
  const carousel = useCarousel("CarouselPlayToggle");
  if (!carousel.autoplay) return null;
  return (
    <button
      ref={ref}
      type="button"
      data-carousel-control=""
      aria-label={carousel.playing ? "Pause slides" : "Play slides"}
      aria-controls={carousel.trackId}
      onClick={(event) => {
        onClick?.(event);
        if (!event.defaultPrevented) carousel.setUserPaused(carousel.playing);
      }}
      className={cn(arrowClass, className)}
      {...props}
    >
      {carousel.playing ? <Pause aria-hidden /> : <Play aria-hidden />}
    </button>
  );
});
CarouselPlayToggle.displayName = "CarouselPlayToggle";

export {
  Carousel,
  CarouselContent,
  CarouselItem,
  CarouselPrevious,
  CarouselNext,
  CarouselDots,
  CarouselProgress,
  CarouselPlayToggle,
};
```
