Browse the library
Blocks / Sections
Footer, big
FreeLarge local business footer with animated newsletter, link columns, NAP, hours, socials and a giant wordmark.
components/facade/footer-big.tsx
"use client";
import * as React from "react";
import { AnimatePresence, motion, useInView, useReducedMotion } from "motion/react";
import { ArrowRight, ArrowUpRight, Check, Clock, Mail, MapPin, Phone } from "lucide-react";
import { cn } from "@/lib/utils";
export interface FooterBigLink {
label: string;
href: string;
badge?: string;
}
export interface FooterBigColumn {
title: string;
links: FooterBigLink[];
}
export type FooterBigSocialNetwork = "facebook" | "instagram" | "linkedin" | "youtube" | "x" | "yelp";
export interface FooterBigSocial {
network: FooterBigSocialNetwork;
href: string;
}
export interface FooterBigHours {
days: string;
hours: string;
}
export interface FooterBigProps {
businessName?: string;
tagline?: string;
newsletterTitle?: string;
newsletterDescription?: string;
/** Called with the email on submit. Return a promise to show a loading state. Without a handler the form only simulates success, for previews. */
onSubscribe?: (email: string) => void | Promise<void>;
columns?: FooterBigColumn[];
address?: { street: string; city: string; mapHref?: string };
phone?: { label: string; href: string };
email?: string;
hours?: FooterBigHours[];
socials?: FooterBigSocial[];
/** Oversized faded wordmark. Defaults to businessName. */
wordmark?: string;
legal?: FooterBigLink[];
license?: string;
className?: string;
}
const EASE = [0.22, 1, 0.36, 1] as const;
function SocialIcon({ network }: { network: FooterBigSocialNetwork }) {
const common = { className: "size-4", viewBox: "0 0 24 24", fill: "currentColor", "aria-hidden": true } as const;
switch (network) {
case "facebook":
return (
<svg {...common}>
<path d="M13.5 21v-7.5h2.53l.38-2.94H13.5V8.69c0-.85.24-1.43 1.45-1.43h1.55V4.63A20.6 20.6 0 0 0 14.24 4.5c-2.24 0-3.77 1.37-3.77 3.88v2.18H7.94v2.94h2.53V21h3.03Z" />
</svg>
);
case "instagram":
return (
<svg {...common} fill="none" stroke="currentColor" strokeWidth="1.8">
<rect x="3.5" y="3.5" width="17" height="17" rx="5" />
<circle cx="12" cy="12" r="4" />
<circle cx="17.3" cy="6.7" r="0.9" fill="currentColor" stroke="none" />
</svg>
);
case "linkedin":
return (
<svg {...common}>
<path d="M6.94 8.75H3.88V20h3.06V8.75ZM5.41 4a1.78 1.78 0 1 0 0 3.56 1.78 1.78 0 0 0 0-3.56ZM20.12 13.6c0-3.03-1.62-4.44-3.78-4.44a3.27 3.27 0 0 0-2.96 1.63V8.75h-3.06c.04.86 0 11.25 0 11.25h3.06v-6.28c0-.34.02-.67.12-.91.27-.67.88-1.37 1.92-1.37 1.35 0 1.89 1.03 1.89 2.54V20h3.06l-.25-6.4Z" />
</svg>
);
case "youtube":
return (
<svg {...common}>
<path d="M21.58 7.19a2.5 2.5 0 0 0-1.77-1.77C18.25 5 12 5 12 5s-6.25 0-7.81.42a2.5 2.5 0 0 0-1.77 1.77C2 8.75 2 12 2 12s0 3.25.42 4.81a2.5 2.5 0 0 0 1.77 1.77C5.75 19 12 19 12 19s6.25 0 7.81-.42a2.5 2.5 0 0 0 1.77-1.77C22 15.25 22 12 22 12s0-3.25-.42-4.81ZM10 15V9l5.2 3L10 15Z" />
</svg>
);
case "x":
return (
<svg {...common}>
<path d="M17.75 3h3.07l-6.7 7.66L22 21h-6.17l-4.83-6.32L5.47 21H2.4l7.17-8.2L2 3h6.33l4.37 5.77L17.75 3Zm-1.08 16.18h1.7L7.4 4.73H5.58l11.09 14.45Z" />
</svg>
);
case "yelp":
return (
<svg {...common}>
<path d="M12.3 2.2c-.5-.3-3.9.6-4.6 1.3-.3.3-.4.6-.3.9l3.9 6.4c.6.9 1.9.6 1.9-.5L13 3c0-.4-.3-.7-.7-.8Zm-1.9 11.5-6 1.7c-.5.2-.8.6-.7 1.1.1 1 1.2 3.2 1.9 3.6.3.2.7.2 1 0l4.6-4.7c.7-.7.1-2-.8-1.7Zm3.2 1.6c-.6-.8-1.9-.4-1.8.6l.4 6.3c0 .5.4.8.9.8 1 0 3.3-.8 3.9-1.4.3-.3.3-.7.1-1l-3.5-5.3Zm6.5-4.5c-.4-.9-2.4-3.2-3.2-3.5-.4-.1-.7 0-1 .3l-3.4 5.4c-.5.9.4 1.9 1.3 1.5l5.8-2.4c.4-.2.6-.7.5-1.3Zm-.2 5.3-5.9-1.6c-1-.3-1.7.9-1 1.6l4.2 4.5c.3.3.8.4 1.1.1.8-.6 2.2-2.6 2.3-3.6.1-.5-.2-.9-.7-1Z" />
</svg>
);
}
}
const SOCIAL_LABELS: Record<FooterBigSocialNetwork, string> = {
facebook: "Facebook",
instagram: "Instagram",
linkedin: "LinkedIn",
youtube: "YouTube",
x: "X",
yelp: "Yelp",
};
type Status = "idle" | "loading" | "success" | "error";
function Newsletter({
title,
description,
onSubscribe,
}: {
title: string;
description: string;
onSubscribe?: (email: string) => void | Promise<void>;
}) {
const id = React.useId();
const [email, setEmail] = React.useState("");
const [status, setStatus] = React.useState<Status>("idle");
const [error, setError] = React.useState("");
const submit = async (e: React.FormEvent) => {
e.preventDefault();
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
setStatus("error");
setError("Please enter a valid email address.");
return;
}
setStatus("loading");
setError("");
try {
if (onSubscribe) {
await onSubscribe(email);
} else {
await new Promise((r) => setTimeout(r, 900));
}
setStatus("success");
} catch {
setStatus("error");
setError("Something went wrong. Please try again.");
}
};
return (
<div className="max-w-md">
<h3 className="font-display text-2xl font-semibold tracking-tight sm:text-3xl">{title}</h3>
<p className="mt-2 text-sm leading-relaxed text-muted-foreground">{description}</p>
<div className="relative mt-6 h-14">
<AnimatePresence mode="wait" initial={false}>
{status === "success" ? (
<motion.div
key="success"
role="status"
initial={{ opacity: 0, y: 10, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.45, ease: EASE }}
className="absolute inset-0 flex items-center gap-3 rounded-full border border-success/30 bg-success/10 px-2 pr-5"
>
<motion.span
initial={{ scale: 0, rotate: -45 }}
animate={{ scale: 1, rotate: 0 }}
transition={{ type: "spring", stiffness: 420, damping: 30, delay: 0.1 }}
className="flex size-10 shrink-0 items-center justify-center rounded-full bg-success text-background"
>
<Check className="size-5" strokeWidth={2.5} />
</motion.span>
<span className="min-w-0 text-sm">
<span className="font-semibold text-foreground">You're on the list.</span>{" "}
<span className="text-muted-foreground">Check {email} to confirm.</span>
</span>
</motion.div>
) : (
<motion.form
key="form"
onSubmit={submit}
noValidate
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10, scale: 0.98 }}
transition={{ duration: 0.35, ease: EASE }}
className={cn(
"absolute inset-0 flex items-center rounded-full border bg-card p-1.5 pl-5 shadow-sm transition-colors focus-within:border-foreground/25",
status === "error" ? "border-destructive/60" : "border-border",
)}
>
<label htmlFor={`${id}-email`} className="sr-only">
Email address
</label>
<Mail className="mr-3 size-4 shrink-0 text-muted-foreground" aria-hidden />
<input
id={`${id}-email`}
type="email"
inputMode="email"
autoComplete="email"
placeholder="you@company.com"
value={email}
onChange={(e) => {
setEmail(e.target.value);
if (status === "error") setStatus("idle");
}}
aria-invalid={status === "error"}
aria-describedby={status === "error" ? `${id}-err` : undefined}
className="h-full min-w-0 flex-1 bg-transparent text-sm text-foreground outline-none placeholder:text-muted-foreground/70"
/>
<button
type="submit"
disabled={status === "loading"}
className="group inline-flex h-full shrink-0 items-center gap-1.5 rounded-full bg-primary px-4 text-sm font-semibold text-primary-foreground transition-opacity hover:opacity-90 disabled:opacity-70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card sm:px-5"
>
{status === "loading" ? (
<span className="flex items-center gap-1" aria-label="Subscribing">
{[0, 1, 2].map((i) => (
<motion.span
key={i}
className="size-1.5 rounded-full bg-primary-foreground"
animate={{ opacity: [0.3, 1, 0.3] }}
transition={{ duration: 0.9, repeat: Infinity, delay: i * 0.15 }}
/>
))}
</span>
) : (
<>
Subscribe
<ArrowRight className="size-3.5 transition-transform group-hover:translate-x-0.5" />
</>
)}
</button>
</motion.form>
)}
</AnimatePresence>
</div>
<p
id={`${id}-err`}
role={status === "error" ? "alert" : undefined}
className={cn("mt-2 h-4 pl-5 text-xs text-destructive", status !== "error" && "invisible")}
>
{error || " "}
</p>
</div>
);
}
export function FooterBig({
businessName = "Ridgeline Home Services",
tagline = "Plumbing, heating and electrical for Austin homeowners since 2009. Family owned, locally loved.",
newsletterTitle = "Seasonal tips, zero spam.",
newsletterDescription = "One email a month with maintenance checklists and members-only discounts. Unsubscribe anytime.",
onSubscribe,
columns = [
{
title: "Services",
links: [
{ label: "Plumbing repair", href: "#" },
{ label: "Water heaters", href: "#" },
{ label: "AC & heating", href: "#" },
{ label: "Electrical", href: "#" },
{ label: "Drain cleaning", href: "#" },
],
},
{
title: "Service areas",
links: [
{ label: "Austin", href: "#" },
{ label: "Round Rock", href: "#" },
{ label: "Cedar Park", href: "#" },
{ label: "Pflugerville", href: "#" },
{ label: "Georgetown", href: "#" },
],
},
{
title: "Company",
links: [
{ label: "About us", href: "#" },
{ label: "Reviews", href: "#" },
{ label: "Careers", href: "#", badge: "Hiring" },
{ label: "Financing", href: "#" },
{ label: "Blog", href: "#" },
],
},
{
title: "Support",
links: [
{ label: "Book online", href: "#" },
{ label: "Membership plan", href: "#" },
{ label: "Warranty", href: "#" },
{ label: "Pay an invoice", href: "#" },
{ label: "Contact", href: "#" },
],
},
],
address = {
street: "4210 Burnet Rd, Suite 120",
city: "Austin, TX 78756",
mapHref: "https://maps.google.com/?q=4210+Burnet+Rd+Austin+TX",
},
phone = { label: "(512) 555-0142", href: "tel:+15125550142" },
email = "hello@ridgelinehome.com",
hours = [
{ days: "Mon to Fri", hours: "7:00 AM to 7:00 PM" },
{ days: "Saturday", hours: "8:00 AM to 4:00 PM" },
{ days: "Emergency", hours: "24/7" },
],
socials = [
{ network: "facebook", href: "#" },
{ network: "instagram", href: "#" },
{ network: "yelp", href: "#" },
{ network: "youtube", href: "#" },
{ network: "linkedin", href: "#" },
],
wordmark,
legal = [
{ label: "Privacy", href: "#" },
{ label: "Terms", href: "#" },
{ label: "Accessibility", href: "#" },
],
license = "TX License M-41872, TECL 33019",
className,
}: FooterBigProps) {
const reduce = useReducedMotion();
const markRef = React.useRef<HTMLDivElement>(null);
const markInView = useInView(markRef, { once: true, margin: "-40px 0px" });
const year = new Date().getFullYear();
const mark = wordmark ?? businessName.split(" ")[0];
return (
<footer className={cn("relative overflow-hidden border-t border-border bg-background text-foreground", className)}>
<div className="mx-auto max-w-7xl px-5 pt-16 sm:px-8 sm:pt-24">
{/* Top: newsletter + NAP */}
<div className="grid gap-12 border-b border-border pb-14 lg:grid-cols-[1.1fr_1fr] lg:gap-20">
<Newsletter
title={newsletterTitle}
description={newsletterDescription}
onSubscribe={onSubscribe}
/>
<div className="grid gap-8 sm:grid-cols-2">
<address className="not-italic" itemScope itemType="https://schema.org/LocalBusiness">
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-muted-foreground">Visit or call</p>
<p itemProp="name" className="mt-3 font-semibold">{businessName}</p>
<a
href={address.mapHref ?? "#"}
target={address.mapHref ? "_blank" : undefined}
rel={address.mapHref ? "noreferrer" : undefined}
className="group mt-2 flex items-start gap-2 rounded-md text-sm text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<MapPin className="mt-0.5 size-4 shrink-0 text-brand" />
<span itemProp="address">
{address.street}
<br />
{address.city}
</span>
</a>
<a
href={phone.href}
itemProp="telephone"
className="mt-3 flex items-center gap-2 rounded-md font-mono text-lg font-semibold tracking-tight transition-colors hover:text-brand focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<Phone className="size-4 text-brand" />
{phone.label}
</a>
<a
href={`mailto:${email}`}
itemProp="email"
className="mt-1.5 flex items-center gap-2 rounded-md text-sm text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<Mail className="size-4 text-brand" />
{email}
</a>
</address>
<div>
<p className="flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.16em] text-muted-foreground">
<Clock className="size-3.5" /> Hours
</p>
<dl className="mt-3 space-y-2 text-sm">
{hours.map((h) => (
<div key={h.days} className="flex items-baseline justify-between gap-4 border-b border-dashed border-border pb-2 last:border-0">
<dt className="text-muted-foreground">{h.days}</dt>
<dd className="font-medium tabular-nums">{h.hours}</dd>
</div>
))}
</dl>
{socials.length > 0 && (
<ul className="mt-6 flex flex-wrap gap-2">
{socials.map((s) => (
<li key={s.network}>
<a
href={s.href}
aria-label={SOCIAL_LABELS[s.network]}
className="flex size-10 items-center justify-center rounded-full border border-border bg-card text-muted-foreground transition-all hover:-translate-y-0.5 hover:border-brand/40 hover:bg-brand hover:text-brand-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<SocialIcon network={s.network} />
</a>
</li>
))}
</ul>
)}
</div>
</div>
</div>
{/* Link columns */}
<div className="grid gap-10 py-14 sm:grid-cols-2 lg:grid-cols-[1.2fr_repeat(4,1fr)]">
<div className="max-w-xs">
<p className="flex items-center gap-2 font-display text-lg font-semibold tracking-tight">
<span className="flex size-7 items-center justify-center rounded-lg bg-brand text-xs font-bold text-brand-foreground">
{businessName.charAt(0)}
</span>
{businessName}
</p>
<p className="mt-3 text-sm leading-relaxed text-muted-foreground">{tagline}</p>
</div>
{columns.map((col) => (
<nav key={col.title} aria-label={col.title}>
<p className="text-sm font-semibold">{col.title}</p>
<ul className="mt-4 space-y-3">
{col.links.map((l) => (
<li key={l.label}>
<a
href={l.href}
className="group inline-flex items-center gap-1.5 rounded-sm text-sm text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<span className="relative">
{l.label}
<span className="absolute -bottom-0.5 left-0 h-px w-full origin-left scale-x-0 bg-foreground/40 transition-transform duration-300 group-hover:scale-x-100" />
</span>
{l.badge && (
<span className="rounded-full bg-brand/10 px-1.5 py-px text-[10px] font-semibold text-brand">
{l.badge}
</span>
)}
</a>
</li>
))}
</ul>
</nav>
))}
</div>
{/* Legal row */}
<div className="flex flex-col gap-4 border-t border-border py-6 text-xs text-muted-foreground md:flex-row md:items-center md:justify-between">
<p>
© {year} {businessName}. All rights reserved.
{license && <span className="ml-1 hidden sm:inline">{license}.</span>}
</p>
<ul className="flex flex-wrap items-center gap-x-5 gap-y-2">
{legal.map((l) => (
<li key={l.label}>
<a href={l.href} className="rounded-sm transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
{l.label}
</a>
</li>
))}
<li>
<a href="#top" className="inline-flex items-center gap-1 rounded-sm transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
Back to top <ArrowUpRight className="size-3" />
</a>
</li>
</ul>
</div>
</div>
{/* Oversized faded wordmark */}
<div ref={markRef} aria-hidden className="pointer-events-none relative -mt-2 select-none overflow-hidden">
<motion.p
initial={reduce ? { opacity: 1 } : { y: "35%", opacity: 0 }}
animate={markInView ? { y: "18%", opacity: 1 } : undefined}
transition={{ duration: 1.4, ease: EASE }}
className="whitespace-nowrap text-center font-display font-semibold leading-[0.8] tracking-[-0.06em] text-transparent"
style={{
fontSize: `clamp(4rem, ${Math.max(8, 150 / Math.max(mark.length, 1))}vw, 22rem)`,
backgroundImage:
"linear-gradient(to bottom, color-mix(in oklch, var(--foreground) 12%, transparent), transparent 85%)",
WebkitBackgroundClip: "text",
backgroundClip: "text",
}}
>
{mark}
</motion.p>
</div>
</footer>
);
}
export default FooterBig;app/page.tsx
import { FooterBig } from "@/components/facade/footer-big";
export default function Demo() {
return <FooterBig />;
}Installation
$
npx shadcn@latest add https://facadeui.com/r/footer-big.jsonUsage
import { FooterBig } from "@/components/facade/footer-big";
export default function Page() {
return <FooterBig />;
}Props
FooterBigProps is exported, so you can extend or wrap it. Every prop has a default; <FooterBig /> renders the example above.
| Prop | Type | Description |
|---|---|---|
| businessName | string | |
| tagline | string | |
| newsletterTitle | string | |
| newsletterDescription | string | |
| onSubscribe | (email: string) => void | Promise<void> | Called with the email on submit. Return a promise to show a loading state. Without a handler the form only simulates success, for previews. |
| columns | FooterBigColumn[] | |
| address | { street: string; city: string; mapHref?: string } | |
| phone | { label: string; href: string } | |
| string | ||
| hours | FooterBigHours[] | |
| socials | FooterBigSocial[] | |
| wordmark | string | Oversized faded wordmark. Defaults to businessName. |
| legal | FooterBigLink[] | |
| license | string | |
| className | string |
Ask your agent
With the Facade MCP server connected, this prompt is enough:
Add the Facade footer, big to this page and adapt the copy to our business.