Offer-led hero
The discount is the headline: oversized offer figure, tap-to-copy promo code, eligibility line and a tagged packshot.
blockofferanyctapromo
$
npx shadcn@latest add @storekit/hero-offerhero-offer
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-offer$
pnpm dlx shadcn@latest add @storekit/hero-offer$
yarn dlx shadcn@latest add @storekit/hero-offer$
bunx --bun shadcn@latest add @storekit/hero-offerSource
components/storekit/hero/hero-offer.tsx
150 lines
"use client"
import { useRef, useState } from "react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import { Check, Copy } from "lucide-react"
import { cn } from "@/components/storekit/lib/cn"
import { useSkBlock } from "@/components/storekit/lib/sk-analytics"
/* Offer-led hero. The offer is the headline: a large figure ("25% off"),
what it applies to, a promo code that copies on tap, and the eligibility
line right under it so nobody feels tricked at checkout. `autoApplied`
swaps the code for an "applied at checkout" note. The packshot sits on
the surface token with a sale tag. Stacks offer first on mobile. */
const EASE = [0.23, 1, 0.32, 1] as const
type Cta = { label: string; href: string }
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 SECONDARY =
"sk-btn inline-flex min-h-12 items-center justify-center py-2.5 text-center leading-tight rounded-sk-btn border border-sk-line px-7 text-[15px] font-medium text-sk-ink outline-none transition-[background-color,transform] duration-200 ease-sk-out hover:bg-sk-ink/[0.04] focus-visible:ring-2 focus-visible:ring-sk-accent focus-visible:ring-offset-2 focus-visible:ring-offset-sk-paper active:scale-[0.97]"
export function HeroOffer({
eyebrow = "Autumn reset sale",
figure = "25% off",
headline = "Everything on the shelf.",
subcopy = "Stack it with Subscribe and Save for up to 35% off your first order.",
code = "RESET25",
autoApplied = false,
eligibility = "Ends Sunday at 11:59pm ET. Excludes gift cards. One use per customer.",
primaryCta = { label: "Shop the sale", href: "#" },
secondaryCta = { label: "Shop bundles", href: "#" },
image = "/placeholder.webp",
imageAlt = "",
tag = "Sale",
className,
}: {
eyebrow?: string
/** The offer itself, set very large. */
figure?: string
headline?: string
subcopy?: string
code?: string
/** True when the discount applies without a code. */
autoApplied?: boolean
eligibility?: string
primaryCta?: Cta
secondaryCta?: Cta
image?: string
imageAlt?: string
tag?: string
className?: string
}) {
const reduce = useReducedMotion()
const sk = useSkBlock("hero", "hero-offer")
const [copied, setCopied] = useState(false)
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
const copy = async () => {
try {
await navigator.clipboard.writeText(code)
} catch {
/* clipboard can be blocked; the code is still visible to type */
}
setCopied(true)
sk.emit("interact", "copy_code")
clearTimeout(timer.current)
timer.current = setTimeout(() => setCopied(false), 2000)
}
return (
<section ref={sk.ref} {...sk.attrs} className={cn("bg-sk-paper text-sk-ink", className)}>
<div className="mx-auto grid w-full max-w-6xl items-center gap-10 px-4 py-10 sm:px-8 sm:py-14 lg:grid-cols-[1.1fr_0.9fr] lg:gap-14">
<motion.div initial={{ opacity: 0, y: reduce ? 0 : 8 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.5, ease: EASE }}>
<p className="font-sk-mono text-[11px] uppercase tracking-[0.14em] text-sk-sale">{eyebrow}</p>
<h1 className="mt-3">
<span className="block font-sk-display font-semibold leading-[0.92] tracking-[-0.04em] text-sk-ink" style={{ fontSize: "clamp(3.75rem, 11vw, 8.5rem)" }}>
{figure}
</span>
<span className="mt-3 block text-balance font-sk-display font-semibold leading-[1.1] tracking-[-0.02em]" style={{ fontSize: "clamp(1.5rem, 2.8vw, 2.25rem)" }}>
{headline}
</span>
</h1>
<p className="mt-4 max-w-md text-[15px] leading-[1.55] text-sk-muted">{subcopy}</p>
<div className="mt-7 flex flex-wrap items-center gap-3">
{autoApplied ? (
<p className="sk-btn inline-flex h-12 items-center gap-2 rounded-sk-btn border border-dashed border-sk-savings/50 bg-sk-savings/[0.08] px-4 text-sm font-medium text-sk-savings">
<Check size={16} aria-hidden /> Discount applied at checkout
</p>
) : (
<button
type="button"
onClick={copy}
className="group inline-flex h-12 items-center gap-3 rounded-sk-btn border border-dashed border-sk-ink/30 bg-sk-surface pl-4 pr-3 outline-none transition-[border-color,transform] duration-200 ease-sk-out hover:border-sk-ink/60 focus-visible:ring-2 focus-visible:ring-sk-accent focus-visible:ring-offset-2 focus-visible:ring-offset-sk-paper active:scale-[0.98]"
>
<span className="text-[12px] text-sk-muted">Code</span>
<span className="font-sk-mono text-[15px] font-medium tracking-[0.08em] text-sk-ink">{code}</span>
<span className="relative grid h-8 w-8 place-items-center rounded-full bg-sk-ink/[0.06] text-sk-ink">
<AnimatePresence initial={false} mode="popLayout">
<motion.span
key={copied ? "y" : "n"}
initial={{ opacity: 0, scale: reduce ? 1 : 0.6 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: reduce ? 1 : 0.6 }}
transition={{ type: "spring", stiffness: 500, damping: 30 }}
className="grid place-items-center"
>
{copied ? <Check size={15} aria-hidden /> : <Copy size={14} aria-hidden />}
</motion.span>
</AnimatePresence>
</span>
<span className="sr-only">{copied ? "Copied" : "Copy code"}</span>
</button>
)}
<span aria-live="polite" className={cn("text-[13px] text-sk-savings transition-opacity duration-200", copied ? "opacity-100" : "opacity-0")}>
{copied ? "Copied to clipboard" : ""}
</span>
</div>
<div className="mt-6 flex flex-col gap-3 sm:flex-row">
<a href={primaryCta.href} onClick={() => sk.emit("interact", "cta")} className={PRIMARY}>
{primaryCta.label}
</a>
<a href={secondaryCta.href} onClick={() => sk.emit("interact", "cta_secondary")} className={SECONDARY}>
{secondaryCta.label}
</a>
</div>
{eligibility ? <p className="mt-5 max-w-md text-[12px] leading-[1.5] text-sk-muted">{eligibility}</p> : null}
</motion.div>
<motion.div
initial={{ opacity: 0, scale: reduce ? 1 : 0.96 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.6, ease: EASE, delay: reduce ? 0 : 0.1 }}
className="relative mx-auto aspect-square w-full max-w-[460px] overflow-hidden rounded-sk-card border border-sk-line bg-sk-surface"
>
<img src={image} alt={imageAlt} className="absolute inset-0 h-full w-full object-cover" />
{tag ? (
<span className="absolute left-4 top-4 rotate-[-6deg] rounded-sk-chip bg-sk-sale px-3 py-1.5 font-sk-mono text-[12px] font-medium uppercase tracking-[0.12em] text-sk-paper shadow-[0_10px_24px_-12px_rgb(var(--sk-ink)/0.5)]">
{tag}
</span>
) : null}
</motion.div>
</div>
</section>
)
}
Usage
Keep `eligibility` honest and visible. Set `autoApplied` when no code is needed.
