# Tessli library item — 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.

Kind: section
Category: cta
Status: community (not yet reviewed by a maintainer)
Tessli page: /library/closing-cta-footer
Preview: /preview/closing-cta-footer

## Install

```sh
npx shadcn@latest add online-scope-lab/tessli/closing-cta-footer
```

## 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.

## Rules

- H-01 — Give each section, card or dialog at most one primary-styled action. (/rules/H-01)
- W-01 — Write headings that make the product's point, not labels like "Features" or "FAQ". (/rules/W-01)
- W-02 — Label actions with what happens next, not "Get started", "Submit" or "Learn more". (/rules/W-02)
- W-05 — Put an arrow on a link only when it leaves the site (↗), never on every link. (/rules/W-05)
- S-02 — Align a section's heading, text and actions on one left edge; centre only short blocks. (/rules/S-02)
- K-02 — Use a link to go to a URL and a button to act on the page; the style never decides. (/rules/K-02)
- R-01 — On phones, restack content by priority and adapt components; don't shrink the desktop. (/rules/R-01)
- A-01 — Make every interactive target at least 44×44 CSS px; inline text links excepted. (/rules/A-01)

## Common AI 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.

## Why this is better

- 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.

## Common failures when adapting it

- 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

- default
- footer-only

## Responsive behaviour

- 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.

## Notes

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.

## Credits

- Contributors: none credited yet
- Provenance: original
- Licence: MIT

## Source

`web/registry/items/closing-cta-footer/closing-cta-footer.tsx`

```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">&nbsp;↗</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>
  );
}
```

## Interpretation boundaries

- Community items are merged and pass automated checks (schema, build, accessibility, overflow and console errors in three themes at five widths); only items with status reviewed have been checked by a maintainer.
- Each rule id links to its page at /rules/<id>; every rule is a draft until a maintainer signs it.
- Tessli does not claim that using an item improves AI-generated UI; that claim waits for a published evaluation.
