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-columnsfooter-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-columnsSource
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">© {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.