CTA band

Full-bleed ink closing band with a display headline, inline email capture and a drifting product tile.

blockbandanyemail-capturecta
$npx shadcn@latest add @storekit/cta-band
cta-band

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/cta-band
$pnpm dlx shadcn@latest add @storekit/cta-band
$yarn dlx shadcn@latest add @storekit/cta-band
$bunx --bun shadcn@latest add @storekit/cta-band

Source

components/storekit/marketing/cta-band.tsx
127 lines
"use client"

import * as React from "react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import { ArrowRight, Check } from "lucide-react"
import { cn } from "@/components/storekit/lib/cn"
import { Button } from "@/components/storekit/ui/button"
import { useSkBlock } from "@/components/storekit/lib/sk-analytics"

/* Closing band in inverted ink. One display headline, an inline email
   capture with a success state, and a tilted product photo tile at the right
   edge that rises in once when the band enters view. */

export function CtaBand({
  title = "New flavors, first access.",
  body = "One email when something new drops. Nothing else.",
  image = "/brands/morrow/greens.webp",
  imageAlt = "",
  cta = "Notify me",
  onSubmit,
  className,
}: {
  title?: string
  body?: string
  image?: string
  imageAlt?: string
  cta?: string
  onSubmit?: (email: string) => void | Promise<void>
  className?: string
}) {
  const reduce = useReducedMotion()
  const sk = useSkBlock("marketing", "cta-band")
  const [email, setEmail] = React.useState("")
  const [state, setState] = React.useState<"idle" | "loading" | "done" | "error">("idle")
  const inputId = React.useId()

  const submit = async (e: React.FormEvent) => {
    e.preventDefault()
    if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim())) return setState("error")
    setState("loading")
    await Promise.all([onSubmit?.(email.trim()), new Promise((r) => setTimeout(r, 700))])
    setState("done")
    sk.emit("convert", "email_submit")
  }

  return (
    <section ref={sk.ref as React.Ref<HTMLElement>} {...sk.attrs} className={cn("relative w-full overflow-hidden bg-sk-ink text-sk-paper", className)}>
      <div className="relative mx-auto max-w-6xl px-4 py-16 sm:px-8 sm:py-24">
        <div className="max-w-xl sm:max-w-[62%] lg:max-w-xl">
          <h2 className="font-sk-display text-3xl font-semibold leading-[1.1] tracking-[-0.02em] sm:text-4xl">{title}</h2>
          <p className="mt-3 text-[15px] leading-[1.5] text-sk-paper/70">{body}</p>

          <div className="mt-8 min-h-[72px]">
            <AnimatePresence mode="wait" initial={false}>
              {state === "done" ? (
                <motion.p
                  key="done"
                  role="status"
                  initial={{ opacity: 0, y: reduce ? 0 : 8 }}
                  animate={{ opacity: 1, y: 0 }}
                  transition={{ duration: 0.4, ease: [0.16, 1, 0.3, 1] }}
                  className="flex h-12 items-center gap-3 text-[15px]"
                >
                  <span className="grid h-7 w-7 place-items-center rounded-full bg-sk-accent text-sk-accent-ink">
                    <Check size={14} strokeWidth={2.5} />
                  </span>
                  You are on the list. Watch for the next drop.
                </motion.p>
              ) : (
                <motion.form
                  key="form"
                  noValidate
                  onSubmit={submit}
                  exit={{ opacity: 0, y: reduce ? 0 : -8 }}
                  transition={{ duration: 0.2 }}
                  className="flex max-w-md flex-col gap-2 sm:flex-row"
                >
                  <label htmlFor={inputId} className="sr-only">Email</label>
                  <input
                    id={inputId}
                    type="email"
                    autoComplete="email"
                    placeholder="you@lab.edu"
                    value={email}
                    aria-invalid={state === "error" || undefined}
                    aria-describedby={state === "error" ? `${inputId}-err` : undefined}
                    onChange={(e) => {
                      setEmail(e.target.value)
                      if (state === "error") setState("idle")
                    }}
                    className={cn(
                      "h-12 w-full min-w-0 shrink-0 rounded-sk-btn border sm:w-auto sm:flex-1 sm:shrink bg-sk-paper/[0.06] px-4 text-sm text-sk-paper outline-none transition-[border-color,box-shadow] duration-200 placeholder:text-sk-paper/45",
                      "focus:border-sk-paper/60 focus:ring-[3px] focus:ring-sk-paper/15",
                      state === "error" ? "border-sk-sale" : "border-sk-paper/20"
                    )}
                  />
                  <Button type="submit" size="lg" loading={state === "loading"} className="focus-visible:ring-offset-sk-ink">
                    {cta}
                    <ArrowRight size={16} />
                  </Button>
                </motion.form>
              )}
            </AnimatePresence>
            {state === "error" ? (
              <p id={`${inputId}-err`} className="mt-2 text-xs text-sk-paper/80">Enter a valid email.</p>
            ) : null}
          </div>
        </div>
        <div
          aria-hidden={imageAlt ? undefined : true}
          className="pointer-events-none absolute -right-8 top-1/2 hidden w-[min(260px,30%)] -translate-y-1/2 sm:block lg:right-8"
        >
          <motion.img
            src={image}
            alt={imageAlt}
            className="aspect-square w-full rotate-[4deg] rounded-sk-card border border-sk-paper/10 object-cover"
            initial={reduce ? false : { opacity: 0, y: 8 }}
            whileInView={{ opacity: 1, y: 0 }}
            viewport={{ once: true, amount: 0.4 }}
            transition={{ duration: 0.6, ease: [0.16, 1, 0.3, 1] }}
          />
        </div>
      </div>
    </section>
  )
}

Usage

Import from "@/components/storekit/marketing/cta-band" and pass your product data. The demo above exercises the full prop surface with sample values.

CTA band, StoreKit