← ui

card-gallery

ui

a row of cards that overflows, with a picker, keys and swipe

npx shadcn add @ag/card-gallery
Already installed? Preview an update
npx shadcn add @ag/card-gallery --dry-run

Your installed source stays unchanged until you update it. How updates work

Latest changes · 2026-10-15
  • picker="labels" shows each card's mark with its label in words; icons (the default) is the mark alone, as before.
Updated 2026-10-15 · release notes

also called carousel

shelf media

uses preview-picker

used by scroll-story

the demo above, verbatim

card-gallery.demo.tsx
"use client"

import { Sample } from "@/app/samples"
import { CardGallery } from "@/registry/base-nova/ui/card-gallery"
import { ScrollStage } from "@/registry/base-nova/ui/scroll-stage"
import { useScrollStageTimeline } from "@/registry/base-nova/ui/scroll-stage-timeline"

// #region cards
const marks = [
  { label: "Observe", mark: "01" },
  { label: "Shape", mark: "02" },
  { label: "Release", mark: "03" },
]
const cards = marks.map((mark) => ({
  id: mark.label,
  ...mark,
  content: (
    <div
      key={mark.label}
      className="flex min-h-[clamp(24rem,54svh,36rem)] flex-col items-start justify-center gap-6 p-8 sm:p-16"
    >
      <span className="font-mono text-sm text-muted-foreground">
        {mark.mark} / 03
      </span>
      <h3 className="text-4xl font-semibold">{mark.label}</h3>
      <p className="max-w-sm text-base text-muted-foreground">
        {
          [
            "Find the detail that changes the whole picture.",
            "Turn that observation into something you can try.",
            "Put it in someone’s hands and learn from what happens.",
          ][Number(mark.mark) - 1]
        }
      </p>
    </div>
  ),
}))
// #endregion

// #region driven
const beats = [{ span: 1 }, { span: 1 }, { span: 0.5 }]
function Driven() {
  const timeline = useScrollStageTimeline(beats, { top: 32, bottom: 96 })
  return <CardGallery items={cards} bleed timeline={timeline} />
}
// #endregion

export default function Demo() {
  return (
    <div className="space-y-16">
      <Sample
        name="gallery"
        title="keyboard, swipe or picker"
        with="cards"
        presentation
      >
        <CardGallery items={cards} bleed />
        <p className="mt-4 text-sm text-muted-foreground">
          Focus the gallery and use ← →, swipe sideways, or choose a card below.
        </p>
      </Sample>
      <Sample
        name="story"
        presentation
        title="the same cards"
        description="driven by page scroll"
        with="cards driven"
      >
        <ScrollStage
          pin="all"
          acts={3}
          pace="70svh"
          tail="0svh"
          stacked={<CardGallery items={cards} bleed />}
        >
          <Driven />
        </ScrollStage>
      </Sample>
    </div>
  )
}

the source, every file an install writes

card-gallery.tsx
"use client"

import {
  type ComponentPropsWithRef,
  type ReactNode,
  useLayoutEffect,
  useRef,
  useState,
} from "react"
import { cn } from "@/lib/utils"
import { PreviewPicker } from "@/registry/base-nova/ui/preview-picker"

export interface CardGalleryProps
  extends Omit<
    ComponentPropsWithRef<"section">,
    "children" | "defaultValue" | "onChange"
  > {
  items: readonly {
    id: string
    label: string
    mark: ReactNode
    content: ReactNode
  }[]
  label?: string
  value?: string
  defaultValue?: string
  onValueChange?: (value: string) => void
  /** Fractional position and navigation belong to the same timeline controller. */
  timeline?: { offset: number; seek: (index: number) => unknown }
  /** Let neighboring cards reach the window edges, keeping the active card centered in its column. */
  bleed?: boolean
  /** Space outside the presentation for a fixed sidebar or table of contents. */
  insetInlineEnd?: string
  /** The picker under the cards: each card's mark alone (`icons`, its label read
   *  only by assistive tech), or its mark and its label in words (`labels`). */
  picker?: "icons" | "labels"
  className?: string
  classNames?: Partial<
    Record<"body" | "viewport" | "track" | "card" | "picker", string>
  >
}

/** Card presentation only. A scroll-stage timeline can own `offset` and `seek`;
 * otherwise the same gallery is a keyboard and touch driven carousel. */
export function CardGallery({
  items,
  label = "Gallery",
  value,
  defaultValue,
  onValueChange,
  timeline,
  bleed = false,
  insetInlineEnd = "0px",
  picker = "icons",
  className,
  classNames,
  ref,
  onKeyDown,
  ...props
}: CardGalleryProps) {
  const first = items[0]
  if (!first || new Set(items.map((item) => item.id)).size !== items.length)
    throw new Error("CardGallery needs nonempty items with unique IDs")
  const at = (i: number): (typeof items)[number] => {
    const item = items[i]
    if (!item) throw new Error(`CardGallery: no item at ${i}`)
    return item
  }
  const [local, setLocal] = useState(defaultValue ?? first.id)
  const offset = timeline
    ? Math.max(0, Math.min(items.length - 1, timeline.offset))
    : undefined
  const index =
    offset !== undefined
      ? Math.floor(offset)
      : Math.max(
          0,
          items.findIndex((item) => item.id === (value ?? local)),
        )
  const seek = (next: number) => {
    const index = Math.max(0, Math.min(items.length - 1, next))
    const id = at(index).id
    if (timeline) timeline.seek(index)
    else if (value === undefined) setLocal(id)
    onValueChange?.(id)
  }
  const wheelAt = useRef(0)
  const touch = useRef<{ x: number; y: number } | null>(null)
  const root = useRef<HTMLElement>(null)
  // biome-ignore lint/correctness/useExhaustiveDependencies: selection changes which subtree becomes inert
  useLayoutEffect(() => {
    const element = root.current
    const active = document.activeElement
    if (
      element &&
      active instanceof HTMLElement &&
      element.contains(active) &&
      active.closest("[inert]")
    )
      element.focus({ preventScroll: true })
  }, [index])
  useLayoutEffect(() => {
    const element = root.current
    if (!element || !bleed) return
    const measure = () => {
      const bounds = element.getBoundingClientRect()
      element.style.setProperty("--gallery-left", `${bounds.left}px`)
      element.style.setProperty(
        "--gallery-window",
        `${document.documentElement.clientWidth}px`,
      )
    }
    measure()
    const observer = new ResizeObserver(measure)
    observer.observe(element)
    window.addEventListener("resize", measure)
    return () => {
      observer.disconnect()
      window.removeEventListener("resize", measure)
    }
  }, [bleed])
  return (
    <section
      {...props}
      ref={(element) => {
        root.current = element
        if (typeof ref === "function") return ref(element)
        if (ref) ref.current = element
      }}
      data-slot="card-gallery"
      aria-label={label}
      aria-roledescription="carousel"
      // biome-ignore lint/a11y/noNoninteractiveTabindex: carousel keyboard navigation needs a focus target
      tabIndex={0}
      onKeyDown={(event) => {
        onKeyDown?.(event)
        if (
          event.defaultPrevented ||
          (event.target as HTMLElement).closest(
            "input,textarea,select,[contenteditable=true],[role=slider],[role=combobox],[role=listbox],[role=menu],[role=tablist]",
          )
        )
          return
        const next =
          event.key === "ArrowRight"
            ? index + 1
            : event.key === "ArrowLeft"
              ? index - 1
              : event.key === "Home"
                ? 0
                : event.key === "End"
                  ? items.length - 1
                  : null
        if (next === null) return
        event.preventDefault()
        seek(next)
      }}
      className={cn(
        "group/gallery w-full min-w-0 @container/gallery [--card:min(88cqw,72rem)] [--card-bg:color-mix(in_oklab,var(--foreground)_8%,var(--background))] [--gap:2rem] outline-none",
        className,
      )}
    >
      <div className={cn("space-y-8", classNames?.body)}>
        <div
          data-slot="card-gallery-viewport"
          className={cn(
            "relative overflow-x-clip py-8 touch-pan-y touch-pinch-zoom",
            classNames?.viewport,
          )}
          style={
            bleed
              ? {
                  marginLeft: "calc(-1 * var(--gallery-left, 0px))",
                  width: `calc(var(--gallery-window, 100%) - ${insetInlineEnd})`,
                  // The fade is as wide as the reserved rail, capped at 2rem: a rail
                  // that resolves to 0px (a custom property below its breakpoint)
                  // fades nothing, which a string comparison could never know.
                  maskImage: `linear-gradient(to right, black calc(100% - min(2rem, ${insetInlineEnd})), transparent)`,
                }
              : undefined
          }
          onWheel={(event) => {
            if (
              Math.abs(event.deltaX) < 24 ||
              Math.abs(event.deltaX) < Math.abs(event.deltaY)
            )
              return
            const now = performance.now()
            if (now - wheelAt.current < 700) return
            wheelAt.current = now
            seek(index + (event.deltaX > 0 ? 1 : -1))
          }}
          onTouchStart={(event) => {
            const point = event.touches[0]
            touch.current =
              event.touches.length === 1 && point
                ? { x: point.clientX, y: point.clientY }
                : null
          }}
          onTouchEnd={(event) => {
            const start = touch.current
            touch.current = null
            const end = event.changedTouches[0]
            if (!start || !end) return
            const dx = start.x - end.clientX,
              dy = start.y - end.clientY
            if (Math.abs(dx) > 50 && Math.abs(dx) > Math.abs(dy) * 1.5)
              seek(index + (dx > 0 ? 1 : -1))
          }}
          onTouchCancel={() => {
            touch.current = null
          }}
        >
          <div
            data-slot="card-gallery-track"
            className={cn(
              "flex gap-(--gap) pl-[calc((100cqw-var(--card))/2)]",
              offset === undefined &&
                "transition-transform duration-700 ease-[cubic-bezier(0.65,0,0.35,1)] motion-reduce:transition-none",
              classNames?.track,
            )}
            style={{
              paddingLeft: bleed
                ? "calc(var(--gallery-left, 0px) + (100cqw - var(--card)) / 2)"
                : undefined,
              transform: `translateX(calc(-${offset ?? index} * (var(--card) + var(--gap))))`,
            }}
          >
            {items.map((item, i) => (
              // biome-ignore lint/a11y/useSemanticElements: a carousel slide groups content, not form fields
              <div
                data-slot="card-gallery-card"
                key={item.id}
                role="group"
                aria-roledescription="slide"
                aria-label={`${item.label}, ${i + 1} of ${items.length}`}
                aria-hidden={i !== index || undefined}
                inert={i !== index}
                className={cn(
                  "w-(--card) shrink-0 rounded-[2rem] bg-(--card-bg)",
                  i === index && "group-focus-visible/gallery:focus-ring",
                  classNames?.card,
                )}
              >
                {item.content}
              </div>
            ))}
          </div>
        </div>
        <div className={cn("flex justify-center", classNames?.picker)}>
          <PreviewPicker
            label={label}
            value={index}
            onChange={seek}
            iconsOnly={picker === "icons"}
            options={items.map((mark, value) => ({
              value,
              label: mark.label,
              icon: mark.mark,
            }))}
          />
        </div>
      </div>
    </section>
  )
}