opencommerceby Agentcard

Components

Carousel

A row of slides that snap into place, with white keys to step through them.

npx -y github:anwarlemu/opencommerce-brand-cli#release add carousel --surface site

Run from the target React 19.2+ app root with Tailwind CSS 4 and the @/* source alias. Source and examples as JSON.

carousel.preview
northwind.example

Northwind Outfitters

Operational

1,284 checkouts today

contoso.example

Contoso Coffee

Operational

642 checkouts today

fabrikam.example

Fabrikam Home

Degraded

318 checkouts today

tailspin.example

Tailspin Toys

Operational

97 checkouts today

litware.example

Litware Books

Operational

1,016 checkouts today

Merchant 1 of 5

Preview
carousel.sizes
northwind.example

Northwind Outfitters

Operational

1,284 checkouts today

contoso.example

Contoso Coffee

Operational

642 checkouts today

fabrikam.example

Fabrikam Home

Degraded

318 checkouts today

tailspin.example

Tailspin Toys

Operational

97 checkouts today

litware.example

Litware Books

Operational

1,016 checkouts today

Sizes
carousel.vertical
northwind.example

Northwind Outfitters

Operational

1,284 checkouts today

contoso.example

Contoso Coffee

Operational

642 checkouts today

fabrikam.example

Fabrikam Home

Degraded

318 checkouts today

tailspin.example

Tailspin Toys

Operational

97 checkouts today

litware.example

Litware Books

Operational

1,016 checkouts today

Vertical

Usage

use-it-for.md

Use for a few related items people browse one at a time (merchant cards, screenshots of a checkout). Keep Previous and Next visible; use setApi to show the current slide. Set opts.loop to wrap around and orientation="vertical" for a column.

avoid.md
  • Auto-playing slides
  • Hiding important content in later slides
  • A carousel for text people must read in full

Exports: Carousel, CarouselContent, CarouselItem, CarouselNext, CarouselPrevious, useCarousel, CarouselApi. This component includes a client boundary. Keep event handlers and state in a client component.

Dependencies: lucide-react.

Example

examples/carousel.tsx
"use client";

import * as React from "react";

import { Badge } from "@/components/ui/badge";
import { Carousel, CarouselContent, CarouselItem, CarouselNext, CarouselPrevious, type CarouselApi } from "@/components/ui/carousel";
import { Window } from "@/components/ui/window";

const MERCHANTS = [
  { name: "Northwind Outfitters", host: "northwind.example", checkouts: "1,284", status: "success" as const, label: "Operational" },
  { name: "Contoso Coffee", host: "contoso.example", checkouts: "642", status: "success" as const, label: "Operational" },
  { name: "Fabrikam Home", host: "fabrikam.example", checkouts: "318", status: "warning" as const, label: "Degraded" },
  { name: "Tailspin Toys", host: "tailspin.example", checkouts: "97", status: "success" as const, label: "Operational" },
  { name: "Litware Books", host: "litware.example", checkouts: "1,016", status: "success" as const, label: "Operational" },
];

function Card({ m }: { m: (typeof MERCHANTS)[number] }) {
  return (
    <Window name={m.host} bodyClassName="grid gap-3 p-5">
      <div className="flex items-start justify-between gap-3">
        <p className="font-display text-h5 font-medium tracking-tight text-fg">{m.name}</p>
        <Badge variant={m.status}>{m.label}</Badge>
      </div>
      <p className="text-body-sm text-fg-muted">
        <span className="font-mono text-fg tabular-nums">{m.checkouts}</span> checkouts today
      </p>
    </Window>
  );
}

export function Site() {
  const [api, setApi] = React.useState<CarouselApi>();
  const [index, setIndex] = React.useState(0);
  React.useEffect(() => {
    if (!api) return;
    const update = (a: CarouselApi) => setIndex(a.selectedScrollSnap());
    api.on("select", update);
    return () => api.off("select", update);
  }, [api]);
  return (
    <div className="mx-auto grid w-full max-w-80 gap-3 sm:max-w-96">
      <Carousel setApi={setApi} aria-label="Merchants">
        <CarouselContent>
          {MERCHANTS.map((m) => (
            <CarouselItem key={m.host}>
              <Card m={m} />
            </CarouselItem>
          ))}
        </CarouselContent>
        <CarouselPrevious className="max-sm:hidden" />
        <CarouselNext className="max-sm:hidden" />
      </Carousel>
      <p className="text-center font-mono text-bar text-fg-muted">
        Merchant {index + 1} of {MERCHANTS.length}
      </p>
    </div>
  );
}

export function Sizes() {
  return (
    <div className="mx-auto w-full max-w-160 px-12 max-sm:px-0">
      <Carousel opts={{ align: "start" }} aria-label="Merchants">
        <CarouselContent>
          {MERCHANTS.map((m) => (
            <CarouselItem key={m.host} className="basis-full sm:basis-1/2">
              <Card m={m} />
            </CarouselItem>
          ))}
        </CarouselContent>
        <CarouselPrevious className="max-sm:hidden" />
        <CarouselNext className="max-sm:hidden" />
      </Carousel>
    </div>
  );
}

export function Vertical() {
  return (
    <div className="mx-auto grid w-full max-w-80 justify-items-center py-12">
      <Carousel orientation="vertical" opts={{ align: "start" }} className="w-full" aria-label="Merchants">
        <CarouselContent className="h-72">
          {MERCHANTS.map((m) => (
            <CarouselItem key={m.host} className="basis-1/2">
              <Card m={m} />
            </CarouselItem>
          ))}
        </CarouselContent>
        <CarouselPrevious />
        <CarouselNext />
      </Carousel>
    </div>
  );
}

Source

components/ui/carousel.tsx
"use client";

import * as React from "react";
import { ArrowLeft, ArrowRight } from "lucide-react";

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

// A row of slides that snap into place, drawn from the brand's windows and keys. Slides scroll natively (touch,
// trackpad and wheel) and snap; the previous and next keys are the white key (a 16 percent ink edge, 8px corners) at
// 32px with an ink arrow, outside the row, that takes the mist under the pointer and shrinks to 0.97 when pressed.
// Arrow keys move a slide when the carousel has focus. The scroll is smooth (the browser's own easing) unless reduced
// motion is set.

type CarouselEvent = "select" | "reInit";
type CarouselApi = {
  scrollPrev: () => void;
  scrollNext: () => void;
  scrollTo: (index: number) => void;
  canScrollPrev: () => boolean;
  canScrollNext: () => boolean;
  selectedScrollSnap: () => number;
  scrollSnapList: () => number[];
  on: (event: CarouselEvent, callback: (api: CarouselApi) => void) => void;
  off: (event: CarouselEvent, callback: (api: CarouselApi) => void) => void;
};
type CarouselOptions = { loop?: boolean; align?: "start" | "center" | "end" };

type CarouselProps = {
  opts?: CarouselOptions;
  orientation?: "horizontal" | "vertical";
  setApi?: (api: CarouselApi) => void;
};

type CarouselContextProps = {
  viewport: React.RefObject<HTMLDivElement | null>;
  orientation: "horizontal" | "vertical";
  align: "start" | "center" | "end";
  scrollPrev: () => void;
  scrollNext: () => void;
  canScrollPrev: boolean;
  canScrollNext: boolean;
  update: () => void;
};

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

function useCarousel() {
  const context = React.useContext(CarouselContext);
  if (!context) throw new Error("useCarousel must be used within a <Carousel />");
  return context;
}

function Carousel({ orientation = "horizontal", opts, setApi, className, children, ...props }: React.ComponentProps<"div"> & CarouselProps) {
  const viewport = React.useRef<HTMLDivElement | null>(null);
  const [edges, setEdges] = React.useState({ prev: false, next: false, index: 0 });
  const listeners = React.useRef<Record<CarouselEvent, Set<(api: CarouselApi) => void>>>({ select: new Set(), reInit: new Set() });
  const vertical = orientation === "vertical";
  const loop = !!opts?.loop;

  const slides = React.useCallback(() => Array.from(viewport.current?.querySelectorAll<HTMLElement>(":scope > [data-slot=carousel-content] > [data-slot=carousel-item]") ?? []), []);
  const offsetOf = React.useCallback((el: HTMLElement) => (vertical ? el.offsetTop : el.offsetLeft), [vertical]);
  const current = React.useCallback(() => {
    const el = viewport.current;
    if (!el) return 0;
    const pos = vertical ? el.scrollTop : el.scrollLeft;
    const list = slides();
    let best = 0;
    list.forEach((s, i) => {
      if (Math.abs(offsetOf(s) - pos) < Math.abs(offsetOf(list[best]) - pos)) best = i;
    });
    return best;
  }, [offsetOf, slides, vertical]);

  const scrollTo = React.useCallback((index: number) => {
    const el = viewport.current;
    const list = slides();
    if (!el || !list.length) return;
    const i = loop ? (index + list.length) % list.length : Math.max(0, Math.min(list.length - 1, index));
    const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    el.scrollTo({ [vertical ? "top" : "left"]: offsetOf(list[i]), behavior: reduce ? "auto" : "smooth" });
  }, [loop, offsetOf, slides, vertical]);

  const api = React.useMemo<CarouselApi>(() => {
    const self: CarouselApi = {
      scrollPrev: () => scrollTo(current() - 1),
      scrollNext: () => scrollTo(current() + 1),
      scrollTo,
      canScrollPrev: () => {
        const el = viewport.current;
        return !!el && (loop || (vertical ? el.scrollTop : el.scrollLeft) > 1);
      },
      canScrollNext: () => {
        const el = viewport.current;
        if (!el) return false;
        return loop || (vertical ? el.scrollTop + el.clientHeight < el.scrollHeight - 1 : el.scrollLeft + el.clientWidth < el.scrollWidth - 1);
      },
      selectedScrollSnap: current,
      scrollSnapList: () => slides().map((_, i) => i),
      on: (event, cb) => void listeners.current[event].add(cb),
      off: (event, cb) => void listeners.current[event].delete(cb),
    };
    return self;
  }, [current, loop, scrollTo, slides, vertical]);

  // The last slide listeners heard about: they are told outside React's state updates, from the scroll event.
  const announced = React.useRef(0);
  const update = React.useCallback(() => {
    const next = { prev: api.canScrollPrev(), next: api.canScrollNext(), index: api.selectedScrollSnap() };
    setEdges((e) => (e.prev === next.prev && e.next === next.next && e.index === next.index ? e : next));
    if (announced.current !== next.index) {
      announced.current = next.index;
      listeners.current.select.forEach((cb) => cb(api));
    }
  }, [api]);

  React.useEffect(() => {
    setApi?.(api);
    const el = viewport.current;
    if (!el) return;
    update();
    const observer = new ResizeObserver(() => {
      update();
      listeners.current.reInit.forEach((cb) => cb(api));
    });
    observer.observe(el);
    el.addEventListener("scroll", update, { passive: true });
    return () => {
      observer.disconnect();
      el.removeEventListener("scroll", update);
    };
  }, [api, setApi, update]);

  const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
    const back = vertical ? "ArrowUp" : "ArrowLeft";
    const forward = vertical ? "ArrowDown" : "ArrowRight";
    if (event.key === back) {
      event.preventDefault();
      api.scrollPrev();
    } else if (event.key === forward) {
      event.preventDefault();
      api.scrollNext();
    }
  };

  return (
    <CarouselContext.Provider value={{ viewport, orientation, align: opts?.align ?? "start", scrollPrev: api.scrollPrev, scrollNext: api.scrollNext, canScrollPrev: edges.prev, canScrollNext: edges.next, update }}>
      <div onKeyDownCapture={onKeyDown} className={cn("relative min-w-0", className)} role="region" aria-roledescription="carousel" data-slot="carousel" data-orientation={orientation} {...props}>
        {children}
      </div>
    </CarouselContext.Provider>
  );
}

function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
  const { viewport, orientation } = useCarousel();
  const vertical = orientation === "vertical";
  return (
    <div
      ref={viewport}
      data-slot="carousel-viewport"
      className={cn("overflow-auto overscroll-contain [scrollbar-width:none] [&::-webkit-scrollbar]:hidden", vertical ? "h-full snap-y snap-mandatory" : "snap-x snap-mandatory")}
    >
      <div data-slot="carousel-content" className={cn("flex", vertical ? "-mt-4 flex-col" : "-ml-4", className)} {...props} />
    </div>
  );
}

function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
  const { orientation, align } = useCarousel();
  return (
    <div
      role="group"
      aria-roledescription="slide"
      data-slot="carousel-item"
      className={cn("min-w-0 shrink-0 grow-0 basis-full", orientation === "horizontal" ? "pl-4" : "pt-4", align === "center" ? "snap-center" : align === "end" ? "snap-end" : "snap-start", className)}
      {...props}
    />
  );
}

const ARROW =
  "absolute inline-flex size-8 items-center justify-center rounded-control border border-hairline bg-surface text-fg outline-none transition-[background-color,opacity,scale] duration-160 ease-out hover:bg-subtle active:scale-97 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-focus-ring disabled:pointer-events-none disabled:opacity-40 motion-reduce:transition-[background-color,opacity] motion-reduce:active:scale-100 [&_svg]:size-3.5";

function CarouselPrevious({ className, ...props }: React.ComponentProps<"button">) {
  const { orientation, scrollPrev, canScrollPrev } = useCarousel();
  return (
    <button
      type="button"
      data-slot="carousel-previous"
      disabled={!canScrollPrev}
      onClick={scrollPrev}
      className={cn(ARROW, orientation === "horizontal" ? "top-1/2 -left-12 -translate-y-1/2" : "-top-12 left-1/2 -translate-x-1/2 rotate-90", className)}
      {...props}
    >
      <ArrowLeft strokeWidth={1.5} />
      <span className="sr-only">Previous slide</span>
    </button>
  );
}

function CarouselNext({ className, ...props }: React.ComponentProps<"button">) {
  const { orientation, scrollNext, canScrollNext } = useCarousel();
  return (
    <button
      type="button"
      data-slot="carousel-next"
      disabled={!canScrollNext}
      onClick={scrollNext}
      className={cn(ARROW, orientation === "horizontal" ? "top-1/2 -right-12 -translate-y-1/2" : "-bottom-12 left-1/2 -translate-x-1/2 rotate-90", className)}
      {...props}
    >
      <ArrowRight strokeWidth={1.5} />
      <span className="sr-only">Next slide</span>
    </button>
  );
}

export { Carousel, CarouselContent, CarouselItem, CarouselNext, CarouselPrevious, useCarousel };
export type { CarouselApi };

Reference: Extension from opencommerce-agentcard-web src/app/globals.css (.win, .btn-secondary).