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-carouselhero-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-carouselSource
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.
