Column footer with newsletter

Brand blurb with an inline newsletter form, three link columns, payment marks, and legal row.

blockcolumnsanynewsletterpayments
$npx shadcn@latest add @storekit/footer-columns
footer-columns

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

Source

components/storekit/footer/footer-columns.tsx
211 lines
"use client"

import { useId, useState } from "react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import { ArrowRight, Check } from "lucide-react"
import { cn } from "@/components/storekit/lib/cn"
import { useSkBlock } from "@/components/storekit/lib/sk-analytics"

/* Four-column footer: brand blurb with an inline newsletter form, then Shop,
   Support, and Company. The bottom row carries generic payment marks (drawn
   shapes, no third-party logos), legal links, and a supplement disclaimer line.
   `onSubscribe` may return a promise; the button shows loading until it
   settles, then the form swaps to a confirmation. */

type Column = { title: string; links: { label: string; href: string }[] }

const ring =
  "outline-none focus-visible:ring-2 focus-visible:ring-sk-accent focus-visible:ring-offset-2 focus-visible:ring-offset-sk-paper"

function PaymentMark({ kind }: { kind: "card" | "chip" | "bank" | "wallet" }) {
  const label = { card: "Credit card", chip: "Debit card", bank: "Bank transfer", wallet: "Digital wallet" }[kind]
  return (
    <span role="img" aria-label={label} className="grid h-7 w-11 place-items-center rounded-[min(var(--sk-radius-btn),6px)] border border-sk-line bg-sk-card text-sk-muted">
      <svg width="28" height="16" viewBox="0 0 28 16" fill="none" aria-hidden>
        {kind === "card" ? (
          <>
            <circle cx="11" cy="8" r="5" fill="currentColor" opacity="0.55" />
            <circle cx="17" cy="8" r="5" fill="currentColor" opacity="0.3" />
          </>
        ) : kind === "chip" ? (
          <>
            <rect x="3" y="4" width="7" height="6" rx="1.5" stroke="currentColor" strokeWidth="1.2" />
            <rect x="13" y="10" width="12" height="1.6" rx="0.8" fill="currentColor" opacity="0.5" />
            <rect x="13" y="5" width="8" height="1.6" rx="0.8" fill="currentColor" opacity="0.3" />
          </>
        ) : kind === "bank" ? (
          <>
            <path d="M14 2.5L23 6.5H5L14 2.5Z" fill="currentColor" opacity="0.55" />
            <rect x="7" y="7.5" width="1.6" height="5" fill="currentColor" opacity="0.5" />
            <rect x="13.2" y="7.5" width="1.6" height="5" fill="currentColor" opacity="0.5" />
            <rect x="19.4" y="7.5" width="1.6" height="5" fill="currentColor" opacity="0.5" />
            <rect x="5" y="13" width="18" height="1.4" fill="currentColor" opacity="0.55" />
          </>
        ) : (
          <>
            <rect x="5" y="3" width="18" height="11" rx="2" stroke="currentColor" strokeWidth="1.2" />
            <rect x="15" y="6.5" width="8" height="4" rx="1" fill="currentColor" opacity="0.45" />
          </>
        )}
      </svg>
    </span>
  )
}

export function FooterColumns({
  brandName = "Morrow",
  markSrc,
  blurb = "Daily wellness, made simple. Clean ingredients, third-party tested and made in the USA.",
  columns = [
    { title: "Shop", links: [{ label: "All products", href: "#shop" }, { label: "Powders", href: "#powders" }, { label: "Drinks", href: "#drinks" }, { label: "Bundles", href: "#bundles" }] },
    { title: "Support", links: [{ label: "Lab results", href: "#lab-results" }, { label: "Shipping", href: "#shipping" }, { label: "Returns", href: "#returns" }, { label: "Contact", href: "#contact" }] },
    { title: "Company", links: [{ label: "About", href: "#about" }, { label: "Quality", href: "#quality" }, { label: "Wholesale", href: "#wholesale" }, { label: "Journal", href: "#journal" }] },
  ],
  legal = [
    { label: "Terms", href: "#terms" },
    { label: "Privacy", href: "#privacy" },
    { label: "Refunds", href: "#refunds" },
  ],
  disclaimer = "These statements have not been evaluated by the FDA. Not intended to diagnose, treat, cure, or prevent any disease.",
  onSubscribe = () => new Promise<void>((r) => setTimeout(r, 900)),
  className,
}: {
  brandName?: string
  markSrc?: string
  blurb?: string
  columns?: Column[]
  legal?: { label: string; href: string }[]
  /** Small print under the bottom row. */
  disclaimer?: string
  onSubscribe?: (email: string) => void | Promise<void>
  className?: string
}) {
  const reduce = useReducedMotion()
  const sk = useSkBlock("footer", "footer-columns")
  const emailId = useId()
  const [email, setEmail] = useState("")
  const [state, setState] = useState<"idle" | "loading" | "done">("idle")

  const submit = async (e: React.FormEvent) => {
    e.preventDefault()
    if (state !== "idle") return
    setState("loading")
    try {
      await onSubscribe(email)
      setState("done")
      sk.emit("convert", "email_submit")
    } catch {
      setState("idle")
    }
  }

  const year = new Date().getFullYear()

  return (
    <footer ref={sk.ref} {...sk.attrs} className={cn("border-t border-sk-line bg-sk-paper text-sk-ink", className)}>
      <div className="mx-auto grid max-w-6xl gap-10 px-4 py-14 sm:px-8 md:grid-cols-[1.6fr_3fr]">
        <div className="max-w-sm">
          <a href="/" className={cn("inline-flex min-h-11 items-center gap-2.5 rounded-sk-btn pr-1", ring)}>
            {markSrc ? <img src={markSrc} alt="" className="h-7 w-7 object-contain" /> : null}
            <span className="font-sk-display text-[15px] font-semibold tracking-[-0.02em]">{brandName}</span>
          </a>
          <p className="mt-3 text-sm leading-[1.5] text-sk-muted">{blurb}</p>

          <div className="mt-6">
            <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-11 items-center gap-2 text-sm"
                >
                  <span className="grid h-5 w-5 place-items-center rounded-full bg-sk-savings/15 text-sk-savings">
                    <Check size={12} aria-hidden />
                  </span>
                  You are on the list. New flavors land in your inbox first.
                </motion.p>
              ) : (
                <motion.form
                  key="form"
                  onSubmit={submit}
                  exit={{ opacity: 0, y: reduce ? 0 : -4, transition: { duration: 0.15 } }}
                  className="relative"
                >
                  <label htmlFor={emailId} className="mb-1.5 block text-[13px] font-medium">New flavors and restocks</label>
                  <input
                    id={emailId}
                    type="email"
                    required
                    autoComplete="email"
                    value={email}
                    onChange={(e) => setEmail(e.target.value)}
                    placeholder="you@lab.edu"
                    className="h-11 w-full rounded-sk-btn border border-sk-line bg-sk-card pl-3.5 pr-12 text-base text-sk-ink outline-none sm:text-sm transition-[border-color,box-shadow] duration-200 placeholder:text-sk-muted/70 focus:border-sk-accent focus:ring-[3px] focus:ring-sk-accent/15"
                  />
                  <button
                    type="submit"
                    aria-label="Subscribe"
                    disabled={state === "loading"}
                    aria-busy={state === "loading" || undefined}
                    className={cn(
                      "absolute bottom-1 right-1 grid h-9 w-9 place-items-center rounded-sk-btn bg-sk-accent text-sk-accent-ink transition-[transform,filter] duration-200 hover:brightness-[1.06] active:scale-[0.97] disabled:opacity-70",
                      ring
                    )}
                  >
                    {state === "loading" ? (
                      <span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
                    ) : (
                      <ArrowRight size={16} aria-hidden />
                    )}
                  </button>
                </motion.form>
              )}
            </AnimatePresence>
          </div>
        </div>

        <div className="grid grid-cols-2 gap-x-6 gap-y-8 sm:grid-cols-3">
          {columns.map((c) => (
            <nav key={c.title} aria-label={c.title}>
              <p className="font-sk-mono text-[11px] uppercase tracking-[0.14em] text-sk-muted">{c.title}</p>
              <ul className="mt-3 grid">
                {c.links.map((l) => (
                  <li key={l.href}>
                    <a href={l.href} className={cn("inline-flex min-h-10 items-center rounded-sk-btn text-sm text-sk-ink/75 transition-colors duration-200 hover:text-sk-ink", ring)}>
                      {l.label}
                    </a>
                  </li>
                ))}
              </ul>
            </nav>
          ))}
        </div>
      </div>

      <div className="border-t border-sk-line">
        <div className="mx-auto flex max-w-6xl flex-col gap-4 px-4 py-6 sm:px-8 md:flex-row md:items-center md:justify-between">
          <div className="flex items-center gap-2" aria-label="Accepted payment methods" role="list">
            {(["card", "chip", "bank", "wallet"] as const).map((k) => (
              <span role="listitem" key={k}><PaymentMark kind={k} /></span>
            ))}
          </div>
          <ul className="flex flex-wrap items-center gap-x-4 text-[13px] text-sk-muted">
            <li className="w-full sm:w-auto">&copy; {year} {brandName}</li>
            {legal.map((l) => (
              <li key={l.href}>
                <a href={l.href} className={cn("inline-flex min-h-10 items-center rounded-sk-btn transition-colors duration-200 hover:text-sk-ink", ring)}>{l.label}</a>
              </li>
            ))}
          </ul>
        </div>
        <p className="mx-auto max-w-6xl px-4 pb-6 font-sk-mono text-[11px] uppercase tracking-[0.14em] text-sk-muted sm:px-8">
          {disclaimer}
        </p>
      </div>
    </footer>
  )
}

Usage

onSubscribe may return a promise; the submit button shows loading until it settles.

Column footer with newsletter, StoreKit