Drop launch hero
Coming soon teaser with the real release date, compact countdown and notify-me email form that flips to available now after release.
blockdropapparelbeverageanyctacapture
$
npx shadcn@latest add @storekit/hero-drophero-drop
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-drop$
pnpm dlx shadcn@latest add @storekit/hero-drop$
yarn dlx shadcn@latest add @storekit/hero-drop$
bunx --bun shadcn@latest add @storekit/hero-dropSource
components/storekit/hero/hero-drop.tsx
216 lines
"use client"
import { useEffect, useId, useMemo, useState } from "react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import { ArrowRight, Bell, Check } from "lucide-react"
import { cn } from "@/components/storekit/lib/cn"
import { useSkBlock } from "@/components/storekit/lib/sk-analytics"
/* Drop launch or coming soon hero. A teaser image, the real release date
and a notify-me form. Before `releaseAt` the hero shows a compact
countdown and the email form; after it, the same block flips to an
"available now" state with a shop CTA, so it never shows a dead timer.
The form validates natively, hands the address to `onSubscribe` (which
may be async), and confirms in place. The visual countdown is hidden from
screen readers; the release date is a real <time> element. Stacks image
first on mobile. */
const EASE = [0.23, 1, 0.32, 1] as const
function parts(ms: number) {
const s = Math.max(0, Math.floor(ms / 1000))
return [
[Math.floor(s / 86400), "d"],
[Math.floor((s % 86400) / 3600), "h"],
[Math.floor((s % 3600) / 60), "m"],
[s % 60, "s"],
] as const
}
export function HeroDrop({
releaseAt,
eyebrow = "Drop 04",
headline = "Sparkling Recovery, blood orange.",
subcopy = "A limited run of 4,000 cases. Subscribers get first access an hour before everyone else.",
image = "/placeholder.webp",
imageAlt = "",
teaser = true,
placeholder = "you@email.com",
buttonLabel = "Notify me",
success = "You're on the list. We'll email you the minute it drops.",
consent = "One email when it launches. No spam, unsubscribe anytime.",
liveCta = { label: "Shop the drop", href: "#" },
onSubscribe,
className,
}: {
/** Real release time. ISO string, epoch ms or Date. */
releaseAt: string | number | Date
eyebrow?: string
headline?: string
subcopy?: string
image?: string
imageAlt?: string
/** Darken and soften the image until release, and lift the veil on hover. */
teaser?: boolean
placeholder?: string
buttonLabel?: string
success?: string
consent?: string
liveCta?: { label: string; href: string }
onSubscribe?: (email: string) => void | Promise<void>
className?: string
}) {
const reduce = useReducedMotion()
const sk = useSkBlock("hero", "hero-drop")
const id = useId()
const end = useMemo(() => new Date(releaseAt).getTime(), [releaseAt])
const [now, setNow] = useState<number | null>(null)
const [email, setEmail] = useState("")
const [state, setState] = useState<"idle" | "busy" | "done" | "error">("idle")
useEffect(() => {
setNow(Date.now())
const t = setInterval(() => setNow(Date.now()), 1000)
return () => clearInterval(t)
}, [])
const live = now !== null && Number.isFinite(end) && now >= end
const dateText = useMemo(
() => (now !== null && Number.isFinite(end) ? new Intl.DateTimeFormat(undefined, { weekday: "long", month: "long", day: "numeric", hour: "numeric", minute: "2-digit" }).format(end) : ""),
// eslint-disable-next-line react-hooks/exhaustive-deps
[end, now === null]
)
const submit = async (e: React.FormEvent) => {
e.preventDefault()
if (state === "busy") return
setState("busy")
try {
await onSubscribe?.(email)
setState("done")
sk.emit("convert", "notify_signup")
} catch {
setState("error")
}
}
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-8 px-4 py-10 sm:px-8 sm:py-14 lg:grid-cols-2 lg:gap-16">
<motion.div
initial={{ opacity: 0, scale: reduce ? 1 : 0.97 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.7, ease: EASE }}
className="group relative aspect-square overflow-hidden rounded-sk-card border border-sk-line bg-sk-surface lg:aspect-[4/5]"
>
<img
src={image}
alt={imageAlt}
className={cn(
"absolute inset-0 h-full w-full object-cover transition-[filter,transform] duration-[1200ms] ease-sk-out",
teaser && !live && "scale-[1.04] brightness-[0.55] saturate-[0.6] group-hover:scale-100 group-hover:brightness-[0.8] group-hover:saturate-100"
)}
/>
<span className="absolute left-4 top-4 inline-flex items-center gap-2 rounded-sk-chip bg-[rgb(0_0_0/0.55)] px-3 py-1.5 font-sk-mono text-[11px] uppercase tracking-[0.14em] text-[rgb(255_255_255)] backdrop-blur-sm">
<span className="relative flex h-1.5 w-1.5">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-[rgb(255_255_255)] opacity-60 motion-reduce:hidden" />
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-[rgb(255_255_255)]" />
</span>
{live ? "Available now" : "Coming soon"}
</span>
{!live && now !== null ? (
<div aria-hidden className="absolute inset-x-4 bottom-4 flex justify-center gap-2 font-sk-mono text-[rgb(255_255_255)] sm:inset-x-6 sm:bottom-6">
{parts(end - now).map(([v, u]) => (
<span key={u} className="flex min-w-[64px] items-baseline justify-center gap-0.5 rounded-sk-card bg-[rgb(0_0_0/0.45)] px-3 py-2.5 backdrop-blur-md">
<span className="text-[24px] font-medium tabular-nums sm:text-[30px]">{String(v).padStart(2, "0")}</span>
<span className="text-[11px] uppercase text-[rgb(255_255_255/0.7)]">{u}</span>
</span>
))}
</div>
) : null}
</motion.div>
<motion.div initial={{ opacity: 0, y: reduce ? 0 : 8 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.5, ease: EASE, delay: reduce ? 0 : 0.1 }}>
<p className="font-sk-mono text-[11px] uppercase tracking-[0.14em] text-sk-muted">{eyebrow}</p>
<h1 className="mt-4 text-balance font-sk-display font-semibold leading-[1.06] tracking-[-0.02em]" style={{ fontSize: "clamp(2.1rem, 4.4vw, 3.5rem)" }}>
{headline}
</h1>
<p className="mt-4 max-w-md text-[15px] leading-[1.55] text-sk-muted">{subcopy}</p>
{Number.isFinite(end) ? (
<p className="mt-6 flex items-center gap-2 text-sm">
<span className="font-sk-mono text-[11px] uppercase tracking-[0.14em] text-sk-muted">{live ? "Released" : "Drops"}</span>
<time dateTime={now !== null ? new Date(end).toISOString() : undefined} className="font-medium">
{dateText || " "}
</time>
</p>
) : null}
<div className="mt-6 min-h-[120px]">
{live ? (
<a
href={liveCta.href}
onClick={() => sk.emit("interact", "cta")}
className="sk-btn group inline-flex min-h-12 items-center justify-center gap-2 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]"
>
{liveCta.label}
<ArrowRight size={16} aria-hidden className="transition-transform duration-300 ease-sk-out group-hover:translate-x-0.5" />
</a>
) : (
<AnimatePresence mode="wait" initial={false}>
{state === "done" ? (
<motion.p
key="done"
role="status"
initial={{ opacity: 0, y: reduce ? 0 : 6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.3, ease: EASE }}
className="flex max-w-md items-start gap-3 rounded-sk-card border border-sk-line bg-sk-surface p-4 text-[15px]"
>
<span className="grid h-6 w-6 shrink-0 place-items-center rounded-full bg-sk-savings text-sk-paper">
<Check size={14} aria-hidden />
</span>
{success}
</motion.p>
) : (
<motion.form key="form" onSubmit={submit} exit={{ opacity: 0, y: reduce ? 0 : -6 }} transition={{ duration: 0.2 }} className="max-w-md">
<label htmlFor={`${id}-email`} className="text-sm font-medium">
Get notified when it drops
</label>
<div className="mt-2 flex flex-col gap-2 sm:flex-row">
<input
id={`${id}-email`}
type="email"
required
autoComplete="email"
inputMode="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder={placeholder}
aria-describedby={`${id}-consent`}
aria-invalid={state === "error" || undefined}
className="h-12 w-full min-w-0 rounded-sk-btn border border-sk-line bg-sk-card px-4 text-base sm:flex-1 sm:text-[15px] text-sk-ink outline-none transition-[border-color,box-shadow] placeholder:text-sk-muted/70 focus:border-sk-ink/40 focus-visible:ring-2 focus-visible:ring-sk-accent/40"
/>
<button
type="submit"
disabled={state === "busy"}
className="sk-btn inline-flex min-h-12 items-center justify-center py-2.5 text-center leading-tight gap-2 rounded-sk-btn bg-sk-accent px-6 text-[15px] font-medium text-sk-accent-ink outline-none transition-[filter,transform,opacity] 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] disabled:opacity-60"
>
<Bell size={15} aria-hidden />
{state === "busy" ? "Saving" : buttonLabel}
</button>
</div>
<p id={`${id}-consent`} className={cn("mt-2 text-[12px]", state === "error" ? "text-sk-sale" : "text-sk-muted")} role={state === "error" ? "alert" : undefined}>
{state === "error" ? "That didn't go through. Please try again." : consent}
</p>
</motion.form>
)}
</AnimatePresence>
)}
</div>
</motion.div>
</div>
</section>
)
}
Usage
`releaseAt` is required. `onSubscribe` may return a promise; a rejection shows the retry message.
