# Specimen Carousel (`specimen-carousel`)

> An editorial carousel for type, product, or archive specimens with numbered tabs and hard snap motion.

- **Docs:** https://www.mellowui.com/components/specimen-carousel
- **Markdown:** https://www.mellowui.com/components/specimen-carousel.md
- **Registry:** https://www.mellowui.com/r/specimen-carousel.json
- **Tool prompt:** https://www.mellowui.com/api/prompt/specimen-carousel
- **Categories:** display, interactive, carousel
- **Dependencies:** none

## AI prompt

Add a SpecimenCarousel component from the mellow library. It renders an editorial carousel with a large specimen stage, numbered tab strip, keyboard arrow navigation, and optional autoplay. Pass items with title, kicker, sample, meta, and description. Key props: items, initialIndex, autoPlay, interval, onIndexChange.

## Install

```bash
npx shadcn@latest add https://mellowui.com/r/specimen-carousel.json
```

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `items` | `SpecimenItem[]` | — | Carousel items. Each item supports title, kicker, description, meta, and sample. |
| `initialIndex` | `number` | `0` | Initial active item index. |
| `autoPlay` | `boolean` | `false` | Automatically advance through items. Disabled when reduced motion is preferred. |
| `interval` | `number` | `4200` | Autoplay interval in milliseconds. |
| `className` | `string` | — | Additional CSS classes. |
| `onIndexChange` | `(index: number) => void` | — | Called whenever the active index changes. |

## Design notes

- Client component (`"use client"`) — required for animation and refs.
- Uses Mellow design tokens: `--ink`, `--ink-rgb`, `--background`, `--background-rgb`, `--rule`, `--font-sans`, `--font-serif`, `--font-mono`. Tokens auto-flip between light and dark themes — never hardcode colors.
- Respects `prefers-reduced-motion: reduce` where applicable.
- Self-contained — no shared utilities. Copy-paste, not a package.

## Source

```tsx
"use client";

import React, { useEffect, useMemo, useState } from "react";

export interface SpecimenItem {
  title: string;
  kicker?: string;
  description?: string;
  meta?: string;
  sample?: string;
}

export interface SpecimenCarouselProps {
  items?: SpecimenItem[];
  initialIndex?: number;
  autoPlay?: boolean;
  interval?: number;
  className?: string;
  style?: React.CSSProperties;
  onIndexChange?: (index: number) => void;
}

const DEFAULT_ITEMS: SpecimenItem[] = [
  {
    kicker: "Specimen 01",
    title: "Editorial",
    sample: "Aa",
    meta: "72 pt / italic",
    description:
      "A slow snap carousel for type samples, product stories, and image-led editorial modules.",
  },
  {
    kicker: "Specimen 02",
    title: "Mechanical",
    sample: "Rr",
    meta: "Mono rail",
    description:
      "Numbered tabs, registration marks, and a hard-set stage keep the motion tactile.",
  },
  {
    kicker: "Specimen 03",
    title: "Archive",
    sample: "Gg",
    meta: "Folio set",
    description:
      "Each panel behaves like a printed card sliding under a loupe, not a generic carousel.",
  },
];

function clampIndex(index: number, length: number) {
  if (length === 0) return 0;
  return ((index % length) + length) % length;
}

function usePrefersReducedMotion() {
  const [reduced, setReduced] = useState(false);

  useEffect(() => {
    const media = window.matchMedia("(prefers-reduced-motion: reduce)");
    const update = () => setReduced(media.matches);
    update();
    media.addEventListener("change", update);
    return () => media.removeEventListener("change", update);
  }, []);

  return reduced;
}

export function SpecimenCarousel({
  items = DEFAULT_ITEMS,
  initialIndex = 0,
  autoPlay = false,
  interval = 4200,
  className,
  style,
  onIndexChange,
}: SpecimenCarouselProps) {
  const reduced = usePrefersReducedMotion();
  const safeItems = items.length > 0 ? items : DEFAULT_ITEMS;
  const [index, setIndex] = useState(() => clampIndex(initialIndex, safeItems.length));

  const active = safeItems[index];
  const folio = useMemo(() => String(index + 1).padStart(2, "0"), [index]);

  const goTo = (nextIndex: number) => {
    const next = clampIndex(nextIndex, safeItems.length);
    setIndex(next);
    onIndexChange?.(next);
  };

  useEffect(() => {
    setIndex((current) => clampIndex(current, safeItems.length));
  }, [safeItems.length]);

  useEffect(() => {
    if (!autoPlay || reduced || safeItems.length < 2) return;
    const id = window.setInterval(() => {
      setIndex((current) => {
        const next = clampIndex(current + 1, safeItems.length);
        onIndexChange?.(next);
        return next;
      });
    }, interval);
    return () => window.clearInterval(id);
  }, [autoPlay, interval, onIndexChange, reduced, safeItems.length]);

  return (
    <section
      className={[
        "relative overflow-hidden border border-(--rule) bg-(--background) text-(--ink)",
        className,
      ]
        .filter(Boolean)
        .join(" ")}
      style={style}
      aria-roledescription="carousel"
      aria-label="Specimen carousel"
      tabIndex={0}
      onKeyDown={(event) => {
        if (event.key === "ArrowRight") goTo(index + 1);
        if (event.key === "ArrowLeft") goTo(index - 1);
      }}
    >
      <div className="pointer-events-none absolute inset-0 bg-[linear-gradient(90deg,rgba(var(--ink-rgb),0.055)_1px,transparent_1px),linear-gradient(0deg,rgba(var(--ink-rgb),0.04)_1px,transparent_1px)] bg-size-[48px_48px]" />

      <div className="relative grid min-h-[420px] grid-cols-1 md:grid-cols-[7rem_1fr]">
        <aside className="hidden border-r border-(--rule) md:flex md:flex-col md:justify-between">
          <div className="p-4 font-mono text-[0.65rem] uppercase tracking-[0.18em] text-[rgba(var(--ink-rgb),0.45)] [writing-mode:vertical-rl]">
            specimen index
          </div>
          <div className="border-t border-(--rule) p-4 font-mono text-3xl tracking-[-0.12em]">
            {folio}
          </div>
        </aside>

        <div className="relative flex min-h-[420px] flex-col justify-between p-5 sm:p-7">
          <div className="flex items-start justify-between gap-4">
            <div>
              <p className="font-mono text-[0.68rem] uppercase tracking-[0.22em] text-[rgba(var(--ink-rgb),0.5)]">
                {active.kicker ?? `Specimen ${folio}`}
              </p>
              <p className="mt-2 font-mono text-[0.68rem] uppercase tracking-[0.18em] text-[rgba(var(--ink-rgb),0.34)]">
                {active.meta ?? "Folio carousel"}
              </p>
            </div>

            <div className="flex border border-(--rule) bg-[rgba(var(--background-rgb),0.72)]">
              <button
                type="button"
                onClick={() => goTo(index - 1)}
                className="h-10 w-10 cursor-pointer border-r border-(--rule) font-mono text-sm text-(--ink) transition-colors hover:bg-[rgba(var(--ink-rgb),0.08)]"
                aria-label="Previous specimen"
              >
                ←
              </button>
              <button
                type="button"
                onClick={() => goTo(index + 1)}
                className="h-10 w-10 cursor-pointer font-mono text-sm text-(--ink) transition-colors hover:bg-[rgba(var(--ink-rgb),0.08)]"
                aria-label="Next specimen"
              >
                →
              </button>
            </div>
          </div>

          <div className="grid items-end gap-6 py-10 md:grid-cols-[1fr_13rem]">
            <div>
              <div className="relative mb-5 inline-flex">
                <span className="absolute -left-3 -top-3 h-3 w-3 border-l border-t border-(--ink)" />
                <span className="absolute -right-3 -bottom-3 h-3 w-3 border-b border-r border-(--ink)" />
                <span className="font-serif text-[clamp(5.5rem,20vw,13rem)] italic leading-[0.72] tracking-[-0.12em] text-(--ink)">
                  {active.sample ?? active.title.slice(0, 2)}
                </span>
              </div>
              <h3 className="font-serif text-[clamp(2.75rem,8vw,6rem)] italic leading-[0.86] tracking-[-0.075em]">
                {active.title}
              </h3>
            </div>

            <p className="max-w-56 text-sm leading-6 text-[rgba(var(--ink-rgb),0.58)]">
              {active.description ??
                "Use this panel for a type specimen, featured artifact, product detail, or editorial story."}
            </p>
          </div>

          <div className="overflow-hidden border-t border-(--rule) pt-4">
            <div
              className="flex gap-3 transition-transform duration-500 ease-[cubic-bezier(0.22,1,0.36,1)]"
              style={{ transform: `translateX(${Math.max(0, index - 1) * -8.75}rem)` }}
            >
              {safeItems.map((item, itemIndex) => {
                const activeItem = itemIndex === index;
                return (
                  <button
                    key={`${item.title}-${itemIndex}`}
                    type="button"
                    onClick={() => goTo(itemIndex)}
                    className={[
                      "min-w-32 cursor-pointer border p-3 text-left transition-all duration-300",
                      activeItem
                        ? "border-(--ink) bg-(--ink) text-(--background)"
                        : "border-(--rule) bg-[rgba(var(--background-rgb),0.66)] text-(--ink) hover:border-[rgba(var(--ink-rgb),0.42)]",
                    ].join(" ")}
                    aria-label={`Show ${item.title}`}
                    aria-current={activeItem ? "true" : undefined}
                  >
                    <span className="block font-mono text-[0.62rem] uppercase tracking-[0.18em] opacity-60">
                      {String(itemIndex + 1).padStart(2, "0")}
                    </span>
                    <span className="mt-4 block truncate font-serif text-xl italic tracking-[-0.04em]">
                      {item.title}
                    </span>
                  </button>
                );
              })}
            </div>
          </div>
        </div>
      </div>
    </section>
  );
}

export default SpecimenCarousel;

```

## Demo

```tsx
"use client";

import React from "react";
import { SpecimenCarousel } from "../mellow/specimen-carousel";

const specimens = [
  {
    kicker: "Typeface / 1896",
    title: "Mercury",
    sample: "Mm",
    meta: "Display italic",
    description:
      "High-contrast forms for loud editorial openers, tuned with tight tracking and a heavy folio presence.",
  },
  {
    kicker: "Archive / Plate 12",
    title: "Basalt",
    sample: "Bb",
    meta: "Industrial serif",
    description:
      "Blocky, mineral weight with registration marks and a mechanical slide between specimens.",
  },
  {
    kicker: "Foundry / Proof",
    title: "Orchid",
    sample: "Oo",
    meta: "Soft grotesk",
    description:
      "A warmer card for brand systems that need some softness without losing the print-room character.",
  },
  {
    kicker: "Index / New",
    title: "Vellum",
    sample: "Vv",
    meta: "Text family",
    description:
      "Quiet bookish texture for long-form pages, product notes, and restrained launch narratives.",
  },
];

export default function SpecimenCarouselDemo() {
  return (
    <div className="w-full p-4">
      <SpecimenCarousel items={specimens} className="w-full" />
    </div>
  );
}

```
