# Site Footer

> The end of every page, in three decisions: a full map of the site, one quiet line, or a newsletter field above the links.

A shadcn/ui block from the [@miseui](https://miseui.junsu4621.workers.dev/?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/miseui/site-footer).

- Page, with a live preview: https://shadcn.exchange/r/miseui/site-footer
- Screenshot: https://shadcn.exchange/shot/miseui/site-footer/light.png
- Registry item: https://miseui.junsu4621.workers.dev/r/site-footer.json
- Categories: blocks
- Listed: 2026-11-05, source last changed 2026-11-05

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/miseui/site-footer.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `lucide-react`
- Registry items: `utils`, `https://miseui.junsu4621.workers.dev/r/button.json`, `https://miseui.junsu4621.workers.dev/r/input.json`, `https://miseui.junsu4621.workers.dev/r/label.json`

## Files

### `registry/miseui/blocks/site-footer.tsx`

```tsx
import { cn } from "@/registry/miseui/lib/utils"
import {
  CopyrightLine,
  DEFAULT_BRAND,
  DEFAULT_COLUMNS,
  DEFAULT_LEGAL,
  FooterColumns,
  FooterLinkRow,
  INNER_CLASS,
  LegalRow,
  ROOT_CLASS,
  type SiteFooterColumn,
  type SiteFooterLink,
  type SiteFooterSharedProps,
} from "./site-footer-parts"
import { SiteFooterSignup, type SiteFooterSignupOptions } from "./site-footer-signup"

type SiteFooterVariant = "columns" | "minimal" | "signup"

const DEFAULT_LINKS: readonly SiteFooterLink[] = [
  { label: "Privacy", href: "#privacy" },
  { label: "Terms", href: "#terms" },
  { label: "Status", href: "#status" },
  { label: "Contact", href: "#contact" },
]

interface SiteFooterProps extends SiteFooterSharedProps, SiteFooterSignupOptions {
  /** `columns` a full map of the site, `minimal` one line for an app or a short site, `signup` a newsletter field above the links. Anything else is `columns`. */
  variant?: SiteFooterVariant
  /** One sentence beside the links on `columns`. */
  tagline?: string
  /** The link groups on `columns` and `signup`. An empty list leaves only the legal line. */
  columns?: readonly SiteFooterColumn[]
  /** The small links beside the copyright on `columns` and `signup`. */
  legal?: readonly SiteFooterLink[]
  /** The whole list on `minimal`. */
  links?: readonly SiteFooterLink[]
}

function SiteFooterColumns({
  brand = DEFAULT_BRAND,
  tagline = "Calls that end in a plan, written before you leave.",
  columns = DEFAULT_COLUMNS,
  legal = DEFAULT_LEGAL,
  copyright,
  className,
  ...props
}: Omit<SiteFooterProps, "variant" | "links" | keyof SiteFooterSignupOptions>) {
  return (
    <footer data-slot="site-footer" data-variant="columns" className={cn(ROOT_CLASS, "border-t bg-muted dark:bg-background", className)} {...props}>
      <div className={cn(INNER_CLASS, "flex flex-col gap-12 py-12")}>
        <div className="grid grid-cols-[minmax(0,1fr)] gap-8 @3xl:grid-cols-[minmax(0,2fr)_minmax(0,3fr)] @3xl:gap-12">
          <div className="flex max-w-xs flex-col gap-2">
            <p className="text-title text-lg">{brand}</p>
            <p className="text-sm leading-relaxed text-pretty text-muted-foreground">{tagline}</p>
          </div>
          <FooterColumns columns={columns} />
        </div>
        <LegalRow brand={brand} copyright={copyright} legal={legal} />
      </div>
    </footer>
  )
}

function SiteFooterMinimal({
  brand = DEFAULT_BRAND,
  links = DEFAULT_LINKS,
  copyright,
  className,
  ...props
}: Omit<SiteFooterProps, "variant" | "tagline" | "columns" | "legal" | keyof SiteFooterSignupOptions>) {
  return (
    <footer data-slot="site-footer" data-variant="minimal" className={cn(ROOT_CLASS, "border-t bg-background", className)} {...props}>
      <div className={cn(INNER_CLASS, "flex flex-col gap-3 py-6 @xl:flex-row @xl:items-center @xl:justify-between @xl:gap-6")}>
        <CopyrightLine brand={brand} copyright={copyright} />
        <FooterLinkRow links={links} label="Footer" />
      </div>
    </footer>
  )
}

function SiteFooter({ variant, tagline, columns, legal, links, onSubscribe, signupTitle, signupQuiet, signupNote, doneMessage, ...props }: SiteFooterProps) {
  switch (variant) {
    case "minimal":
      return <SiteFooterMinimal links={links} {...props} />
    case "signup":
      return (
        <SiteFooterSignup
          columns={columns}
          legal={legal}
          onSubscribe={onSubscribe}
          signupTitle={signupTitle}
          signupQuiet={signupQuiet}
          signupNote={signupNote}
          doneMessage={doneMessage}
          {...props}
        />
      )
    default:
      return <SiteFooterColumns tagline={tagline} columns={columns} legal={legal} {...props} />
  }
}

export { SiteFooter, type SiteFooterColumn, type SiteFooterLink, type SiteFooterProps, type SiteFooterVariant }
```

### `registry/miseui/blocks/site-footer-parts.tsx`

```tsx
import type * as React from "react"
import { useId } from "react"
import { cn } from "@/registry/miseui/lib/utils"

interface SiteFooterLink {
  label: string
  href: string
}

interface SiteFooterColumn {
  title: string
  links: readonly SiteFooterLink[]
}

interface SiteFooterSharedProps extends React.ComponentProps<"footer"> {
  /** Your product name, used in the copyright line and beside the tagline on `columns`. */
  brand?: string
  /** Replaces the line built from the current year and `brand`. */
  copyright?: string
}

const DEFAULT_BRAND = "Paperline"

const DEFAULT_COLUMNS: readonly SiteFooterColumn[] = [
  {
    title: "Product",
    links: [
      { label: "Summaries", href: "#summaries" },
      { label: "Action items", href: "#action-items" },
      { label: "Integrations", href: "#integrations" },
      { label: "Pricing", href: "#pricing" },
    ],
  },
  {
    title: "Resources",
    links: [
      { label: "Guides", href: "#guides" },
      { label: "Changelog", href: "#changelog" },
      { label: "Status", href: "#status" },
    ],
  },
  {
    title: "Company",
    links: [
      { label: "About", href: "#about" },
      { label: "Careers", href: "#careers" },
      { label: "Contact", href: "#contact" },
    ],
  },
]

const DEFAULT_LEGAL: readonly SiteFooterLink[] = [
  { label: "Privacy", href: "#privacy" },
  { label: "Terms", href: "#terms" },
]

const ROOT_CLASS = "@container flex w-full flex-col justify-center"
const INNER_CLASS = "mx-auto w-full max-w-6xl px-4 @2xl:px-8"

/**
 * A 20 px line plus 10 px above and below makes the 40 px touch target, without moving a single word on a desktop.
 * The stacks and rows below open their gaps by the same amount on touch, so two targets never overlap.
 */
const LINK_CLASS =
  "relative inline-block rounded-sm text-sm leading-5 text-muted-foreground outline-none transition-colors duration-(--duration-fast) ease-(--ease-mise) hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 pointer-coarse:after:absolute pointer-coarse:after:-inset-x-2 pointer-coarse:after:-inset-y-2.5"

function FooterLinkRow({ links, label, className }: { links: readonly SiteFooterLink[]; label: string; className?: string }) {
  if (links.length === 0) return null
  return (
    <nav aria-label={label} className={className}>
      <ul className="flex flex-wrap gap-x-4 gap-y-2 pointer-coarse:gap-y-5">
        {links.map((link) => (
          <li key={link.href}>
            <a href={link.href} className={LINK_CLASS}>
              {link.label}
            </a>
          </li>
        ))}
      </ul>
    </nav>
  )
}

function FooterColumn({ column }: { column: SiteFooterColumn }) {
  const titleId = useId()
  return (
    <div className="flex min-w-0 flex-col gap-3 pointer-coarse:gap-5">
      <p id={titleId} className="text-[13px] leading-5 font-medium">
        {column.title}
      </p>
      <ul aria-labelledby={titleId} className="flex flex-col items-start gap-2 pointer-coarse:gap-5">
        {column.links.map((link) => (
          <li key={link.href}>
            <a href={link.href} className={LINK_CLASS}>
              {link.label}
            </a>
          </li>
        ))}
      </ul>
    </div>
  )
}

function FooterColumns({ columns, className }: { columns: readonly SiteFooterColumn[]; className?: string }) {
  if (columns.length === 0) return null
  return (
    <nav aria-label="Footer" data-slot="site-footer-columns" className={cn("grid grid-cols-2 gap-x-6 gap-y-8 @lg:grid-cols-3", className)}>
      {columns.map((column) => (
        <FooterColumn key={column.title} column={column} />
      ))}
    </nav>
  )
}

/** Reads the clock so a copied footer never ships last year. The server and a browser can disagree for a moment at New Year, which is harmless. */
function CopyrightLine({ brand, copyright }: { brand: string; copyright?: string }) {
  return (
    <p className="text-[13px] leading-5 text-muted-foreground" suppressHydrationWarning>
      {copyright ?? `© ${new Date().getFullYear()} ${brand}`}
    </p>
  )
}

function LegalRow({ brand, copyright, legal }: { brand: string; copyright?: string; legal: readonly SiteFooterLink[] }) {
  return (
    <div className="flex flex-wrap items-center justify-between gap-x-6 gap-y-3 border-t pt-6">
      <CopyrightLine brand={brand} copyright={copyright} />
      <FooterLinkRow links={legal} label="Legal" />
    </div>
  )
}

export {
  CopyrightLine,
  DEFAULT_BRAND,
  DEFAULT_COLUMNS,
  DEFAULT_LEGAL,
  FooterColumns,
  FooterLinkRow,
  INNER_CLASS,
  LegalRow,
  ROOT_CLASS,
  type SiteFooterColumn,
  type SiteFooterLink,
  type SiteFooterSharedProps,
}
```

### `registry/miseui/blocks/site-footer-signup.tsx`

```tsx
import type * as React from "react"
import { useEffect, useId, useRef, useState } from "react"
import { Check, Loader2 } from "lucide-react"
import { cn } from "@/registry/miseui/lib/utils"
import { Button } from "@/registry/miseui/ui/button"
import { Input } from "@/registry/miseui/ui/input"
import { Label } from "@/registry/miseui/ui/label"
import { DEFAULT_BRAND, DEFAULT_COLUMNS, DEFAULT_LEGAL, FooterColumns, INNER_CLASS, LegalRow, ROOT_CLASS, type SiteFooterColumn, type SiteFooterLink, type SiteFooterSharedProps } from "./site-footer-parts"

const PREVIEW_DELAY_MS = 600
const INVALID_MESSAGE = "Enter an email address, such as name@example.com."
const FAILED_MESSAGE = "That did not go through. Your address is still here, so try again."

/** Catches typos, not fake addresses: only a confirmation email can prove one is real. */
function looksLikeEmail(value: string) {
  return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)
}

/** Stands in until you pass onSubscribe, so the preview reaches the done state. Replace it. */
function previewSubscribe() {
  return new Promise<void>((resolve) => {
    window.setTimeout(resolve, PREVIEW_DELAY_MS)
  })
}

type SubscribeHandler = (email: string) => void | Promise<void>

function useSignup(onSubscribe: SubscribeHandler) {
  const [email, setEmail] = useState("")
  const [error, setError] = useState<string | null>(null)
  const [sending, setSending] = useState(false)
  const [doneFor, setDoneFor] = useState<string | null>(null)
  const change = (next: string) => {
    setEmail(next)
    setError(null)
  }
  /** Resolves to whether the address was taken, so the form knows where focus belongs. */
  const submit = async () => {
    const value = email.trim()
    if (!looksLikeEmail(value)) {
      setError(INVALID_MESSAGE)
      return false
    }
    setError(null)
    setSending(true)
    try {
      await onSubscribe(value)
      setDoneFor(value)
      return true
    } catch {
      // A fixed sentence: a raw network error says nothing useful in a footer.
      setError(FAILED_MESSAGE)
      return false
    } finally {
      setSending(false)
    }
  }
  return { email, error, sending, doneFor, change, submit }
}

function SignupDone({ email, doneMessage }: { email: string; doneMessage: (email: string) => React.ReactNode }) {
  const ref = useRef<HTMLParagraphElement>(null)
  // The form the person was in has just gone, and focus would fall back to the top of the page with it.
  useEffect(() => ref.current?.focus(), [])
  return (
    <p ref={ref} tabIndex={-1} role="status" data-slot="site-footer-done" className="flex min-w-0 items-start gap-2 text-[15px] leading-relaxed text-pretty [overflow-wrap:anywhere] outline-none">
      <Check aria-hidden="true" className="mt-1 size-4 shrink-0 text-emerald-600 dark:text-emerald-400" />
      <span>{doneMessage(email)}</span>
    </p>
  )
}

interface SignupFormProps {
  onSubscribe: SubscribeHandler
  note: string
  doneMessage: (email: string) => React.ReactNode
}

function SignupForm({ onSubscribe, note, doneMessage }: SignupFormProps) {
  const fieldId = useId()
  const messageId = useId()
  const fieldRef = useRef<HTMLInputElement>(null)
  const signup = useSignup(onSubscribe)
  if (signup.doneFor !== null) return <SignupDone email={signup.doneFor} doneMessage={doneMessage} />
  const submit = async (event: React.FormEvent<HTMLFormElement>) => {
    event.preventDefault()
    if (signup.sending) return
    if (!(await signup.submit())) fieldRef.current?.focus()
  }
  return (
    <form noValidate onSubmit={submit} className="flex min-w-0 flex-col gap-2">
      <Label htmlFor={fieldId} className="sr-only">
        Email address
      </Label>
      <div className="flex flex-col gap-2 @md:flex-row">
        <Input
          ref={fieldRef}
          id={fieldId}
          type="email"
          autoComplete="email"
          placeholder="name@example.com"
          value={signup.email}
          readOnly={signup.sending}
          aria-invalid={signup.error ? true : undefined}
          aria-describedby={messageId}
          onChange={(event) => signup.change(event.target.value)}
          className="h-10 @md:flex-1"
        />
        {/* Not disabled: a disabled button drops keyboard focus for the whole send. */}
        <Button type="submit" variant="mise" aria-disabled={signup.sending || undefined} aria-busy={signup.sending || undefined} className="h-10">
          {signup.sending ? <Loader2 aria-hidden="true" className="animate-spin motion-reduce:animate-none" /> : null}
          {signup.sending ? "Subscribing" : "Subscribe"}
        </Button>
      </div>
      {/* One line for both, so an error takes the place of the note and nothing below moves. */}
      <p id={messageId} role={signup.error ? "alert" : undefined} className={cn("text-[13px] leading-5 text-pretty", signup.error ? "text-destructive" : "text-muted-foreground")}>
        {signup.error ?? note}
      </p>
    </form>
  )
}

interface SiteFooterSignupOptions {
  /** Save the address here. Return a promise to get the sending state; a rejection offers another try. */
  onSubscribe?: SubscribeHandler
  signupTitle?: string
  /** Continues the title in the same size, one tone quieter. */
  signupQuiet?: string
  /** Under the field. Say how often you really write. */
  signupNote?: string
  /** What replaces the form when it worked. */
  doneMessage?: (email: string) => React.ReactNode
}

interface SiteFooterSignupProps extends SiteFooterSharedProps, SiteFooterSignupOptions {
  columns?: readonly SiteFooterColumn[]
  legal?: readonly SiteFooterLink[]
}

const defaultDoneMessage = (email: string) => `You are on the list. We will write to ${email}.`

function SiteFooterSignup({
  brand = DEFAULT_BRAND,
  copyright,
  columns = DEFAULT_COLUMNS,
  legal = DEFAULT_LEGAL,
  onSubscribe = previewSubscribe,
  signupTitle = "One useful email a month.",
  signupQuiet = "What changed, and nothing else.",
  signupNote = "Unsubscribe from any email, with one click.",
  doneMessage = defaultDoneMessage,
  className,
  ...props
}: SiteFooterSignupProps) {
  return (
    <footer data-slot="site-footer" data-variant="signup" className={cn(ROOT_CLASS, "bg-muted dark:bg-background", className)} {...props}>
      <div className={cn(INNER_CLASS, "flex flex-col gap-12 py-12")}>
        <div data-slot="site-footer-signup" className="grid grid-cols-[minmax(0,1fr)] items-start gap-6 rounded-2xl border bg-background p-6 dark:bg-card @3xl:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] @3xl:gap-12 @3xl:p-8">
          <h2 className="text-title text-2xl text-balance">
            {signupTitle} <span className="text-muted-foreground">{signupQuiet}</span>
          </h2>
          <SignupForm onSubscribe={onSubscribe} note={signupNote} doneMessage={doneMessage} />
        </div>
        <FooterColumns columns={columns} />
        <LegalRow brand={brand} copyright={copyright} legal={legal} />
      </div>
    </footer>
  )
}

export { SiteFooterSignup, looksLikeEmail, type SiteFooterSignupOptions }
```
