Manual carousel hero

Split slides that change only on arrows, numbered tabs, Arrow keys or swipe. No autoplay, with carousel ARIA and a polite slide announcement.

blockcarouselanycta
$npx shadcn@latest add @storekit/hero-carousel
hero-carousel

Live and interactive. Switch the brand in the frame header to see the same source under another token file.

Installation

$npx shadcn@latest add @storekit/hero-carousel
$pnpm dlx shadcn@latest add @storekit/hero-carousel
$yarn dlx shadcn@latest add @storekit/hero-carousel
$bunx --bun shadcn@latest add @storekit/hero-carousel

Source

components/storekit/hero/hero-carousel.tsx
166 lines
"use client"

import { useId, useState } from "react"
import { AnimatePresence, motion, useReducedMotion, type PanInfo } from "motion/react"
import { ChevronLeft, ChevronRight } from "lucide-react"
import { cn } from "@/components/storekit/lib/cn"
import { useSkBlock } from "@/components/storekit/lib/sk-analytics"

/* Manual hero carousel. There is no autoplay, on purpose: rotating heroes
   have weak evidence behind them, later slides are rarely seen, and motion
   the shopper did not ask for is an accessibility cost. Slides change only
   from the arrows, the numbered tabs, Arrow keys while the carousel has
   focus, or a horizontal swipe. Each slide is a split of copy and image that
   stacks on mobile. The region is announced as a carousel and the live
   region reports "Slide 2 of 3" only after a user action. */

const EASE = [0.23, 1, 0.32, 1] as const

export type CarouselSlide = {
  image: string
  alt: string
  eyebrow?: string
  title: string
  body?: string
  cta: { label: string; href: string }
  focus?: string
}

const SAMPLE: CarouselSlide[] = [
  { image: "/placeholder.webp", alt: "", eyebrow: "New", title: "Your morning, handled in one scoop.", body: "Greens, adaptogens and probiotics that taste good.", cta: { label: "Shop Daily Greens", href: "#" } },
  { image: "/placeholder.webp", alt: "", eyebrow: "Bundle and save", title: "Build a routine, save 20%.", body: "Pick any three. Change or skip anytime.", cta: { label: "Build a bundle", href: "#" } },
  { image: "/placeholder.webp", alt: "", eyebrow: "Ingredients", title: "Nothing hidden. Every batch tested.", body: "Read the lab report for the tub in your hand.", cta: { label: "See lab results", href: "#" } },
]

const PRIMARY =
  "sk-btn inline-flex min-h-12 items-center justify-center py-2.5 text-center leading-tight rounded-sk-btn bg-sk-accent px-7 text-[15px] font-medium text-sk-accent-ink outline-none transition-[filter,transform] duration-200 ease-sk-out hover:brightness-[1.06] focus-visible:ring-2 focus-visible:ring-sk-accent focus-visible:ring-offset-2 focus-visible:ring-offset-sk-paper active:scale-[0.97]"
const ARROW =
  "grid h-11 w-11 place-items-center rounded-full border border-sk-line bg-sk-paper text-sk-ink outline-none transition-[background-color,transform] duration-200 ease-sk-out hover:bg-sk-surface focus-visible:ring-2 focus-visible:ring-sk-accent active:scale-[0.94]"

export function HeroCarousel({
  slides = SAMPLE,
  label = "Featured campaigns",
  className,
}: {
  slides?: CarouselSlide[]
  /** Accessible name for the carousel region. */
  label?: string
  className?: string
}) {
  const reduce = useReducedMotion()
  const sk = useSkBlock("hero", "hero-carousel")
  const uid = useId()
  const [[index, dir], set] = useState<[number, number]>([0, 0])
  const [announce, setAnnounce] = useState(false)
  const n = slides.length
  const s = slides[index]

  const go = (next: number, how: string) => {
    const i = (next + n) % n
    if (i === index) return
    set([i, next > index ? 1 : -1])
    setAnnounce(true)
    sk.emit("interact", `slide_${how}`, i)
  }

  const onDragEnd = (_: unknown, info: PanInfo) => {
    if (info.offset.x < -60 || info.velocity.x < -400) go(index + 1, "swipe")
    else if (info.offset.x > 60 || info.velocity.x > 400) go(index - 1, "swipe")
  }

  const shift = reduce ? 0 : 40

  return (
    <section
      ref={sk.ref}
      {...sk.attrs}
      aria-roledescription="carousel"
      aria-label={label}
      onKeyDown={(e) => {
        if (e.target instanceof HTMLElement && e.target.closest("a")) return
        if (e.key === "ArrowRight") go(index + 1, "key")
        else if (e.key === "ArrowLeft") go(index - 1, "key")
      }}
      className={cn("bg-sk-paper text-sk-ink", className)}
    >
      <div className="mx-auto w-full max-w-6xl px-4 py-8 sm:px-8 sm:py-12">
        <div className="relative overflow-hidden rounded-sk-card border border-sk-line bg-sk-surface">
          <AnimatePresence initial={false} custom={dir} mode="popLayout">
            <motion.div
              key={index}
              id={`${uid}-slide-${index}`}
              role="group"
              aria-roledescription="slide"
              aria-label={`${index + 1} of ${n}`}
              custom={dir}
              initial={{ opacity: 0, x: dir * shift }}
              animate={{ opacity: 1, x: 0 }}
              exit={{ opacity: 0, x: dir * -shift }}
              transition={reduce ? { duration: 0.2 } : { type: "spring", stiffness: 260, damping: 32, mass: 0.9 }}
              drag={n > 1 ? "x" : false}
              dragConstraints={{ left: 0, right: 0 }}
              dragElastic={0.12}
              onDragEnd={onDragEnd}
              className="grid touch-pan-y items-stretch lg:min-h-[520px] lg:grid-cols-[1fr_1.1fr]"
            >
              <div className="relative order-1 aspect-[4/3] lg:order-2 lg:aspect-auto">
                <img src={s.image} alt={s.alt} draggable={false} className="absolute inset-0 h-full w-full select-none object-cover" style={{ objectPosition: s.focus }} />
              </div>
              <div className="order-2 flex flex-col justify-center bg-sk-paper p-6 pb-20 sm:p-10 sm:pb-24 lg:order-1 lg:bg-sk-surface lg:p-12 lg:pb-24">
                {s.eyebrow ? <p className="font-sk-mono text-[11px] uppercase tracking-[0.14em] text-sk-muted">{s.eyebrow}</p> : null}
                {index === 0 ? (
                  <h1 className="mt-4 text-balance font-sk-display font-semibold leading-[1.08] tracking-[-0.02em]" style={{ fontSize: "clamp(2rem, 4vw, 3.25rem)" }}>
                    {s.title}
                  </h1>
                ) : (
                  <h2 className="mt-4 text-balance font-sk-display font-semibold leading-[1.08] tracking-[-0.02em]" style={{ fontSize: "clamp(2rem, 4vw, 3.25rem)" }}>
                    {s.title}
                  </h2>
                )}
                {s.body ? <p className="mt-4 max-w-md text-[15px] leading-[1.55] text-sk-muted">{s.body}</p> : null}
                <div className="mt-7">
                  <a href={s.cta.href} onClick={() => sk.emit("interact", "cta", index)} className={PRIMARY}>
                    {s.cta.label}
                  </a>
                </div>
              </div>
            </motion.div>
          </AnimatePresence>

          {n > 1 ? (
            <div className="absolute bottom-4 left-6 right-6 flex items-center justify-between gap-4 sm:left-10 sm:right-10 lg:left-12 lg:right-auto lg:w-[calc(50%-6rem)]">
              <div role="tablist" aria-label="Choose slide" className="flex items-center gap-1">
                {slides.map((sl, i) => (
                  <button
                    key={sl.title + i}
                    type="button"
                    role="tab"
                    aria-selected={i === index}
                    aria-controls={`${uid}-slide-${i}`}
                    aria-label={`Slide ${i + 1}: ${sl.title}`}
                    onClick={() => go(i, "tab")}
                    className="group grid h-11 min-w-8 place-items-center outline-none focus-visible:rounded-sk-chip focus-visible:ring-2 focus-visible:ring-sk-accent"
                  >
                    <span className={cn("block h-1 rounded-full transition-all duration-300 ease-sk-out", i === index ? "w-8 bg-sk-ink" : "w-4 bg-sk-ink/25 group-hover:bg-sk-ink/45")} />
                  </button>
                ))}
              </div>
              <div className="flex gap-2">
                <button type="button" aria-label="Previous slide" onClick={() => go(index - 1, "prev")} className={ARROW}>
                  <ChevronLeft size={18} aria-hidden />
                </button>
                <button type="button" aria-label="Next slide" onClick={() => go(index + 1, "next")} className={ARROW}>
                  <ChevronRight size={18} aria-hidden />
                </button>
              </div>
            </div>
          ) : null}
        </div>
        <p className="sr-only" aria-live="polite" aria-atomic="true">
          {announce ? `Slide ${index + 1} of ${n}: ${s.title}` : ""}
        </p>
      </div>
    </section>
  )
}

Usage

Evidence for rotating heroes is weak: later slides are rarely seen. Prefer hero-multi-panel or one strong hero, and use this only for genuinely parallel campaigns. Autoplay is intentionally not supported.

Manual carousel hero, StoreKit