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-capture
email-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-capture

Source

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).

Email capture popup, StoreKit