Seasonal collection hero
One `preset` (spring, summer, autumn, holiday, new year, valentines, back to school, gifting) swaps copy, motif and band tone over a seasonal edit.
blockseasonalanycta
$
npx shadcn@latest add @storekit/hero-seasonalhero-seasonal
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-seasonal$
pnpm dlx shadcn@latest add @storekit/hero-seasonal$
yarn dlx shadcn@latest add @storekit/hero-seasonal$
bunx --bun shadcn@latest add @storekit/hero-seasonalSource
components/storekit/hero/hero-seasonal.tsx
166 lines
"use client"
import { motion, useReducedMotion } from "motion/react"
import { Gift, Heart, Leaf, PencilLine, Snowflake, Sparkles, Sprout, Sun, type LucideIcon } from "lucide-react"
import { cn, money } from "@/components/storekit/lib/cn"
import { useSkBlock } from "@/components/storekit/lib/sk-analytics"
/* Seasonal collection hero. One `preset` swaps the copy, a line-drawn motif
and the band tone for the moment (spring, summer, autumn, holiday, new
year, valentines, back to school, gifting), and the right side shows the
seasonal edit as a short row of product cards. Every preset value can be
overridden by props, so the preset is a starting point, not a cage. All
color comes from tokens: the motif is drawn in the accent and the band
uses the surface, accent or bar token. The motif drifts very slowly and
holds still with reduced motion. Stacks copy first on mobile. */
const EASE = [0.23, 1, 0.32, 1] as const
export type SeasonPreset = "spring" | "summer" | "autumn" | "holiday" | "new-year" | "valentines" | "back-to-school" | "gifting"
type Tone = "surface" | "accent" | "bar"
type Cta = { label: string; href: string }
export type SeasonProduct = { id: string; title: string; price: number; image: string; href: string; note?: string }
export const SEASON_PRESETS: Record<SeasonPreset, { eyebrow: string; headline: string; subcopy: string; cta: string; motif: LucideIcon; tone: Tone }> = {
spring: { eyebrow: "The spring edit", headline: "Fresh starts, lighter mornings.", subcopy: "Our picks for longer days: hydration, greens and a lighter routine.", cta: "Shop the spring edit", motif: Sprout, tone: "surface" },
summer: { eyebrow: "Summer essentials", headline: "Built for heat, travel and long days.", subcopy: "Hydration that packs flat and drinks that taste like a holiday.", cta: "Shop summer", motif: Sun, tone: "surface" },
autumn: { eyebrow: "The autumn reset", headline: "Back to a routine that sticks.", subcopy: "Steady energy and better sleep for shorter days.", cta: "Shop the reset", motif: Leaf, tone: "surface" },
holiday: { eyebrow: "Holiday gift guide", headline: "Gifts that get used, not shelved.", subcopy: "Ready-to-give sets with free gift wrap through December 20.", cta: "Shop gift sets", motif: Snowflake, tone: "accent" },
"new-year": { eyebrow: "New year, same you", headline: "Small habits. Every single day.", subcopy: "Start the year with one scoop, not a whole new life.", cta: "Build your routine", motif: Sparkles, tone: "bar" },
valentines: { eyebrow: "For someone you love", headline: "Care, in a box.", subcopy: "Pair sets for two, with a handwritten note on request.", cta: "Shop pairs", motif: Heart, tone: "surface" },
"back-to-school": { eyebrow: "Back to school", headline: "Focus for the first bell.", subcopy: "Focus, steady energy and sleep for students and the people packing their bags.", cta: "Shop the semester", motif: PencilLine, tone: "surface" },
gifting: { eyebrow: "Gifting", headline: "The easiest gift on their list.", subcopy: "Curated sets, gift notes and delivery dates you can trust.", cta: "Shop gifts", motif: Gift, tone: "accent" },
}
const TONES: Record<Tone, { band: string; muted: string; motif: string; primary: string; card: string }> = {
surface: {
band: "bg-sk-surface text-sk-ink",
muted: "text-sk-muted",
motif: "text-sk-accent/20",
primary: "bg-sk-accent text-sk-accent-ink focus-visible:ring-sk-accent focus-visible:ring-offset-sk-surface",
card: "border-sk-line",
},
accent: {
band: "bg-sk-accent text-sk-accent-ink",
muted: "text-sk-accent-ink/75",
motif: "text-sk-accent-ink/20",
primary: "bg-sk-accent-ink text-sk-accent focus-visible:ring-sk-accent-ink focus-visible:ring-offset-sk-accent",
card: "border-sk-accent-ink/15",
},
bar: {
band: "bg-sk-bar text-sk-bar-ink",
muted: "text-sk-bar-ink/75",
motif: "text-sk-bar-ink/20",
primary: "bg-sk-bar-ink text-sk-bar focus-visible:ring-sk-bar-ink focus-visible:ring-offset-sk-bar",
card: "border-sk-bar-ink/15",
},
}
export function HeroSeasonal({
preset = "autumn",
eyebrow,
headline,
subcopy,
primaryCta,
tone,
products = [],
note,
className,
}: {
preset?: SeasonPreset
eyebrow?: string
headline?: string
subcopy?: string
primaryCta?: Cta
tone?: Tone
/** The seasonal edit. Up to three are shown. */
products?: SeasonProduct[]
/** Small print, e.g. a real gift-delivery cutoff. */
note?: string
className?: string
}) {
const reduce = useReducedMotion()
const sk = useSkBlock("hero", "hero-seasonal")
const p = SEASON_PRESETS[preset]
const t = TONES[tone ?? p.tone]
const Motif = p.motif
const cta = primaryCta ?? { label: p.cta, href: "#" }
return (
<section ref={sk.ref} {...sk.attrs} data-sk-season={preset} className={cn("bg-sk-paper", className)}>
<div className="mx-auto w-full max-w-6xl px-4 py-6 sm:px-8 sm:py-8">
<div className={cn("relative isolate overflow-hidden rounded-sk-card", t.band)}>
<motion.div
aria-hidden
key={preset}
initial={{ opacity: 0, rotate: reduce ? 0 : -8, scale: reduce ? 1 : 0.9 }}
animate={{ opacity: 1, rotate: 0, scale: 1 }}
transition={{ duration: 0.9, ease: EASE }}
className={cn("pointer-events-none absolute -right-16 -top-16 -z-10 sm:-right-10 sm:-top-10", t.motif)}
>
<motion.div animate={reduce ? undefined : { rotate: [0, 6, 0], y: [0, -6, 0] }} transition={{ duration: 14, ease: "easeInOut", repeat: Infinity }}>
<Motif strokeWidth={0.25} className="h-[300px] w-[300px] sm:h-[420px] sm:w-[420px]" />
</motion.div>
</motion.div>
<Motif aria-hidden strokeWidth={0.5} className={cn("pointer-events-none absolute -bottom-10 left-[38%] -z-10 hidden h-40 w-40 rotate-12 lg:block", t.motif)} />
<div className="grid items-center gap-8 p-6 sm:p-10 lg:grid-cols-[0.9fr_1.1fr] lg:gap-12 lg:p-14">
<motion.div key={`copy-${preset}`} initial={{ opacity: 0, y: reduce ? 0 : 8 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.5, ease: EASE }}>
<p className={cn("flex items-center gap-2 font-sk-mono text-[11px] uppercase tracking-[0.14em]", t.muted)}>
<Motif size={14} aria-hidden />
{eyebrow ?? p.eyebrow}
</p>
<h1 className="mt-4 text-balance font-sk-display font-semibold leading-[1.06] tracking-[-0.02em]" style={{ fontSize: "clamp(2rem, 4.4vw, 3.5rem)" }}>
{headline ?? p.headline}
</h1>
<p className={cn("mt-4 max-w-md text-[15px] leading-[1.55]", t.muted)}>{subcopy ?? p.subcopy}</p>
<a
href={cta.href}
onClick={() => sk.emit("interact", "cta")}
className={cn(
"sk-btn mt-7 inline-flex min-h-12 w-full items-center justify-center py-2.5 text-center leading-tight rounded-sk-btn px-7 text-[15px] font-medium outline-none transition-[opacity,transform] duration-200 ease-sk-out hover:opacity-90 focus-visible:ring-2 focus-visible:ring-offset-2 active:scale-[0.97] sm:w-auto",
t.primary
)}
>
{cta.label}
</a>
{note ? <p className={cn("mt-4 text-[12px]", t.muted)}>{note}</p> : null}
</motion.div>
{products.length ? (
<ul className="grid grid-cols-2 gap-3 sm:grid-cols-3 sm:gap-4">
{products.slice(0, 3).map((pr, i) => (
<motion.li
key={pr.id}
initial={{ opacity: 0, y: reduce ? 0 : 14 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.55, ease: EASE, delay: reduce ? 0 : 0.1 + i * 0.07 }}
className={cn(i === 1 && "sm:-translate-y-4", i === 2 && "hidden sm:block")}
>
<a
href={pr.href}
onClick={() => sk.emit("interact", "product", i)}
className={cn(
"group block overflow-hidden rounded-sk-card border bg-sk-card text-sk-ink outline-none transition-transform duration-300 ease-sk-out hover:-translate-y-1 focus-visible:ring-2 focus-visible:ring-sk-accent",
t.card
)}
>
<div className="relative aspect-[4/5] overflow-hidden bg-sk-surface">
<img src={pr.image} alt={pr.title} className="absolute inset-0 h-full w-full object-cover transition-transform duration-700 ease-sk-out group-hover:scale-[1.04]" />
</div>
<div className="p-2 sm:p-3">
<p className="line-clamp-2 min-h-[30px] text-[12px] font-medium leading-[15px] sm:min-h-0 sm:truncate sm:text-sm sm:leading-normal">{pr.title}</p>
<p className="text-[12px] tabular-nums text-sk-muted sm:text-[13px]">{money(pr.price)}</p>
</div>
</a>
</motion.li>
))}
</ul>
) : null}
</div>
</div>
</div>
</section>
)
}
Usage
Every preset value can be overridden by props. Use `note` for real gift-delivery cutoffs.


