Footer
Footer Newsletter
A call-to-action photo card over the edge of an inverted footer; contact, two link columns and a newsletter sign-up.
Layout and motion from the Findit template — see it in context"use client";
import Link from "next/link";
import { FlipButton } from "@/components/ui/flip-button";
import { SubscribeInput } from "@/components/ui/subscribe-input";
import { cn } from "@/lib/utils";
/*
* Footer Newsletter — a call-to-action photo card over the edge of an inverted footer, then contact details, two link
* columns and a newsletter sign-up, a line, the copyright and social links.
* Source: marcframe/findit → components/site/Footer.tsx (+ design-system NewsletterForm).
*
* Layout kept from Framer: the card (467px tall) straddles the edge — the page background runs to its middle, the
* inverted footer starts there. On the card: a dark gradient from the top, then the centered heading, text and button
* at the bottom. Below it, 96px apart (64px on phones, 80px from 768px): contact, two link columns and the newsletter
* — two columns on phones and three from 768px with the newsletter across two, one row from 1024px with the
* newsletter 350px wide; a line; the copyright, and the social links beside it (above it on phones).
* Motion kept: the card's button rolls its label (flip-button); the newsletter button shows a spinner, then a check
* (subscribe-input). Links go from 80% to full with an underline on hover (Findit turns them yellow).
* Sending is yours: pass `onSubscribe`; without it the form only pretends to send. The group titles are h2 — a
* footer's parts are top-level sections of the page — inside the footer landmark.
*/
type LinkItem = { label: string; href: string };
export type FooterNewsletterProps = {
cta?: { title: string; text?: string; action?: LinkItem; image: string };
contact?: { title: string; address?: string; phones?: string[]; email?: string };
columns?: { title: string; links: LinkItem[] }[];
newsletter?: {
title: string;
text?: string;
placeholder?: string;
submitLabel?: string;
/** Small print under the field; its link goes to your privacy policy. */
consent?: { text: string; link?: LinkItem };
};
/** Called with the address; throw (or reject) to show the error state. */
onSubscribe?: (email: string) => Promise<void> | void;
/** "{year}" becomes the current year. */
copyright?: string;
socialLabel?: string;
socials?: LinkItem[];
className?: string;
};
const defaults = {
cta: {
title: "Ready to find your next home?",
text: "Tell us what you're looking for and an agent will send you a shortlist within a day — no obligation.",
action: { label: "Get in touch", href: "/contact" },
image: "https://images.unsplash.com/photo-1600585154340-be6161a56a0c?auto=format&fit=crop&w=2000&q=80",
},
contact: {
title: "Contact us",
address: "480 Harbor Road, Suite 12, Westport",
phones: ["+1 (555) 014 2290", "+1 (555) 017 8834"],
email: "hello@harbor.example",
},
columns: [
{
title: "Explore",
links: [
{ label: "Properties", href: "/properties" },
{ label: "For sale", href: "/properties?type=sale" },
{ label: "For rent", href: "/properties?type=rent" },
{ label: "Locations", href: "/locations" },
],
},
{
title: "Company",
links: [
{ label: "About us", href: "/about" },
{ label: "Our agents", href: "/agents" },
{ label: "Journal", href: "/blog" },
{ label: "Contact", href: "/contact" },
],
},
],
newsletter: {
title: "Stay in the loop",
text: "New listings and market notes, once a month.",
placeholder: "Your email",
submitLabel: "Subscribe",
consent: { text: "By subscribing you agree to our", link: { label: "privacy policy", href: "/privacy" } },
},
copyright: "© {year} Harbor Homes. All rights reserved.",
socialLabel: "Follow us:",
socials: [
{ label: "Instagram", href: "https://instagram.com" },
{ label: "LinkedIn", href: "https://linkedin.com" },
{ label: "Facebook", href: "https://facebook.com" },
],
};
const footerLink = "text-background/80 transition-colors hover:text-background hover:underline hover:underline-offset-4";
export function FooterNewsletter({
cta = defaults.cta,
contact = defaults.contact,
columns = defaults.columns,
newsletter = defaults.newsletter,
onSubscribe,
copyright = defaults.copyright,
socialLabel = defaults.socialLabel,
socials = defaults.socials,
className,
}: FooterNewsletterProps) {
const subscribe = onSubscribe ?? (() => new Promise<void>((resolve) => setTimeout(resolve, 900)));
return (
<footer className={cn("bg-foreground pb-5 text-background", className)}>
{cta && (
<div className="relative pt-(--section-y)">
{/* The page background runs to the middle of the card: the top padding plus half the card. */}
<div aria-hidden className="absolute inset-x-0 top-0 h-[calc(50%+var(--section-y)/2)] bg-background" />
<div className="relative mx-auto max-w-(--container) px-(--gutter)">
<div className="relative flex h-116.75 overflow-hidden rounded-xl bg-muted">
<img src={cta.image} alt="" className="absolute inset-0 size-full object-cover" />
<div className="relative flex w-full flex-col items-center justify-end gap-12 bg-linear-to-b from-black/10 via-black/50 via-40% to-black px-7.5 pt-24 pb-12 text-center text-white">
<div className="flex flex-col items-center gap-5">
<h2 className="font-heading text-h2 font-semibold tracking-tight text-balance">{cta.title}</h2>
{cta.text && <p className="max-w-150 text-pretty text-white/85">{cta.text}</p>}
</div>
{cta.action && (
<FlipButton href={cta.action.href} size="lg" className="bg-white text-neutral-950 hover:bg-white/90">
{cta.action.label}
</FlipButton>
)}
</div>
</div>
</div>
</div>
)}
<div className={cn("mx-auto flex max-w-(--container) flex-col gap-12 px-(--gutter)", cta ? "pt-16 md:pt-20 lg:pt-24" : "pt-(--section-y)")}>
<div className="grid grid-cols-2 gap-12 md:grid-cols-3 lg:flex">
{contact && (
<Column title={contact.title}>
{contact.address && <p>{contact.address}</p>}
{contact.phones && contact.phones.length > 0 && (
<span className="flex flex-col items-start gap-1">
{contact.phones.map((phone) => (
<a key={phone} href={`tel:${phone.replace(/[^\d+]/g, "")}`} className={footerLink}>
{phone}
</a>
))}
</span>
)}
{contact.email && (
<a href={`mailto:${contact.email}`} className={cn(footerLink, "break-all")}>
{contact.email}
</a>
)}
</Column>
)}
{columns.map((column) => (
<Column key={column.title} title={column.title}>
{column.links.map((link) => (
<Link key={link.href + link.label} href={link.href} className={footerLink}>
{link.label}
</Link>
))}
</Column>
))}
{newsletter && (
<div className="col-span-2 flex flex-col gap-5 lg:w-87.5 lg:flex-none">
<h2 className="font-heading text-lg font-semibold tracking-tight">{newsletter.title}</h2>
<div className="flex flex-col gap-4">
{newsletter.text && <p className="text-sm text-background/80">{newsletter.text}</p>}
<SubscribeInput
onSubmit={subscribe}
placeholder={newsletter.placeholder}
submitLabel={newsletter.submitLabel}
className="border-transparent"
/>
{newsletter.consent && (
<p className="text-xs text-background/80">
{newsletter.consent.text}
{newsletter.consent.link && (
<>
{" "}
<Link href={newsletter.consent.link.href} className="font-medium text-background underline underline-offset-4">
{newsletter.consent.link.label}
</Link>
.
</>
)}
</p>
)}
</div>
</div>
)}
</div>
<div className="flex flex-col-reverse items-start gap-5 border-t border-background/15 pt-5 text-sm md:flex-row md:items-center">
{/* The year is rendered on the server too; a new year before the next build must not warn. */}
<p suppressHydrationWarning className="flex-1 text-background/80">{copyright.replace("{year}", String(new Date().getFullYear()))}</p>
{socials.length > 0 && (
<div className="flex flex-wrap items-center gap-x-2.5 gap-y-1">
{socialLabel && <span>{socialLabel}</span>}
{socials.map((social) => (
<a key={social.href} href={social.href} target="_blank" rel="noopener noreferrer" className={footerLink}>
{social.label}
</a>
))}
</div>
)}
</div>
</div>
</footer>
);
}
/* A group: its title, then lines and links 10px apart at 80%. */
function Column({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="flex min-w-0 flex-col gap-5 lg:flex-1">
<h2 className="font-heading text-lg font-semibold tracking-tight">{title}</h2>
<div className="flex flex-col items-start gap-2.5 text-sm text-background/80">{children}</div>
</div>
);
}Installation
$ npx shadcn@latest add https://framebrick.co/r/blocks/footer-newsletter.json1. Add the Framebrick UI components it uses (they bring Motion).
npx shadcn@latest add https://framebrick.co/r/flip-button.json https://framebrick.co/r/subscribe-input.json2. Add the block tokens to
app/globals.cssonce (theming).npx shadcn@latest add https://framebrick.co/r/blocks/tokens.json3. Copy the block into
components/footer-newsletter.tsx."use client"; import Link from "next/link"; import { FlipButton } from "@/components/ui/flip-button"; import { SubscribeInput } from "@/components/ui/subscribe-input"; import { cn } from "@/lib/utils"; /* * Footer Newsletter — a call-to-action photo card over the edge of an inverted footer, then contact details, two link * columns and a newsletter sign-up, a line, the copyright and social links. * Source: marcframe/findit → components/site/Footer.tsx (+ design-system NewsletterForm). * * Layout kept from Framer: the card (467px tall) straddles the edge — the page background runs to its middle, the * inverted footer starts there. On the card: a dark gradient from the top, then the centered heading, text and button * at the bottom. Below it, 96px apart (64px on phones, 80px from 768px): contact, two link columns and the newsletter * — two columns on phones and three from 768px with the newsletter across two, one row from 1024px with the * newsletter 350px wide; a line; the copyright, and the social links beside it (above it on phones). * Motion kept: the card's button rolls its label (flip-button); the newsletter button shows a spinner, then a check * (subscribe-input). Links go from 80% to full with an underline on hover (Findit turns them yellow). * Sending is yours: pass `onSubscribe`; without it the form only pretends to send. The group titles are h2 — a * footer's parts are top-level sections of the page — inside the footer landmark. */ type LinkItem = { label: string; href: string }; export type FooterNewsletterProps = { cta?: { title: string; text?: string; action?: LinkItem; image: string }; contact?: { title: string; address?: string; phones?: string[]; email?: string }; columns?: { title: string; links: LinkItem[] }[]; newsletter?: { title: string; text?: string; placeholder?: string; submitLabel?: string; /** Small print under the field; its link goes to your privacy policy. */ consent?: { text: string; link?: LinkItem }; }; /** Called with the address; throw (or reject) to show the error state. */ onSubscribe?: (email: string) => Promise<void> | void; /** "{year}" becomes the current year. */ copyright?: string; socialLabel?: string; socials?: LinkItem[]; className?: string; }; const defaults = { cta: { title: "Ready to find your next home?", text: "Tell us what you're looking for and an agent will send you a shortlist within a day — no obligation.", action: { label: "Get in touch", href: "/contact" }, image: "https://images.unsplash.com/photo-1600585154340-be6161a56a0c?auto=format&fit=crop&w=2000&q=80", }, contact: { title: "Contact us", address: "480 Harbor Road, Suite 12, Westport", phones: ["+1 (555) 014 2290", "+1 (555) 017 8834"], email: "hello@harbor.example", }, columns: [ { title: "Explore", links: [ { label: "Properties", href: "/properties" }, { label: "For sale", href: "/properties?type=sale" }, { label: "For rent", href: "/properties?type=rent" }, { label: "Locations", href: "/locations" }, ], }, { title: "Company", links: [ { label: "About us", href: "/about" }, { label: "Our agents", href: "/agents" }, { label: "Journal", href: "/blog" }, { label: "Contact", href: "/contact" }, ], }, ], newsletter: { title: "Stay in the loop", text: "New listings and market notes, once a month.", placeholder: "Your email", submitLabel: "Subscribe", consent: { text: "By subscribing you agree to our", link: { label: "privacy policy", href: "/privacy" } }, }, copyright: "© {year} Harbor Homes. All rights reserved.", socialLabel: "Follow us:", socials: [ { label: "Instagram", href: "https://instagram.com" }, { label: "LinkedIn", href: "https://linkedin.com" }, { label: "Facebook", href: "https://facebook.com" }, ], }; const footerLink = "text-background/80 transition-colors hover:text-background hover:underline hover:underline-offset-4"; export function FooterNewsletter({ cta = defaults.cta, contact = defaults.contact, columns = defaults.columns, newsletter = defaults.newsletter, onSubscribe, copyright = defaults.copyright, socialLabel = defaults.socialLabel, socials = defaults.socials, className, }: FooterNewsletterProps) { const subscribe = onSubscribe ?? (() => new Promise<void>((resolve) => setTimeout(resolve, 900))); return ( <footer className={cn("bg-foreground pb-5 text-background", className)}> {cta && ( <div className="relative pt-(--section-y)"> {/* The page background runs to the middle of the card: the top padding plus half the card. */} <div aria-hidden className="absolute inset-x-0 top-0 h-[calc(50%+var(--section-y)/2)] bg-background" /> <div className="relative mx-auto max-w-(--container) px-(--gutter)"> <div className="relative flex h-116.75 overflow-hidden rounded-xl bg-muted"> <img src={cta.image} alt="" className="absolute inset-0 size-full object-cover" /> <div className="relative flex w-full flex-col items-center justify-end gap-12 bg-linear-to-b from-black/10 via-black/50 via-40% to-black px-7.5 pt-24 pb-12 text-center text-white"> <div className="flex flex-col items-center gap-5"> <h2 className="font-heading text-h2 font-semibold tracking-tight text-balance">{cta.title}</h2> {cta.text && <p className="max-w-150 text-pretty text-white/85">{cta.text}</p>} </div> {cta.action && ( <FlipButton href={cta.action.href} size="lg" className="bg-white text-neutral-950 hover:bg-white/90"> {cta.action.label} </FlipButton> )} </div> </div> </div> </div> )} <div className={cn("mx-auto flex max-w-(--container) flex-col gap-12 px-(--gutter)", cta ? "pt-16 md:pt-20 lg:pt-24" : "pt-(--section-y)")}> <div className="grid grid-cols-2 gap-12 md:grid-cols-3 lg:flex"> {contact && ( <Column title={contact.title}> {contact.address && <p>{contact.address}</p>} {contact.phones && contact.phones.length > 0 && ( <span className="flex flex-col items-start gap-1"> {contact.phones.map((phone) => ( <a key={phone} href={`tel:${phone.replace(/[^\d+]/g, "")}`} className={footerLink}> {phone} </a> ))} </span> )} {contact.email && ( <a href={`mailto:${contact.email}`} className={cn(footerLink, "break-all")}> {contact.email} </a> )} </Column> )} {columns.map((column) => ( <Column key={column.title} title={column.title}> {column.links.map((link) => ( <Link key={link.href + link.label} href={link.href} className={footerLink}> {link.label} </Link> ))} </Column> ))} {newsletter && ( <div className="col-span-2 flex flex-col gap-5 lg:w-87.5 lg:flex-none"> <h2 className="font-heading text-lg font-semibold tracking-tight">{newsletter.title}</h2> <div className="flex flex-col gap-4"> {newsletter.text && <p className="text-sm text-background/80">{newsletter.text}</p>} <SubscribeInput onSubmit={subscribe} placeholder={newsletter.placeholder} submitLabel={newsletter.submitLabel} className="border-transparent" /> {newsletter.consent && ( <p className="text-xs text-background/80"> {newsletter.consent.text} {newsletter.consent.link && ( <> {" "} <Link href={newsletter.consent.link.href} className="font-medium text-background underline underline-offset-4"> {newsletter.consent.link.label} </Link> . </> )} </p> )} </div> </div> )} </div> <div className="flex flex-col-reverse items-start gap-5 border-t border-background/15 pt-5 text-sm md:flex-row md:items-center"> {/* The year is rendered on the server too; a new year before the next build must not warn. */} <p suppressHydrationWarning className="flex-1 text-background/80">{copyright.replace("{year}", String(new Date().getFullYear()))}</p> {socials.length > 0 && ( <div className="flex flex-wrap items-center gap-x-2.5 gap-y-1"> {socialLabel && <span>{socialLabel}</span>} {socials.map((social) => ( <a key={social.href} href={social.href} target="_blank" rel="noopener noreferrer" className={footerLink}> {social.label} </a> ))} </div> )} </div> </div> </footer> ); } /* A group: its title, then lines and links 10px apart at 80%. */ function Column({ title, children }: { title: string; children: React.ReactNode }) { return ( <div className="flex min-w-0 flex-col gap-5 lg:flex-1"> <h2 className="font-heading text-lg font-semibold tracking-tight">{title}</h2> <div className="flex flex-col items-start gap-2.5 text-sm text-background/80">{children}</div> </div> ); }
Usage
import { FooterNewsletter } from "@/components/footer-newsletter";
<FooterNewsletter
cta={{
title: "Ready to find your next home?",
action: { label: "Get in touch", href: "/contact" },
image: "/footer/house.jpg",
}}
onSubscribe={async (email) => {
await fetch("/api/subscribe", { method: "POST", body: JSON.stringify({ email }) });
}}
/>Props
| Prop | Type | Default | Description |
|---|---|---|---|
| cta | { title, text?, action?, image } | — | The photo card over the edge (decorative photo, dark gradient, white text); omit to drop it. |
| contact | { title, address?, phones?, email? } | — | First column; phones and email become links. |
| columns | { title, links }[] | Explore, Company | Link columns. |
| newsletter | { title, text?, placeholder?, submitLabel?, consent? } | — | Sign-up column; consent is small print with a link to your privacy policy. |
| onSubscribe | (email) => Promise<void> | void | — | Sends the address; throw to show the error state. Without it the form pretends to send. |
| copyright | string | © {year} Harbor Homes… | "{year}" becomes the current year. |
| socialLabel / socials | string / { label, href }[] | — | Beside the copyright (above it on phones); open in a new tab. |
| className | string | — | Classes for the <footer>. |
- The page background runs to the middle of the 467px card; the inverted footer (bg-foreground) starts there, so the card straddles the edge.
- Columns: two on phones, three from 768px with the newsletter across two, one row from 1024px with the newsletter 350px wide. Group titles are h2 inside the footer landmark.
- The card's button rolls its label (flip-button); the newsletter button shows a spinner, then a check, and the result is announced (subscribe-input). Links go from 80% to full with an underline on hover.
AI prompt
Paste into v0, Lovable, Bolt or Claude to rebuild this block in your own project.
Create a React + Tailwind CSS block called FooterNewsletter (Next.js, client component). - <footer className="bg-foreground text-background pb-5">. First a wrapper pt-(--section-y) whose top part (the padding plus half the card) is bg-background, so a 467px card (rounded-xl, overflow-hidden) straddles the edge: a decorative photo, a gradient from black/10 via black/50 (40%) to black, and at the bottom, centered and white: an h2 (font-heading text-h2), a paragraph (max 600px) and a white button whose label rolls. - Then the container (pt-16 / md:pt-20 / lg:pt-24, gap-12): a grid of 2 columns (3 from 768px, a flex row from 1024px, gap-12): Contact (address, phones, email), two link columns, and a newsletter column spanning 2 (lg: 350px): h2 (text-lg), text, an e-mail pill with a round submit button (spinner while sending, check on success), small print with a privacy link. Titles are h2 (font-heading text-lg); links text-background/80, full with an underline on hover. - A border-t (background/15) row: the copyright (current year) and "Follow us:" social links (above the copyright on phones). - Only shadcn variables and block tokens (white text on the photo card); plain <img>.











