Invite Carousel
betaEvent invitations fanned out as tilted cards — the current one upright in front, its neighbours leaning behind — advancing on a timer or by hand.
Yoga
Sat, June 14, 6:00 AM
Central Park
Installation
pnpm dlx @dowel-ui/cli add invite-carouselInstalls 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.
Props
InviteCarousel
| Prop | Type | Default |
|---|---|---|
events (required) | InviteCarouselEvent[] | — |
aspectRatioHeight as a multiple of width. | number | 1.5625 |
autoPlayAdvances on a timer (the source always did), with a stop/start control. | boolean | true |
cardClassName | string | — |
cardWidthAny CSS length, including | string | "15rem" |
defaultIndex | number | — |
indexControlled current card. | number | — |
intervalMilliseconds per card while playing. | number | 3000 |
labels | Partial<InviteCarouselLabels> | — |
onIndexChange | (index: number) => void | — |
showNavigation | boolean | true |
Plus every attribute of <section> except children.
Quality
8/8 checks, measured from the source and its tests
- Tested — passes
- axe assertion — passes
- Keyboard tested — does not apply
- Storybook examples — passes
- Accessibility documented — passes
- Semantic tokens only — passes
- Motion from tokens — passes
- className merged — passes
- Visible focus — does not apply
- No fixed widths — passes
Source
This is exactly what dowel add invite-carousel writes into your project, with imports rewritten to your own path alias.
"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 };
"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>
);
}