Email capture popup
Split modal with a product render, 10% off offer, loading and success states, and every dismiss path.
componentmodalanynewsletterdiscount
$
npx shadcn@latest add @storekit/email-captureemail-capture
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/email-capture$
pnpm dlx shadcn@latest add @storekit/email-capture$
yarn dlx shadcn@latest add @storekit/email-capture$
bunx --bun shadcn@latest add @storekit/email-captureSource
components/storekit/compliance/email-capture.tsx
221 lines
"use client"
import { useEffect, useId, useRef, useState } from "react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import { Check, X } from "lucide-react"
import { cn } from "@/components/storekit/lib/cn"
import { Button } from "@/components/storekit/ui/button"
import { ProductImage } from "@/components/storekit/ui/product-image"
import { useSkBlock } from "@/components/storekit/lib/sk-analytics"
type Sk = ReturnType<typeof useSkBlock>
/** Mounts with the dialog so the view event fires when it actually opens. */
function Tracked({ children }: { children: (sk: Sk) => React.ReactNode }) {
const sk = useSkBlock("compliance", "email-capture")
return <>{children(sk)}</>
}
/* Email capture modal with a split layout: product render on the left, form
on the right. Controlled via `open` and `onOpenChange`. Escape, the close
button, the backdrop, and "No thanks" all dismiss. Scroll lock and a focus
trap while open; focus returns to whatever opened it. Set `contained` to
position inside the nearest relative parent instead of the viewport (it then
skips the page scroll lock). `initialFocus={false}` keeps focus where it is
on open, for previews that start open. */
const ring =
"outline-none focus-visible:ring-2 focus-visible:ring-sk-accent focus-visible:ring-offset-2 focus-visible:ring-offset-sk-paper"
const FOCUSABLE = 'a[href],button:not([disabled]),input:not([disabled]),[tabindex]:not([tabindex="-1"])'
export function EmailCapture({
open,
onOpenChange,
image = "/brands/morrow/greens.webp",
imageAlt = "",
eyebrow = "First order",
headline = "10% off your first order",
body = "New flavors, restocks and member-only bundles. One email a week at most.",
code = "WELCOME10",
onSubmit = () => new Promise<void>((r) => setTimeout(r, 900)),
contained = false,
initialFocus = true,
className,
}: {
open: boolean
onOpenChange: (open: boolean) => void
image?: string
imageAlt?: string
eyebrow?: string
headline?: string
body?: string
code?: string
onSubmit?: (email: string) => void | Promise<void>
contained?: boolean
initialFocus?: boolean
className?: string
}) {
const reduce = useReducedMotion()
const [email, setEmail] = useState("")
const [state, setState] = useState<"idle" | "loading" | "done">("idle")
const [error, setError] = useState<string | null>(null)
const ref = useRef<HTMLDivElement>(null)
const titleId = useId()
const inputId = useId()
useEffect(() => {
if (!open) return
const opener = document.activeElement as HTMLElement | null
const prev = document.body.style.overflow
if (!contained) document.body.style.overflow = "hidden"
const raf = initialFocus ? requestAnimationFrame(() => ref.current?.querySelector<HTMLInputElement>("input")?.focus({ preventScroll: true })) : 0
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault()
onOpenChange(false)
return
}
if (e.key !== "Tab" || !ref.current) return
const items = Array.from(ref.current.querySelectorAll<HTMLElement>(FOCUSABLE))
if (!items.length) return
const first = items[0]
const last = items[items.length - 1]
if (e.shiftKey && document.activeElement === first) {
e.preventDefault()
last.focus()
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault()
first.focus()
}
}
document.addEventListener("keydown", onKey)
return () => {
cancelAnimationFrame(raf)
if (!contained) document.body.style.overflow = prev
document.removeEventListener("keydown", onKey)
if (initialFocus) opener?.focus({ preventScroll: true })
}
}, [open, onOpenChange, contained, initialFocus])
const submit = async (e: React.FormEvent, sk: Sk) => {
e.preventDefault()
if (!/^\S+@\S+\.\S+$/.test(email)) {
setError("Enter a valid email address.")
return
}
setError(null)
setState("loading")
try {
await onSubmit(email)
setState("done")
sk.emit("convert", "email_submit")
} catch {
setState("idle")
setError("Something went wrong. Try again.")
}
}
const ease = [0.16, 1, 0.3, 1] as const
return (
<AnimatePresence>
{open ? (
<Tracked key="email-capture">
{(sk) => (
<div
ref={sk.ref as React.Ref<HTMLDivElement>}
{...sk.attrs}
className={cn(contained ? "absolute" : "fixed", "inset-0 z-50 grid place-items-center p-4", className)}
>
<motion.div
aria-hidden
onClick={() => onOpenChange(false)}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="absolute inset-0 bg-[rgb(0_0_0/0.5)]"
/>
<motion.div
ref={ref}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.97, transition: { duration: 0.15 } }}
transition={{ duration: 0.25, ease }}
className="relative grid w-full max-w-2xl overflow-hidden rounded-sk-card border border-sk-line bg-sk-paper shadow-[0_24px_60px_-24px_rgb(0_0_0/0.45)] sm:grid-cols-2"
>
<ProductImage src={image} alt={imageAlt} zoomOnHover={false} className="hidden h-full sm:block sm:aspect-auto" />
<div className="relative flex flex-col justify-center p-6 sm:p-8">
<button
type="button"
aria-label="Close"
onClick={() => onOpenChange(false)}
className={cn("absolute right-1 top-1 grid h-11 w-11 place-items-center rounded-sk-btn text-sk-muted transition-colors duration-200 hover:text-sk-ink", ring)}
>
<X size={18} aria-hidden />
</button>
<AnimatePresence mode="wait" initial={false}>
{state === "done" ? (
<motion.div
key="done"
role="status"
initial={{ opacity: 0, y: reduce ? 0 : 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, ease }}
>
<span className="grid h-9 w-9 place-items-center rounded-full bg-sk-savings/15 text-sk-savings">
<Check size={16} aria-hidden />
</span>
<h2 id={titleId} className="mt-4 font-sk-display text-2xl font-semibold leading-[1.1] tracking-[-0.02em] text-balance">Your code is ready</h2>
<p className="mt-2 text-sm leading-[1.5] text-sk-muted">Use it at checkout. We also sent it to {email}.</p>
<p className="mt-4 inline-flex rounded-sk-chip border border-dashed border-sk-line px-3 py-1.5 font-sk-mono text-sm font-semibold tracking-[0.14em]">{code}</p>
<Button className="mt-6 w-full" onClick={() => onOpenChange(false)}>Start shopping</Button>
</motion.div>
) : (
<motion.div key="form" exit={{ opacity: 0, y: reduce ? 0 : -4, transition: { duration: 0.15 } }}>
<p className="font-sk-mono text-[11px] uppercase tracking-[0.14em] text-sk-muted">{eyebrow}</p>
<h2 id={titleId} className="mt-2 pr-8 font-sk-display text-2xl font-semibold leading-[1.1] tracking-[-0.02em] text-balance">{headline}</h2>
<p className="mt-2 text-sm leading-[1.5] text-sk-muted">{body}</p>
<form onSubmit={(e) => submit(e, sk)} noValidate className="mt-6 grid gap-2">
<label htmlFor={inputId} className="sr-only">Email</label>
<input
id={inputId}
type="email"
autoComplete="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@lab.edu"
aria-invalid={!!error || undefined}
aria-describedby={error ? `${inputId}-err` : undefined}
className={cn(
"h-11 w-full rounded-sk-btn border bg-sk-card px-3.5 text-sm text-sk-ink outline-none transition-[border-color,box-shadow] duration-200 placeholder:text-sk-muted/70 focus:border-sk-accent focus:ring-[3px] focus:ring-sk-accent/15",
error ? "border-sk-sale" : "border-sk-line"
)}
/>
{error ? <p id={`${inputId}-err`} className="text-xs text-sk-sale">{error}</p> : null}
<Button type="submit" loading={state === "loading"} className="w-full">Get 10% off</Button>
</form>
<button
type="button"
onClick={() => onOpenChange(false)}
className={cn("mt-2 inline-flex min-h-11 w-full items-center justify-center rounded-sk-btn text-[13px] text-sk-muted transition-colors duration-200 hover:text-sk-ink", ring)}
>
No thanks
</button>
</motion.div>
)}
</AnimatePresence>
</div>
</motion.div>
</div>
)}
</Tracked>
) : null}
</AnimatePresence>
)
}
Usage
Controlled: you decide when to open it (timer, exit intent, scroll depth).
