Aura Design System

Expressive Carousel

A carousel whose items resize as you scroll.

Preview

Loading...
import {
  ExpressiveCarousel,
  ExpressiveCarouselItem,
} from "@/components/ui/ExpressiveCarousel";

export function ExpressiveCarouselDemo() {
  return (
    <ExpressiveCarousel label="Featured">
      {tones.map((tone, index) => (
        <ExpressiveCarouselItem key={tone}>
          <div className={`flex h-16 items-end rounded-sm p-1 ${tone}`}>
            <span>Item {index + 1}</span>
          </div>
        </ExpressiveCarouselItem>
      ))}
    </ExpressiveCarousel>
  );
}

Installation

Make sure that namespace is set in your component.json file. Namespace docs: Learn more about namespaces

pnpm dlx shadcn@latest add @aura/expressive-carousel

Manual

Copy and paste the class names utility into your utils/class-names.ts file.

import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs))
}

Copy and paste the ExpressiveCarousel component into your components/ui/ExpressiveCarousel.tsx file.

"use client";

import * as React from "react";
import { cn } from "@/utils/class-names";

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

function ExpressiveCarousel({
  className,
  label = "Carousel",
  children,
  ...props
}: React.ComponentProps<"div"> & { label?: string }) {
  const scroller = React.useRef<HTMLDivElement>(null);
  const reduced = useReducedMotion();

  const layout = React.useCallback(() => {
    const root = scroller.current;
    if (!root) return;
    const center = root.scrollLeft + root.clientWidth / 2;
    const items = root.querySelectorAll<HTMLElement>(
      "[data-slot='expressive-carousel-item']",
    );
    items.forEach((item) => {
      const itemCenter = item.offsetLeft + item.offsetWidth / 2;
      const distance = Math.abs(center - itemCenter);
      const scale = reduced
        ? 1
        : Math.max(0.84, 1 - distance / (root.clientWidth * 1.4));
      item.style.transform = `scale(${scale})`;
    });
  }, [reduced]);

  React.useEffect(() => {
    layout();
  }, [layout, children]);

  return (
    <div
      ref={scroller}
      role="region"
      aria-roledescription="carousel"
      aria-label={label}
      tabIndex={0}
      data-slot="expressive-carousel"
      className={cn(
        "flex snap-x snap-mandatory gap-1 overflow-x-auto py-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-8",
        className,
      )}
      {...props}
      onScroll={(event) => {
        props.onScroll?.(event);
        layout();
      }}
      onKeyDown={(event) => {
        props.onKeyDown?.(event);
        if (event.defaultPrevented) return;
        const root = scroller.current;
        if (!root) return;
        const delta = root.clientWidth * 0.6;
        if (event.key === "ArrowRight") {
          event.preventDefault();
          root.scrollBy({ left: delta, behavior: reduced ? "auto" : "smooth" });
        } else if (event.key === "ArrowLeft") {
          event.preventDefault();
          root.scrollBy({ left: -delta, behavior: reduced ? "auto" : "smooth" });
        }
      }}
    >
      {children}
    </div>
  );
}

function ExpressiveCarouselItem({
  className,
  ...props
}: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="expressive-carousel-item"
      className={cn(
        "w-16 shrink-0 snap-center origin-center transition-transform motion-reduce:transition-none",
        className,
      )}
      {...props}
    />
  );
}

export { ExpressiveCarousel, ExpressiveCarouselItem };