Closing CTA and footer
One final action tied to the page's job, then a compact footer with a few grouped links, a real contact route and the legal links.
Community — not yet reviewed by a maintainer
Preview
1440 px wide. Open this preview on its own ↗
Install
npx shadcn@latest add online-scope-lab/tessli/closing-cta-footerRun it in a project set up with shadcn/ui. It adds closing-cta-footer.tsx to your components folder, plus the shadcn/ui button component if you don’t have it, and the item takes on your project’s theme.
When to use it
Use when
- A marketing, pricing or product page ends and the reader should have one clear next step.
- The site needs a footer that helps people find a few things (pricing, help, contact, legal), not a sitemap.
Avoid when
- The page's job is not to convert (documentation, help, legal); leave out the CTA and keep the footer only.
- The site has dozens of destinations people browse from the footer; give it a proper sitemap page and link to it.
- The last action on the page is already the same as the hero's with nothing new to say; restate why to act, or drop the CTA.
Common AI default vs. this
The common default
A centred "Ready to get started?" band on a gradient with two equal filled buttons ("Get started" and "Book a demo") repeating the hero word for word, followed by a five-column link-farm footer of every page, a newsletter field, a row of social-media icons and an arrow after every link.
What this item does instead
- The closing heading restates the page's point as a reason to act now, and the single primary action says what happens next ("Create a free workspace").
- The alternative is a quiet text link ("Compare plans"), so the page ends on one obvious step instead of a choice between two equal buttons.
- A short note under the action removes the last doubt (price, card, commitment) and only says what is true.
- The footer groups a handful of links people actually look for, names a real contact route with who answers and how fast, and keeps legal links in their own row.
- Only the link that leaves the site carries ↗, with a screen-reader note; nothing else gets an arrow, and there are no icon-only social links.
Why it is built this way
The end of a page is where the reader decides. A closing section earns its place when it restates the page's point as a reason to act now and offers one action named by what happens next. Anything else is a quieter text link, not a second filled button.
The footer is for finding things, not for showing how big the site is. Keep it to a few groups of links people really look for (pricing, help, export, security), a contact route that says who answers and how fast, and the legal links. The product name is text; no logo, newsletter field or social icons are needed.
Pass cta only on pages whose job is to convert. On help, documentation and legal pages, leave it out and the footer stands alone, as the footer-only state shows. Mark links that leave the site with external, and only those get an arrow.
Rules
- H-01
- W-01
- W-02
- W-05
- S-02
- K-02
- R-01
- A-01
Each rule page says why the rule matters and how to check it. Rules are drafts until a maintainer signs them; see all rules.
How it breaks
Common mistakes when adapting this item.
- Repeating the hero's headline and buttons word for word, so the closing section adds nothing.
- Adding a second filled button, or styling the secondary link as a button of equal weight.
- Turning the footer into a sitemap with five or more columns, or adding social icons with no visible labels.
- Writing a reassurance note ("Cancel anytime", "No card needed") that the product's terms do not support.
- Pointing the contact line at an inbox nobody reads, or promising a reply time nobody keeps.
States, responsive behaviour and accessibility
- States
- default, footer only
- Desktop
- The closing heading, text and actions share one left edge with text capped at a readable measure; the footer puts the name, description and contact in one column beside two to four link groups, with copyright and legal links in a row below.
- Mobile
- The primary action becomes full width with the text link below it on the same left edge; the footer stacks name and contact first, link groups in two columns, then copyright and legal links.
Accessibility
- The closing section is labelled by its h2; the footer is a footer element with two navigation landmarks, "Footer" and "Legal".
- Each link group is a list labelled by its visible group name (aria-labelledby), so screen readers announce "Product, list, 3 items".
- Every footer link, the brand link and the text link are at least 44px tall with a visible focus ring; the mailto link in the contact sentence is an inline link.
- The external link's ↗ is aria-hidden and replaced by "(another site)" for screen readers; nothing relies on colour or hover alone.
Code
web/registry/items/closing-cta-footer/closing-cta-footer.tsx
import * as React from "react";
import { cn } from "@/registry/shadcn/lib/utils";
import { buttonVariants } from "@/registry/shadcn/ui/button";
export type FooterLink = {
label: string;
href: string;
/**
* The link leaves your site. It gets a ↗ and a screen-reader note; links
* that stay on the site never get an arrow.
*/
external?: boolean;
};
export type FooterLinkGroup = {
/** A short, plain group name: "Product", "Help", "Company". */
title: string;
/** Three to five links people actually look for at the end of a page. */
links: FooterLink[];
};
export type ClosingCta = {
/**
* The page's point, restated as a reason to act now. Not "Ready to get
* started?".
*/
title: React.ReactNode;
description?: React.ReactNode;
/** The one primary action, named by what happens next. */
action: { label: string; href: string };
/** An optional quieter alternative, rendered as a text link, not a button. */
secondaryAction?: { label: string; href: string };
/** What removes the last doubt: price, card, commitment. Only if true. */
note?: React.ReactNode;
};
export type ClosingCtaFooterProps = React.ComponentProps<"div"> & {
/** Leave out on pages whose job is not to convert (docs, legal, help). */
cta?: ClosingCta;
/** The product name as text, linking home. No logo is required. */
brand: { name: string; href: string; description?: React.ReactNode };
/** Two to four groups. More than that is a sitemap, not a footer. */
groups: FooterLinkGroup[];
/** A real contact route: who answers and how. */
contact?: React.ReactNode;
/** Privacy, terms and any other legally required links. */
legal?: FooterLink[];
/** Copyright or company line, e.g. "© 2026 Example Ltd." */
copyright?: React.ReactNode;
};
const linkClass =
"inline-flex min-h-11 min-w-11 items-center rounded-sm text-sm text-muted-foreground underline-offset-4 hover:text-foreground hover:underline focus-visible:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none";
function FooterAnchor({ link }: { link: FooterLink }) {
return (
<a href={link.href} className={linkClass}>
{/* One inline run, so a wrapped label keeps ↗ beside its last word. */}
<span>
{link.label}
{link.external ? (
<>
<span aria-hidden="true"> ↗</span>
<span className="sr-only"> (another site)</span>
</>
) : null}
</span>
</a>
);
}
export function ClosingCtaFooter({
cta,
brand,
groups,
contact,
legal,
copyright,
className,
...props
}: ClosingCtaFooterProps) {
const id = React.useId();
const ctaHeadingId = `${id}-cta`;
return (
<div
data-slot="closing-cta-footer"
className={cn("bg-background text-foreground", className)}
{...props}
>
{cta ? (
<section aria-labelledby={ctaHeadingId}>
<div className="mx-auto w-full max-w-6xl px-4 py-16 sm:px-6 sm:py-20 lg:px-8 lg:py-24">
<div className="max-w-2xl">
<h2
id={ctaHeadingId}
className="text-3xl leading-tight font-semibold tracking-tight text-balance sm:text-4xl"
>
{cta.title}
</h2>
{cta.description ? (
<p className="mt-4 text-base leading-relaxed text-pretty text-muted-foreground">
{cta.description}
</p>
) : null}
<div className="mt-8 flex flex-col gap-3 sm:flex-row sm:items-center sm:gap-6">
<a
href={cta.action.href}
className={cn(
buttonVariants({ size: "lg" }),
"h-11 w-full sm:w-auto",
)}
>
{cta.action.label}
</a>
{cta.secondaryAction ? (
<a
href={cta.secondaryAction.href}
className="inline-flex min-h-11 items-center self-center rounded-sm text-sm font-medium text-foreground underline underline-offset-4 focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none sm:self-auto"
>
{cta.secondaryAction.label}
</a>
) : null}
</div>
{cta.note ? (
<div className="mt-4 text-sm leading-relaxed text-muted-foreground">
{cta.note}
</div>
) : null}
</div>
</div>
</section>
) : null}
<footer className="border-t border-border">
<div className="mx-auto w-full max-w-6xl px-4 pt-10 pb-6 sm:px-6 lg:px-8 lg:pt-12">
<div className="grid gap-8 lg:grid-cols-[minmax(0,1fr)_minmax(0,2fr)] lg:gap-12">
<div className="max-w-sm">
<a
href={brand.href}
className="inline-flex min-h-11 items-center rounded-sm text-base font-semibold tracking-tight focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none"
>
{brand.name}
</a>
{brand.description ? (
<p className="mt-1 text-sm leading-relaxed text-pretty text-muted-foreground">
{brand.description}
</p>
) : null}
{contact ? (
<div className="mt-4 text-sm leading-relaxed text-pretty text-muted-foreground">
{contact}
</div>
) : null}
</div>
<nav aria-label="Footer">
<div
className={cn(
"grid grid-cols-2 gap-x-6 gap-y-8",
groups.length === 3 && "sm:grid-cols-3",
groups.length >= 4 && "md:grid-cols-4",
)}
>
{groups.map((group, index) => {
const groupId = `${id}-group-${index}`;
return (
<div key={group.title}>
{/* A list label, not a heading: "Product" says nothing
about the page, and the nav landmark already lets
people jump here. */}
<p
id={groupId}
className="text-sm font-medium text-foreground"
>
{group.title}
</p>
<ul aria-labelledby={groupId} className="mt-2">
{group.links.map((link) => (
<li key={link.href}>
<FooterAnchor link={link} />
</li>
))}
</ul>
</div>
);
})}
</div>
</nav>
</div>
{legal?.length || copyright ? (
<div className="mt-10 flex flex-col gap-2 border-t border-border pt-4 sm:flex-row sm:items-center sm:justify-between sm:gap-6">
{copyright ? (
<div className="text-sm text-muted-foreground">{copyright}</div>
) : null}
{legal?.length ? (
<nav aria-label="Legal">
<ul className="flex flex-wrap gap-x-6">
{legal.map((link) => (
<li key={link.href}>
<FooterAnchor link={link} />
</li>
))}
</ul>
</nav>
) : null}
</div>
) : null}
</div>
</footer>
</div>
);
}
Credits and licence
- Contributors
- None credited yet.
- Provenance
- Original work
- Licence
- MIT
- Review
- Community — not yet reviewed by a maintainer
For agents
The same taste notes and code as structured data.