Swipe Carousel

beta

Cards on a ring seen from the front — swipe, tap or step and the ring turns, so nothing ever leaves the frame.

Card 1

Installation

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

Installs button as well, because this component imports it.

npm packages installed: class-variance-authority, motion.

Accessibility

Follows the APG carousel pattern: a section with aria-roledescription="carousel", each card a labelled group with aria-roledescription="slide", and only the front card exposed (the others are aria-hidden and inert). The swipe is never the only way through: named previous/next buttons, arrow keys (mirrored in RTL), Home and End, and a tap on a card peeking out at either side all turn the ring. A drag that ends over a link in the front card does not follow it. Under reduced motion the ring jumps instead of springing and the idle float stops; the spring is `motion`, wrapped in MotionConfig reducedMotion="user".

SwipeCarousel

PropTypeDefault
aspectRatio

Height as a multiple of width.

number1.417
cardWidth

Any CSS length.

string"13rem"
defaultIndexnumber0
float

Lets each card drift gently while idle.

booleantrue
indexnumber
labelsPartial<SwipeCarouselLabels>
onIndexChange(index: number) => void

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 swipe-carousel writes into your project, with imports rewritten to your own path alias.

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

// Original design (pattern inspired by bencho Carousel).
import { cva } from "class-variance-authority";
import {
  MotionConfig,
  animate,
  motion,
  useMotionValue,
  useTransform,
  type AnimationPlaybackControls,
  type MotionValue,
} from "motion/react";
import {
  Children,
  useEffect,
  useRef,
  useState,
  type ComponentPropsWithRef,
  type CSSProperties,
  type KeyboardEvent,
  type MouseEvent,
  type PointerEvent,
  type ReactNode,
} from "react";

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

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

/*
 * Cards stand on a ring seen from the front: the current one faces you, the
 * others swing round behind it, smaller and higher, so a swipe turns the ring
 * and nothing ever slides out of the frame.
 *
 * The ring's rotation is one continuous number — the position, in cards. A
 * drag moves it directly under the finger; on release it springs to the
 * nearest card *carrying the flick's velocity*, which is the one thing a CSS
 * transition cannot do honestly, and why this uses `motion`. Buttons and keys
 * move the same number with the same spring and no velocity. Under reduced
 * motion the ring jumps instead of springing, and the idle float stops.
 */

const PREFIX = "dowel-swipe-carousel";

/** Keyframes and the rules for the idle float only; decoration, never an indicator. */
const STYLES = `
@keyframes ${PREFIX}-float{0%,100%{transform:translateY(0) rotate(0)}50%{transform:translateY(calc(var(--${PREFIX}-lift) * -1)) rotate(var(--${PREFIX}-sway))}}
[data-slot=swipe-carousel-float]{animation:${PREFIX}-float var(--${PREFIX}-duration) ease-in-out infinite}
[data-slot=swipe-carousel][data-held] [data-slot=swipe-carousel-float]{animation-play-state:paused}
@media (prefers-reduced-motion: reduce){[data-slot=swipe-carousel-float]{animation:none}}
`;

/** Ring geometry, as fractions of the card: sideways reach and rise at the back. */
const REACH = 67;
const RISE = 6.5;
/** How far, in card widths, a drag travels to turn the ring by one card. */
const DRAG_PER_CARD = 0.64;
/** How far ahead a flick's velocity projects the resting card, in seconds. */
const PROJECTION = 0.15;
/** A resting tilt per card, so the stack reads as a hand of photos, not a grid. */
const TILTS = [1.7, -4.2, 2.6, -1.4, 3.8, -2.4, 1.1];
const FLOAT_SECONDS = [7.1, 4.7, 5.9, 6.7, 5.3, 6.1, 4.9];
const SPRING = { type: "spring", stiffness: 260, damping: 30 } as const;

const swipeCarouselVariants = cva(
  cn(
    "col-start-1 row-start-1",
    "translate-x-(--swipe-x) translate-y-(--swipe-y) scale-(--swipe-s)",
    "rtl:translate-x-[calc(var(--swipe-x)*-1)]",
  ),
);

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

const DEFAULT_LABELS: SwipeCarouselLabels = {
  previous: "Previous card",
  next: "Next card",
  slide: (index, count) => `${String(index + 1)} of ${String(count)}`,
};

export interface SwipeCarouselProps extends Omit<ComponentPropsWithRef<"section">, "children"> {
  /** One child per card: an image, or any content. */
  children?: ReactNode;
  index?: number;
  defaultIndex?: number;
  onIndexChange?: (index: number) => void;
  /** Any CSS length. */
  cardWidth?: string;
  /** Height as a multiple of width. */
  aspectRatio?: number;
  /** Lets each card drift gently while idle. */
  float?: boolean;
  labels?: Partial<SwipeCarouselLabels>;
}

interface Drag {
  pointer: number;
  startX: number;
  startPosition: number;
  cardWidth: number;
  sign: 1 | -1;
  moved: boolean;
}

/** Rounded, and never "-0" from a sine that is a hair below zero. */
function round(value: number, places = 2): number {
  const factor = 10 ** places;
  return Math.round(value * factor) / factor + 0;
}

/** The shortest whole-card move from `from` that lands on `index`. */
function nearest(from: number, index: number, count: number): number {
  let delta = wrapIndex(index - from, count);
  if (delta > count / 2) delta -= count;
  return from + delta;
}

/** A card that orbits a carousel on a ring, swiped, clicked or stepped with keys. */
export function SwipeCarousel({
  className,
  children,
  index: indexProp,
  defaultIndex = 0,
  onIndexChange,
  cardWidth = "13rem",
  aspectRatio = 1.417,
  float = true,
  labels: labelsProp,
  ...props
}: SwipeCarouselProps) {
  const labels = { ...DEFAULT_LABELS, ...labelsProp };
  const slides = Children.toArray(children);
  const count = slides.length;
  const carousel = useCarouselIndex({
    count,
    index: indexProp,
    defaultIndex,
    onIndexChange,
    loop: true,
  });
  const { index, goTo } = carousel;
  const position = useMotionValue(index);
  // The whole-card position the ring is resting at, or springing toward.
  const target = useRef(index);
  const running = useRef<AnimationPlaybackControls | null>(null);
  const drag = useRef<Drag | null>(null);
  // Set once a pointer has moved far enough to count as a drag, not a tap.
  const dragged = useRef(false);
  const [held, setHeld] = useState(false);

  function settle(next: number, velocity = 0) {
    target.current = next;
    running.current?.stop();
    if (prefersReducedMotion()) {
      position.set(next);
      return;
    }
    running.current = animate(position, next, { ...SPRING, velocity });
  }

  const settleRef = useRef(settle);
  useEffect(() => {
    settleRef.current = settle;
  });

  // Follow the index wherever it changes from: buttons, keys, or the owner.
  useEffect(() => {
    if (count === 0 || wrapIndex(target.current, count) === index) return;
    settleRef.current(nearest(target.current, index, count));
  }, [index, count]);

  useEffect(
    () => () => {
      running.current?.stop();
    },
    [],
  );

  if (count === 0) return null;

  function release(next: number, velocity: number) {
    settle(next, velocity);
    goTo(wrapIndex(next, count));
  }

  function handlePointerDown(event: PointerEvent<HTMLDivElement>) {
    if (event.button !== 0) return;
    running.current?.stop();
    const card = event.currentTarget.querySelector<HTMLElement>(
      "[data-slot=swipe-carousel-slide]",
    );
    const rtl = event.currentTarget.closest("[dir]")?.getAttribute("dir") === "rtl";
    drag.current = {
      pointer: event.pointerId,
      startX: event.clientX,
      startPosition: position.get(),
      cardWidth: card?.offsetWidth || 200,
      sign: rtl ? -1 : 1,
      moved: false,
    };
    event.currentTarget.setPointerCapture(event.pointerId);
    setHeld(true);
  }

  function handlePointerMove(event: PointerEvent<HTMLDivElement>) {
    const current = drag.current;
    if (current?.pointer !== event.pointerId) return;
    const dx = event.clientX - current.startX;
    if (Math.abs(dx) > 4) current.moved = true;
    position.set(
      current.startPosition - (current.sign * dx) / (current.cardWidth * DRAG_PER_CARD),
    );
  }

  function handlePointerUp(event: PointerEvent<HTMLDivElement>) {
    const current = drag.current;
    if (current?.pointer !== event.pointerId) return;
    drag.current = null;
    setHeld(false);
    if (event.currentTarget.hasPointerCapture(event.pointerId)) {
      event.currentTarget.releasePointerCapture(event.pointerId);
    }

    if (current.moved) {
      const velocity = position.getVelocity();
      // A flick carries on by at most one more card.
      const carry = Math.max(-1, Math.min(1, velocity * PROJECTION));
      release(Math.round(position.get() + carry), velocity);
      return;
    }

    // A tap on a card peeking out at either side brings it to the front.
    const box = event.currentTarget.getBoundingClientRect();
    const offset = (event.clientX - (box.left + box.width / 2)) * current.sign;
    const step = Math.abs(offset) > current.cardWidth * 0.35 ? Math.sign(offset) : 0;
    release(target.current + step, 0);
  }

  function handlePointerCancel(event: PointerEvent<HTMLDivElement>) {
    if (drag.current?.pointer !== event.pointerId) return;
    drag.current = null;
    setHeld(false);
    release(Math.round(position.get()), 0);
  }

  // A drag that ends over a link in the front card must not also follow it.
  function handleClickCapture(event: MouseEvent<HTMLDivElement>) {
    if (dragged.current) {
      event.preventDefault();
      event.stopPropagation();
    }
    dragged.current = false;
  }

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

  return (
    <MotionConfig reducedMotion="user">
      <section
        data-slot="swipe-carousel"
        data-held={held || undefined}
        aria-roledescription="carousel"
        aria-label={props["aria-labelledby"] ? undefined : "Cards"}
        className={cn(
          "relative flex w-full flex-col items-center gap-4 overflow-x-clip",
          className,
        )}
        {...props}
      >
        <style href={PREFIX} precedence="dowel">
          {STYLES}
        </style>
        <div
          data-slot="swipe-carousel-viewport"
          aria-live="polite"
          className={cn(
            "grid max-w-full touch-pan-y place-items-center select-none",
            held ? "cursor-grabbing" : "cursor-grab",
          )}
          style={{
            inlineSize: `calc(${cardWidth} * 2.4)`,
            paddingBlock: `calc(${cardWidth} * 0.15)`,
          }}
          onPointerDown={handlePointerDown}
          onPointerMove={(event) => {
            handlePointerMove(event);
            if (drag.current?.moved) dragged.current = true;
          }}
          onPointerUp={handlePointerUp}
          onPointerCancel={handlePointerCancel}
          onClickCapture={handleClickCapture}
          onDragStart={(event) => {
            event.preventDefault();
          }}
        >
          {slides.map((slide, slot) => (
            <Slide
              key={slot}
              slot={slot}
              count={count}
              position={position}
              active={slot === index}
              label={labels.slide(slot, count)}
              float={float}
              style={{ width: cardWidth, aspectRatio: `1 / ${String(aspectRatio)}` }}
            >
              {slide}
            </Slide>
          ))}
        </div>
        <div data-slot="swipe-carousel-controls" className="flex items-center gap-2">
          <CarouselButton
            direction="previous"
            aria-label={labels.previous}
            onClick={carousel.previous}
            onKeyDown={handleKeyDown}
          />
          <CarouselButton
            direction="next"
            aria-label={labels.next}
            onClick={carousel.next}
            onKeyDown={handleKeyDown}
          />
        </div>
      </section>
    </MotionConfig>
  );
}

interface SlideProps {
  slot: number;
  count: number;
  position: MotionValue<number>;
  active: boolean;
  label: string;
  float: boolean;
  style: CSSProperties;
  children: ReactNode;
}

/** One card, placed on the ring from the shared position. */
function Slide({ slot, count, position, active, label, float, style, children }: SlideProps) {
  const angle = useTransform(position, (value) => ((slot - value) * 2 * Math.PI) / count);
  const x = useTransform(angle, (value) => `${String(round(Math.sin(value) * REACH))}%`);
  const y = useTransform(angle, (value) => `${String(round(-(1 - Math.cos(value)) * RISE))}%`);
  const scale = useTransform(angle, (value) => String(round(0.75 + 0.25 * Math.cos(value), 4)));
  const zIndex = useTransform(angle, (value) => Math.round(50 + 50 * Math.cos(value)));
  const tilt = TILTS[slot % TILTS.length] ?? 0;
  const seconds = FLOAT_SECONDS[slot % FLOAT_SECONDS.length] ?? 6;

  return (
    <motion.div
      role="group"
      aria-roledescription="slide"
      aria-label={label}
      aria-hidden={active ? undefined : true}
      inert={active ? undefined : true}
      data-slot="swipe-carousel-slide"
      data-state={active ? "active" : "inactive"}
      className={swipeCarouselVariants()}
      style={{
        ...style,
        zIndex,
        ...({ "--swipe-x": x, "--swipe-y": y, "--swipe-s": scale } as object),
      }}
    >
      <div
        data-slot={float ? "swipe-carousel-float" : "swipe-carousel-card"}
        className="size-full overflow-hidden rounded-[1.125rem] bg-muted shadow-lg [&>img]:size-full [&>img]:object-cover"
        style={
          {
            rotate: `${String(tilt)}deg`,
            [`--${PREFIX}-duration`]: `calc(${String(seconds * 1000)}ms * var(--motion-scale, 1))`,
            [`--${PREFIX}-lift`]: "0.15rem",
            [`--${PREFIX}-sway`]: `${String(tilt > 0 ? 0.2 : -0.2)}deg`,
          } as CSSProperties
        }
      >
        {children}
      </div>
    </motion.div>
  );
}

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

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

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

/*
 * The state and keyboard rules every Dowel carousel shares, kept in one file
 * that each carousel carries so it installs on its own. This carousel never
 * rotates by itself, so it carries the subset without the rotation control
 * (see reviews-carousel/carousel-controls.tsx for the full set).
 *
 * - 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.
 */

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

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

/** `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 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>
  );
}