Invite Carousel

beta

Event invitations fanned out as tilted cards — the current one upright in front, its neighbours leaning behind — advancing on a timer or by hand.

Hosting
Amara

Yoga

Sat, June 14, 6:00 AM

Central Park

Installation

Terminal
pnpm dlx @dowel-ui/cli add invite-carousel

Installs button as well, because this component imports it.

npm packages installed: class-variance-authority.

Accessibility

Follows the APG carousel pattern: a section with aria-roledescription="carousel", each card a labelled group with aria-roledescription="slide", and only the current card exposed (the neighbours are aria-hidden and inert). Automatic rotation — on by default, as in the source — has a stop/start control first in the tab order, pauses on hover and focus, turns the live region off while rotating, and does not start by itself under reduced motion. Previous/next buttons are named, arrow keys (mirrored in RTL), Home and End work inside it, and the fan mirrors under dir="rtl". Background images are decorative unless `imageAlt` is given; participant avatars take their name as alt text.

InviteCarousel

PropTypeDefault
events (required)InviteCarouselEvent[]
aspectRatio

Height as a multiple of width.

number1.5625
autoPlay

Advances on a timer (the source always did), with a stop/start control.

booleantrue
cardClassNamestring
cardWidth

Any CSS length, including clamp() for a responsive width.

string"15rem"
defaultIndexnumber
index

Controlled current card.

number
interval

Milliseconds per card while playing.

number3000
labelsPartial<InviteCarouselLabels>
onIndexChange(index: number) => void
showNavigationbooleantrue

Plus every attribute of <section> except children.

8/8 checks, measured from the source and its tests

  • Testedpasses
  • axe assertionpasses
  • Keyboard testeddoes not apply
  • Storybook examplespasses
  • Accessibility documentedpasses
  • Semantic tokens onlypasses
  • Motion from tokenspasses
  • className mergedpasses
  • Visible focusdoes not apply
  • No fixed widthspasses

Source

This is exactly what dowel add invite-carousel writes into your project, with imports rewritten to your own path alias.

ui/invite-carousel.tsx
"use client";

// Ported from SmoothUI Apple Invites (MIT, © 2024 Eduardo Calvo). See THIRD_PARTY_NOTICES.md.
import { cva } from "class-variance-authority";
import type { ComponentPropsWithRef, CSSProperties, KeyboardEvent, ReactNode } from "react";

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

import {
  CarouselButton,
  RotationButton,
  chain,
  handleCarouselKey,
  useAutoRotate,
  useCarouselIndex,
  wrapIndex,
} from "./carousel-controls";

/*
 * Three invitation cards fanned out: the current one upright in front, its
 * neighbours tilted behind it on either side, the rest faded out in the middle.
 *
 * The source mounts only the three visible cards and animates them with motion
 * springs and AnimatePresence, using popmotion's `wrap` for the index. Every
 * card is mounted here instead, and each moves between four resting places by
 * a CSS transition, so there is no animation library, no enter/exit bookkeeping
 * and reduced motion makes each move instant.
 *
 * The sideways offset and tilt are CSS variables that flip sign under
 * `dir="rtl"`, so "next" always sits toward the inline end. The source scaled
 * its type with a JavaScript breakpoint table; the card is a size container
 * here, so its type scales with its width in CSS.
 */

type Place = "current" | "next" | "previous" | "hidden";

const PLACES: Record<Place, CSSProperties & Record<`--${string}`, string>> = {
  current: {
    "--invite-x": "0%",
    "--invite-r": "0deg",
    "--invite-s": "1",
    opacity: 1,
    zIndex: 3,
  },
  next: {
    "--invite-x": "80%",
    "--invite-r": "12deg",
    "--invite-s": "0.9",
    opacity: 0.8,
    zIndex: 2,
  },
  previous: {
    "--invite-x": "-80%",
    "--invite-r": "-12deg",
    "--invite-s": "0.9",
    opacity: 0.8,
    zIndex: 2,
  },
  hidden: {
    "--invite-x": "0%",
    "--invite-r": "0deg",
    "--invite-s": "0.8",
    opacity: 0,
    zIndex: 1,
  },
};

/** A card. Its position comes from the variables in PLACES. */
const inviteCarouselVariants = cva(
  cn(
    "@container col-start-1 row-start-1 overflow-hidden rounded-3xl bg-primary text-foreground shadow-xl",
    "translate-x-(--invite-x) scale-(--invite-s) rotate-(--invite-r)",
    "rtl:translate-x-[calc(var(--invite-x)*-1)] rtl:rotate-[calc(var(--invite-r)*-1)]",
    "transition-[translate,rotate,scale,opacity] duration-[var(--duration-slower)] ease-[var(--ease-out-quint)]",
  ),
);

export interface InviteParticipant {
  avatar: string;
  /** Used as the avatar's alternative text. */
  name: string;
}

export interface InviteCarouselEvent {
  id: string | number;
  title?: ReactNode;
  subtitle?: ReactNode;
  location?: ReactNode;
  /** A short status, e.g. "Hosting" or "Going", shown in a pill at the top. */
  badge?: ReactNode;
  /** An icon before the badge text. Decorative. */
  badgeIcon?: ReactNode;
  /** Background image URL. */
  image?: string;
  /** Alternative text for `image`. Omitted, the image is treated as decorative. */
  imageAlt?: string;
  /** A background node instead of an image, e.g. a gradient. */
  background?: ReactNode;
  participants?: InviteParticipant[];
}

export interface InviteCarouselLabels {
  previous: string;
  next: string;
  stop: string;
  start: string;
  slide: (index: number, count: number) => string;
}

const DEFAULT_LABELS: InviteCarouselLabels = {
  previous: "Previous invitation",
  next: "Next invitation",
  stop: "Stop automatic slide show",
  start: "Start automatic slide show",
  slide: (index, count) => `${String(index + 1)} of ${String(count)}`,
};

export interface InviteCarouselProps extends Omit<
  ComponentPropsWithRef<"section">,
  "children"
> {
  events: InviteCarouselEvent[];
  /** Controlled current card. */
  index?: number;
  defaultIndex?: number;
  onIndexChange?: (index: number) => void;
  /** Advances on a timer (the source always did), with a stop/start control. */
  autoPlay?: boolean;
  /** Milliseconds per card while playing. */
  interval?: number;
  /** Any CSS length, including `clamp()` for a responsive width. */
  cardWidth?: string;
  /** Height as a multiple of width. */
  aspectRatio?: number;
  cardClassName?: string;
  showNavigation?: boolean;
  labels?: Partial<InviteCarouselLabels>;
}

function placeOf(position: number, index: number, count: number): Place {
  let offset = wrapIndex(position - index, count);
  if (offset > count / 2) offset -= count;
  if (offset === 0) return "current";
  if (offset === 1) return "next";
  if (offset === -1) return "previous";
  return "hidden";
}

/** Event invitations fanned out as tilted cards, advancing on their own or by hand. */
export function InviteCarousel({
  className,
  events,
  index: indexProp,
  defaultIndex,
  onIndexChange,
  autoPlay = true,
  interval = 3000,
  cardWidth = "15rem",
  aspectRatio = 1.5625,
  cardClassName,
  showNavigation = true,
  labels: labelsProp,
  onPointerEnter,
  onPointerLeave,
  onFocus,
  onBlur,
  ...props
}: InviteCarouselProps) {
  const labels = { ...DEFAULT_LABELS, ...labelsProp };
  const count = events.length;
  const carousel = useCarouselIndex({
    count,
    index: indexProp,
    defaultIndex,
    onIndexChange,
    loop: true,
  });
  const { index, goTo } = carousel;
  const rotation = useAutoRotate({
    autoPlay,
    interval,
    count,
    resetKey: index,
    advance: carousel.next,
  });

  if (count === 0) return null;

  const hasControls = showNavigation || autoPlay;

  function handleKeyDown(event: KeyboardEvent<HTMLElement>) {
    handleCarouselKey(event, {
      next: carousel.next,
      previous: carousel.previous,
      first: () => {
        goTo(0);
      },
      last: () => {
        goTo(count - 1);
      },
    });
  }

  return (
    <section
      data-slot="invite-carousel"
      aria-roledescription="carousel"
      aria-label={props["aria-labelledby"] ? undefined : "Invitations"}
      className={cn(
        "relative flex w-full flex-col items-center gap-6 overflow-x-clip py-6",
        className,
      )}
      onPointerEnter={chain(rotation.rootProps.onPointerEnter, onPointerEnter)}
      onPointerLeave={chain(rotation.rootProps.onPointerLeave, onPointerLeave)}
      onFocus={chain(rotation.rootProps.onFocus, onFocus)}
      onBlur={chain(rotation.rootProps.onBlur, onBlur)}
      {...props}
    >
      <div
        data-slot="invite-carousel-viewport"
        aria-live={rotation.rotating ? "off" : "polite"}
        className="grid place-items-center"
      >
        {events.map((event, position) => {
          const place = placeOf(position, index, count);
          const current = place === "current";
          return (
            <div
              key={event.id}
              role="group"
              aria-roledescription="slide"
              aria-label={labels.slide(position, count)}
              aria-hidden={current ? undefined : true}
              inert={current ? undefined : true}
              data-slot="invite-carousel-card"
              data-place={place}
              className={cn(inviteCarouselVariants(), cardClassName)}
              style={{
                ...PLACES[place],
                width: cardWidth,
                aspectRatio: `1 / ${String(aspectRatio)}`,
              }}
            >
              <InviteCard event={event} />
            </div>
          );
        })}
      </div>

      {hasControls ? (
        <div
          data-slot="invite-carousel-controls"
          className="relative z-10 flex items-center gap-2"
        >
          {autoPlay ? (
            <RotationButton
              enabled={rotation.enabled}
              onToggle={rotation.toggle}
              onKeyDown={handleKeyDown}
              stopLabel={labels.stop}
              startLabel={labels.start}
            />
          ) : null}
          {showNavigation ? (
            <>
              <CarouselButton
                direction="previous"
                aria-label={labels.previous}
                onClick={carousel.previous}
                onKeyDown={handleKeyDown}
              />
              <CarouselButton
                direction="next"
                aria-label={labels.next}
                onClick={carousel.next}
                onKeyDown={handleKeyDown}
              />
            </>
          ) : null}
        </div>
      ) : null}
    </section>
  );
}

/** The face of one card. Sizes are in container units, floored for legibility. */
function InviteCard({ event }: { event: InviteCarouselEvent }) {
  return (
    <div className="relative size-full">
      <div data-slot="invite-carousel-background" className="absolute inset-0">
        {event.background ??
          (event.image ? (
            <img
              src={event.image}
              alt={event.imageAlt ?? ""}
              draggable={false}
              className="size-full object-cover"
            />
          ) : null)}
      </div>

      {event.badge != null ? (
        <span
          data-slot="invite-carousel-badge"
          className={cn(
            "absolute start-[max(0.5rem,6.5cqi)] top-[max(0.5rem,6.5cqi)] z-10 flex items-center gap-[max(0.25rem,3cqi)]",
            "rounded-full bg-background/60 px-[max(0.5rem,5cqi)] py-[max(0.0625rem,1cqi)] font-medium backdrop-blur-xl",
            "text-[length:max(0.625rem,5cqi)] [&_svg]:size-[max(0.75rem,6cqi)] [&_svg]:shrink-0",
          )}
        >
          {event.badgeIcon != null ? <span aria-hidden="true">{event.badgeIcon}</span> : null}
          {event.badge}
        </span>
      ) : null}

      {/* A scrim that blurs and tints the lower half, so text reads on any image. */}
      <div
        aria-hidden="true"
        className="absolute inset-x-0 bottom-0 h-3/5 bg-linear-to-t from-background/90 via-background/50 to-transparent [mask-image:linear-gradient(to_top,var(--color-foreground)_40%,transparent)] backdrop-blur-sm"
      />

      <div
        data-slot="invite-carousel-content"
        className="absolute inset-x-0 bottom-0 z-10 p-[max(0.75rem,10cqi)] text-center leading-[1.4]"
      >
        {event.participants && event.participants.length > 0 ? (
          <div className="mb-[max(0.25rem,3cqi)] flex items-center justify-center gap-[max(0.25rem,3cqi)]">
            {event.participants.map((participant, position) => (
              <img
                key={`${participant.avatar}-${String(position)}`}
                src={participant.avatar}
                alt={participant.name}
                draggable={false}
                className="size-[max(1.25rem,10cqi)] rounded-full object-cover"
              />
            ))}
          </div>
        ) : null}
        {event.title != null ? (
          <p className="mb-[max(0.125rem,1.5cqi)] text-[length:max(0.875rem,7.5cqi)] font-bold break-words">
            {event.title}
          </p>
        ) : null}
        {event.subtitle != null ? (
          <p className="text-[length:max(0.625rem,5cqi)] break-words opacity-90">
            {event.subtitle}
          </p>
        ) : null}
        {event.location != null ? (
          <p className="text-[length:max(0.625rem,5cqi)] break-words opacity-90">
            {event.location}
          </p>
        ) : null}
      </div>
    </div>
  );
}

export { inviteCarouselVariants };
ui/carousel-controls.tsx
"use client";

// Original design (pattern inspired by the WAI-ARIA APG Carousel pattern).
import {
  useCallback,
  useEffect,
  useRef,
  useState,
  useSyncExternalStore,
  type FocusEvent,
  type KeyboardEvent,
} from "react";

import { Button, type ButtonProps } from "@/components/button";
import { mirrorForDirection } from "@/lib/styles";
import { cn } from "@/lib/utils";

/*
 * The state, keyboard and rotation rules every Dowel carousel shares, kept in
 * one file that each carousel carries so it installs on its own.
 *
 * - The index is controllable (`index` / `defaultIndex` / `onIndexChange`).
 * - Arrow keys follow the reading direction: in a right-to-left document the
 *   right arrow goes back, because "next" lies toward the inline end.
 * - Automatic rotation follows the APG: it has a stop/start control, pauses
 *   while the pointer is over the carousel or focus is inside it, and never
 *   starts by itself under reduced motion.
 */

const REDUCED_MOTION = "(prefers-reduced-motion: reduce)";

export function prefersReducedMotion(): boolean {
  return (
    typeof window !== "undefined" &&
    typeof window.matchMedia === "function" &&
    window.matchMedia(REDUCED_MOTION).matches
  );
}

function subscribeReducedMotion(onChange: () => void): () => void {
  if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
    return () => {};
  }
  const query = window.matchMedia(REDUCED_MOTION);
  query.addEventListener("change", onChange);
  return () => {
    query.removeEventListener("change", onChange);
  };
}

/** Whether the reader has asked for less motion, kept live. */
export function usePrefersReducedMotion(): boolean {
  return useSyncExternalStore(subscribeReducedMotion, prefersReducedMotion, () => false);
}

/** `index` wrapped into `[0, count)`. */
export function wrapIndex(index: number, count: number): number {
  if (count <= 0) return 0;
  return ((index % count) + count) % count;
}

export interface CarouselIndexOptions {
  count: number;
  index?: number;
  defaultIndex?: number;
  onIndexChange?: (index: number) => void;
  /** Wrap from the last slide to the first. Otherwise the ends are hard stops. */
  loop: boolean;
}

/** A controllable slide index that wraps or clamps. */
export function useCarouselIndex({
  count,
  index: indexProp,
  defaultIndex = 0,
  onIndexChange,
  loop,
}: CarouselIndexOptions) {
  const [uncontrolled, setUncontrolled] = useState(defaultIndex);
  const controlled = indexProp !== undefined;
  const raw = controlled ? indexProp : uncontrolled;
  const last = Math.max(0, count - 1);
  const index = loop ? wrapIndex(raw, count) : Math.min(Math.max(raw, 0), last);

  const goTo = useCallback(
    (target: number) => {
      const next = loop ? wrapIndex(target, count) : Math.min(Math.max(target, 0), last);
      if (next === index) return;
      if (!controlled) setUncontrolled(next);
      onIndexChange?.(next);
    },
    [loop, count, last, index, controlled, onIndexChange],
  );

  return {
    index,
    goTo,
    next: () => {
      goTo(index + 1);
    },
    previous: () => {
      goTo(index - 1);
    },
    canNext: count > 1 && (loop || index < last),
    canPrevious: count > 1 && (loop || index > 0),
  };
}

function isRightToLeft(element: Element): boolean {
  return element.closest("[dir]")?.getAttribute("dir") === "rtl";
}

export interface CarouselKeys {
  next: () => void;
  previous: () => void;
  first: () => void;
  last: () => void;
}

/**
 * Arrow keys (mirrored in RTL), Home and End, for the carousel's own controls.
 * It is bound to the buttons rather than the region, so arrow keys pressed
 * inside a slide's content — a text field, a slider — are never taken over.
 * Returns true when it handled the key.
 */
export function handleCarouselKey(
  event: KeyboardEvent<HTMLElement>,
  keys: CarouselKeys,
): boolean {
  if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey) return false;
  const rtl = isRightToLeft(event.currentTarget);
  const action = {
    ArrowRight: rtl ? keys.previous : keys.next,
    ArrowLeft: rtl ? keys.next : keys.previous,
    Home: keys.first,
    End: keys.last,
  }[event.key];
  if (!action) return false;
  event.preventDefault();
  action();
  return true;
}

export interface AutoRotateOptions {
  autoPlay: boolean;
  /** Milliseconds per slide. */
  interval: number;
  count: number;
  /** Changes whenever the slide changes, so a manual move restarts the clock. */
  resetKey: number;
  advance: () => void;
}

/** APG automatic rotation: a stop/start control, and pauses for hover and focus. */
export function useAutoRotate({
  autoPlay,
  interval,
  count,
  resetKey,
  advance,
}: AutoRotateOptions) {
  const reduced = usePrefersReducedMotion();
  const [playing, setPlaying] = useState(autoPlay);
  // Under reduced motion rotation only runs once the reader has asked for it.
  const [chosen, setChosen] = useState(false);
  const [hovered, setHovered] = useState(false);
  const [focused, setFocused] = useState(false);
  const advanceRef = useRef(advance);

  useEffect(() => {
    advanceRef.current = advance;
  });

  const enabled = playing && (chosen || !reduced);
  const rotating = enabled && !hovered && !focused && count > 1;

  useEffect(() => {
    if (!rotating) return;
    const timer = setInterval(() => {
      advanceRef.current();
    }, interval);
    return () => {
      clearInterval(timer);
    };
  }, [rotating, interval, resetKey]);

  return {
    /** Whether rotation is switched on (it may still be paused by hover or focus). */
    enabled,
    /** Whether slides are changing by themselves right now. */
    rotating,
    toggle: () => {
      setChosen(true);
      setPlaying(!enabled);
    },
    rootProps: {
      onPointerEnter: () => {
        setHovered(true);
      },
      onPointerLeave: () => {
        setHovered(false);
      },
      // Focus on the rotation control itself does not pause: that is where a
      // keyboard user sits to start rotation and watch it run.
      onFocus: (event: FocusEvent<HTMLElement>) => {
        const target = event.target as Element;
        setFocused(target.closest("[data-slot=carousel-rotation]") === null);
      },
      onBlur: (event: FocusEvent<HTMLElement>) => {
        if (!event.currentTarget.contains(event.relatedTarget)) setFocused(false);
      },
    },
  };
}

/** Chains a consumer's handler before ours. */
export function chain<E>(ours: (event: E) => void, theirs?: (event: E) => void) {
  return (event: E) => {
    theirs?.(event);
    ours(event);
  };
}

export interface CarouselButtonProps extends Omit<ButtonProps, "children"> {
  direction: "previous" | "next";
}

/** A round previous/next button whose chevron mirrors in RTL. */
export function CarouselButton({ direction, className, ...props }: CarouselButtonProps) {
  return (
    <Button
      type="button"
      variant="outline"
      size="icon-sm"
      data-slot={`carousel-${direction}`}
      className={cn("size-8 rounded-full bg-background/70 backdrop-blur-sm", className)}
      {...props}
    >
      <svg
        viewBox="0 0 24 24"
        fill="none"
        stroke="currentColor"
        strokeWidth="2"
        strokeLinecap="round"
        strokeLinejoin="round"
        aria-hidden="true"
        className={cn("size-4", mirrorForDirection)}
      >
        <path d={direction === "previous" ? "m15 18-6-6 6-6" : "m9 18 6-6-6-6"} />
      </svg>
    </Button>
  );
}

export interface RotationButtonProps extends Omit<ButtonProps, "children" | "onClick"> {
  enabled: boolean;
  onToggle: () => void;
  stopLabel: string;
  startLabel: string;
}

/** The APG rotation control. Its name says what pressing it will do. */
export function RotationButton({
  enabled,
  onToggle,
  stopLabel,
  startLabel,
  className,
  ...props
}: RotationButtonProps) {
  return (
    <Button
      type="button"
      variant="outline"
      size="icon-sm"
      data-slot="carousel-rotation"
      aria-label={enabled ? stopLabel : startLabel}
      className={cn("size-8 rounded-full bg-background/70 backdrop-blur-sm", className)}
      onClick={onToggle}
      {...props}
    >
      <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" className="size-3.5">
        {enabled ? (
          <path d="M7 5h3v14H7zM14 5h3v14h-3z" />
        ) : (
          <path d="M8 5.5v13a.5.5 0 0 0 .76.43l10.4-6.5a.5.5 0 0 0 0-.86L8.76 5.07A.5.5 0 0 0 8 5.5z" />
        )}
      </svg>
    </Button>
  );
}