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

Source

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.

Drop launch hero, StoreKit