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-seasonal
hero-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-seasonal

Source

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.

Seasonal collection hero, StoreKit