# Site footer

> A website footer with link columns and newsletter, a minimal layout, and a large fading Arc mark.

A shadcn/ui block from the [@uiarc](https://uiarc.dev) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/uiarc/site-footer).

- Page, with a live preview: https://shadcn.exchange/r/uiarc/site-footer
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/uiarc/site-footer-light-bc318ca2e4e92b87.png
- Registry item: https://uiarc.dev/r/site-footer.json
- Categories: blocks
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://uiarc.dev/r/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`, `motion`
- Registry items: `https://uiarc.dev/r/arc-motion-tokens.json`, `https://uiarc.dev/r/button.json`, `https://uiarc.dev/r/segmented-control.json`

## Notes from the registry

Use this as a starting point and replace the sample data with your own.

## Files

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

```tsx
"use client";

import { forwardRef, useId, useLayoutEffect, useRef, useState } from "react";
import type { FormEvent, ReactNode } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { ArrowUpRight, Check } from "lucide-react";
import { Button } from "../../button/button";
import SegmentedControl from "../../segmented-control/segmented-control";
import { motionTokens } from "../../lib/motion-tokens";
import styles from "./site-footer.module.css";

export type SiteFooterVariant = "columns" | "minimal" | "logo";

export interface SiteFooterLink {
  label: string;
  href?: string;
  /** Opens in a new tab and shows an outward arrow. */
  external?: boolean;
}

export interface SiteFooterColumn {
  title: string;
  links: SiteFooterLink[];
}

export interface SiteFooterNewsletter {
  title?: string;
  description?: string;
  placeholder?: string;
  /** Called with a valid address. Throw or reject to show an error; resolve to show the subscribed state. */
  onSubscribe?: (email: string) => void | Promise<void>;
}

export interface SiteFooterProps {
  /** `columns` pairs a newsletter with link columns, `minimal` is one quiet row, `logo` ends in a large fading Arc mark. */
  variant?: SiteFooterVariant;
  brand?: { name: string; href?: string; mark?: ReactNode };
  /** One short line under the brand. */
  tagline?: string;
  columns?: SiteFooterColumn[];
  /** Links for the minimal variant. Defaults to the first link of each column. */
  links?: SiteFooterLink[];
  /** Small links beside the copyright, such as Privacy and Terms. */
  legal?: SiteFooterLink[];
  socials?: SiteFooterLink[];
  /** Newsletter signup. Pass null to hide it. */
  newsletter?: SiteFooterNewsletter | null;
  /** A system status link. Pass null to hide it. */
  status?: { label: string; tone?: "success" | "warning" | "danger"; href?: string } | null;
  /** Year in the copyright line. */
  year?: number;
  /** Called for every link that is pressed. Links without an href render as buttons and only call this. */
  onNavigate?: (link: SiteFooterLink) => void;
  className?: string;
}

type Bezier = [number, number, number, number];
const enter = [...motionTokens.ease.enter] as Bezier;

export const siteFooterExampleColumns: SiteFooterColumn[] = [
  { title: "Product", links: [{ label: "Components" }, { label: "Blocks" }, { label: "Templates" }, { label: "Pricing" }] },
  { title: "Resources", links: [{ label: "Documentation" }, { label: "Changelog" }, { label: "Guides" }, { label: "Figma kit", external: true }] },
  { title: "Company", links: [{ label: "About" }, { label: "Customers" }, { label: "Careers" }, { label: "Contact" }] },
];
const exampleLegal: SiteFooterLink[] = [{ label: "Privacy" }, { label: "Terms" }, { label: "Licenses" }];
const exampleSocials: SiteFooterLink[] = [{ label: "X", external: true }, { label: "GitHub", external: true }, { label: "LinkedIn", external: true }];
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;

function FooterLink({ link, onNavigate, className }: { link: SiteFooterLink; onNavigate?: (link: SiteFooterLink) => void; className?: string }) {
  const content = <>{link.label}{link.external && <ArrowUpRight className={styles.external} size={13} strokeWidth={2} aria-hidden="true" />}</>;
  const onClick = () => onNavigate?.(link);
  if (link.href) return <a className={className ?? styles.link} href={link.href} onClick={onClick} {...(link.external ? { target: "_blank", rel: "noopener noreferrer" } : {})}>{content}</a>;
  return <button type="button" className={className ?? styles.link} onClick={onClick}>{content}</button>;
}

function ArcMark({ className }: { className?: string }) {
  return <svg className={className} viewBox="0 0 64 64" fill="none" stroke="currentColor" strokeWidth="5.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
    <path d="M9 48V29C9 15 19 7 32 7s23 8 23 22v19" />
    <path d="M20 48V31c0-8 5-13 12-13s12 5 12 13v17" />
    <path d="M32 38v10" />
  </svg>;
}

/** Email signup that validates in place, keeps its width while the button morphs, and confirms without a toast. */
function Newsletter({ title, description, placeholder = "you@example.com", onSubscribe }: SiteFooterNewsletter) {
  const id = useId();
  const reduced = !!useReducedMotion();
  const [email, setEmail] = useState("");
  const [state, setState] = useState<"idle" | "loading" | "done">("idle");
  const [error, setError] = useState<string | null>(null);
  const [touched, setTouched] = useState(false);

  const validate = (value: string) => !value.trim() ? "Enter your email address" : EMAIL.test(value.trim()) ? null : "That email doesn't look right";

  async function submit(event: FormEvent) {
    event.preventDefault();
    if (state !== "idle") return;
    setTouched(true);
    const problem = validate(email);
    setError(problem);
    if (problem) return;
    setState("loading");
    try {
      await (onSubscribe ? onSubscribe(email.trim()) : new Promise(resolve => setTimeout(resolve, 900)));
      setState("done");
    } catch {
      setState("idle");
      setError("We couldn't subscribe you. Try again in a moment");
    }
  }

  const message = state === "done" ? `Check ${email.trim()} to confirm` : error ?? description;
  const tone = state === "done" ? "success" : error ? "error" : "hint";

  return <form className={styles.newsletter} onSubmit={submit} noValidate aria-labelledby={title ? `${id}-title` : undefined}>
    {title && <h2 id={`${id}-title`} className={styles.newsletterTitle}>{title}</h2>}
    <div className={styles.newsletterRow} data-invalid={error ? "" : undefined} data-done={state === "done" ? "" : undefined}>
      <label className={styles.srOnly} htmlFor={`${id}-email`}>Email address</label>
      <input
        id={`${id}-email`}
        className={styles.newsletterInput}
        type="email"
        inputMode="email"
        autoComplete="email"
        placeholder={placeholder}
        value={email}
        readOnly={state !== "idle"}
        aria-invalid={error ? true : undefined}
        aria-describedby={`${id}-message`}
        onChange={event => { setEmail(event.target.value); if (touched) setError(validate(event.target.value)); }}
        onBlur={() => { if (email) { setTouched(true); setError(validate(email)); } }}
      />
      <Button type="submit" size="sm" variant="primary" loading={state === "loading"} className={styles.newsletterButton} aria-disabled={state === "done" || undefined}>
        {state === "done" ? <><Check size={15} strokeWidth={2.25} aria-hidden="true" />Subscribed</> : "Subscribe"}
      </Button>
    </div>
    <div className={styles.messageSlot} id={`${id}-message`} aria-live="polite">
      <AnimatePresence initial={false} mode="popLayout">
        {message && <motion.p key={message} className={styles.message} data-tone={tone} role={tone === "error" ? "alert" : undefined}
          initial={reduced ? { opacity: 0 } : { opacity: 0, y: 4, filter: `blur(${motionTokens.blur.subtle}px)` }}
          animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
          exit={reduced ? { opacity: 0, transition: { duration: 0 } } : { opacity: 0, y: -4, filter: `blur(${motionTokens.blur.subtle}px)`, transition: { duration: motionTokens.duration.instant } }}
          transition={{ duration: reduced ? 0 : motionTokens.duration.standard, ease: enter }}>{message}</motion.p>}
      </AnimatePresence>
    </div>
  </form>;
}

/**
 * A website footer in three layouts: link columns with a newsletter signup, a single quiet row, and a closing
 * variant where the Arc arch rises in, oversized and cropped by the bottom edge, fading into the page.
 */
export const SiteFooter = forwardRef<HTMLElement, SiteFooterProps>(function SiteFooter({
  variant = "columns",
  brand = { name: "Arc" },
  tagline = "Interface components that move with intent.",
  columns = siteFooterExampleColumns,
  links,
  legal = exampleLegal,
  socials = exampleSocials,
  newsletter = { title: "Get the monthly release notes", description: "One email a month. Unsubscribe anytime." },
  status = { label: "All systems normal", tone: "success" },
  year = new Date().getFullYear(),
  onNavigate,
  className,
}, ref) {
  const reduced = !!useReducedMotion();
  const gradientId = useId().replace(/:/g, "");
  const brandNode = <FooterLink link={{ label: brand.name, href: brand.href }} onNavigate={onNavigate} className={styles.brand} />;
  const brandWithMark = <span className={styles.brandRow}>{brand.mark ?? <ArcMark className={styles.brandMark} />}{brandNode}</span>;
  const statusNode = status && (status.href
    ? <a className={styles.status} href={status.href} data-tone={status.tone ?? "success"} onClick={() => onNavigate?.({ label: status.label, href: status.href })}><span className={styles.statusDot} aria-hidden="true" />{status.label}</a>
    : <button type="button" className={styles.status} data-tone={status.tone ?? "success"} onClick={() => onNavigate?.({ label: status.label })}><span className={styles.statusDot} aria-hidden="true" />{status.label}</button>);
  const socialNode = socials.length > 0 && <ul className={styles.inline} aria-label="Social">{socials.map(link => <li key={link.label}><FooterLink link={link} onNavigate={onNavigate} /></li>)}</ul>;
  const legalRow = <div className={styles.bottom}>
    <div className={styles.bottomStart}>
      <span className={styles.copyright}>© {year} {brand.name}</span>
      {legal.length > 0 && <ul className={styles.inline} aria-label="Legal">{legal.map(link => <li key={link.label}><FooterLink link={link} onNavigate={onNavigate} /></li>)}</ul>}
    </div>
    <div className={styles.bottomEnd}>{statusNode}{socialNode}</div>
  </div>;
  const columnNav = <nav className={styles.columns} aria-label="Footer">
    {columns.map(column => <div key={column.title} className={styles.column}>
      <h2>{column.title}</h2>
      <ul>{column.links.map(link => <li key={link.label}><FooterLink link={link} onNavigate={onNavigate} /></li>)}</ul>
    </div>)}
  </nav>;

  if (variant === "minimal") {
    const rowLinks = links ?? columns.map(column => column.links[0]).filter((link): link is SiteFooterLink => Boolean(link)).concat(legal.slice(0, 2));
    return <footer ref={ref} className={[styles.footer, styles.minimal, className].filter(Boolean).join(" ")}>
      <div className={styles.minimalRow}>
        {brandWithMark}
        <nav aria-label="Footer"><ul className={styles.inline}>{rowLinks.map(link => <li key={link.label}><FooterLink link={link} onNavigate={onNavigate} /></li>)}</ul></nav>
      </div>
      <div className={styles.minimalRow}>
        <span className={styles.copyright}>© {year} {brand.name}. {tagline}</span>
        <div className={styles.bottomEnd}>{statusNode}{socialNode}</div>
      </div>
    </footer>;
  }

  return <footer ref={ref} className={[styles.footer, variant === "logo" ? styles.logo : "", className].filter(Boolean).join(" ")}>
    <div className={styles.top}>
      <div className={styles.intro}>
        {brandWithMark}
        {tagline && <p className={styles.tagline}>{tagline}</p>}
        {newsletter && variant === "columns" && <Newsletter {...newsletter} />}
      </div>
      {columnNav}
    </div>
    {legalRow}
    {variant === "logo" && <motion.div className={styles.markStage} aria-hidden="true"
      initial={reduced ? false : { opacity: 0, y: 40 }}
      whileInView={{ opacity: 1, y: 0 }}
      viewport={{ once: true, amount: .3 }}
      transition={{ duration: reduced ? 0 : .9, ease: enter }}>
      {/* The top of the arch, drawn oversized and cropped by its viewBox, in the brand gradient and fading toward the bottom edge. */}
      <svg className={styles.bigMark} viewBox="0 3.5 64 30" fill="none" strokeWidth="3.4" strokeLinecap="round" strokeLinejoin="round" preserveAspectRatio="xMidYMin meet">
        <defs>
          <linearGradient id={gradientId} x1="0" y1="0" x2="1" y2="0">
            <stop offset="0" style={{ stopColor: "var(--arc-gradient-from)" }} />
            <stop offset="1" style={{ stopColor: "var(--arc-gradient-to)" }} />
          </linearGradient>
        </defs>
        <g stroke={`url(#${gradientId})`}>
          <path d="M9 48V29C9 15 19 7 32 7s23 8 23 22v19" />
          <path d="M20 48V31c0-8 5-13 12-13s12 5 12 13v17" />
          <path d="M32 38v10" />
        </g>
      </svg>
    </motion.div>}
  </footer>;
});

SiteFooter.displayName = "SiteFooter";

const variantOptions = [{ value: "columns", label: "Columns" }, { value: "minimal", label: "Minimal" }, { value: "logo", label: "Logo" }];

/** Preview: the footer with a switch between its three layouts and a note of the last link pressed. */
export function SiteFooterBlock({ variant: initial = "columns" }: { variant?: SiteFooterVariant }) {
  const [variant, setVariant] = useState<SiteFooterVariant>(initial);
  const [last, setLast] = useState<string | null>(null);
  const reduced = !!useReducedMotion();
  const contentRef = useRef<HTMLDivElement>(null);
  const [height, setHeight] = useState<number | null>(null);
  // The frame springs between layout heights instead of snapping; the new layout fades in where it sits.
  useLayoutEffect(() => {
    const node = contentRef.current;
    if (!node) return;
    setHeight(node.offsetHeight);
    if (typeof ResizeObserver === "undefined") return;
    const observer = new ResizeObserver(() => setHeight(node.offsetHeight));
    observer.observe(node);
    return () => observer.disconnect();
  }, [variant]);
  return <div className={styles.preview}>
    <SegmentedControl label="Footer layout" options={variantOptions} value={variant} onValueChange={value => setVariant(value as SiteFooterVariant)} />
    <div className={styles.frame}>
      <motion.div className={styles.frameBody} initial={false} animate={{ height: height ?? "auto" }} transition={reduced ? { duration: 0 } : motionTokens.spring.smooth}>
        <motion.div ref={contentRef} key={variant} initial={reduced ? false : { opacity: 0 }} animate={{ opacity: 1 }} transition={{ duration: motionTokens.duration.standard, ease: enter }}>
          <SiteFooter variant={variant} onNavigate={link => setLast(link.label)} />
        </motion.div>
      </motion.div>
    </div>
    <p className={styles.srOnly} aria-live="polite">{last ? `Opened ${last}` : ""}</p>
  </div>;
}

export default SiteFooterBlock;
```

### `registry/blocks/site-footer/site-footer.module.css`

```css
.footer { --footer-pad: var(--space-6); position: relative; container: site-footer / inline-size; overflow: hidden; border-top: 1px solid var(--border); padding: var(--space-12) var(--footer-pad) var(--space-6); background: var(--background); color: var(--foreground); font-family: var(--font-body); letter-spacing: var(--tracking-body); }
.srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }

.top { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.25fr); gap: var(--space-12); }
.intro { display: grid; align-content: start; gap: var(--space-3); min-width: 0; }
.brandRow { display: inline-flex; align-items: center; gap: var(--space-2); }
.brandMark { width: 22px; height: 22px; flex: none; color: var(--foreground); }
.brand { border: 0; padding: 0; background: none; color: var(--foreground); font: inherit; font-size: var(--text-base); font-weight: 500; text-decoration: none; cursor: pointer; }
.tagline { max-width: 320px; margin: 0; color: var(--text-secondary); font-size: var(--text-sm); line-height: var(--leading-body); }

.columns { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--space-6); }
.column h2 { margin: 0 0 var(--space-3); font-size: var(--text-sm); font-weight: 500; }
.column ul, .inline { margin: 0; padding: 0; list-style: none; }
.column ul { display: grid; gap: var(--space-2); }
.link { display: inline-flex; align-items: center; gap: 3px; border: 0; padding: 2px 0; background: none; color: var(--text-secondary); font: inherit; font-size: var(--text-sm); text-align: left; text-decoration: none; cursor: pointer; transition: color var(--duration-fast) var(--ease-standard); }
.link:active, .link:focus-visible { color: var(--foreground); }
.external { color: var(--text-muted); transition: transform var(--duration-fast) var(--ease-standard); }
@media (hover: hover) and (pointer: fine) {
  .link:hover { color: var(--foreground); }
  .link:hover .external { transform: translate(1px, -1px); }
}

/* Newsletter: input and button share one shell; the shell carries validation state with its border only. */
.newsletter { display: grid; gap: var(--space-2); max-width: 400px; margin-top: var(--space-5); }
.newsletterTitle { margin: 0; font-size: var(--text-sm); font-weight: 500; }
.newsletterRow { display: flex; height: var(--control-height-md); align-items: center; gap: var(--space-2); border: 1px solid var(--border); border-radius: var(--radius-control); padding: 3px 3px 3px var(--space-4); background: var(--surface); transition: border-color var(--duration-fast) var(--ease-standard); }
.newsletterRow:focus-within { border-color: var(--border-strong); }
.newsletterRow[data-invalid] { border-color: var(--danger); }
.newsletterInput { width: 100%; min-width: 0; flex: 1; border: 0; padding: 0; background: none; color: var(--foreground); font: inherit; font-size: var(--text-sm); }
.newsletterInput::placeholder { color: var(--text-muted); }
.newsletterRow[data-done] .newsletterInput { color: var(--text-secondary); }
.newsletterButton { flex: none; border-radius: calc(var(--radius-control) - 3px); }
.messageSlot { position: relative; min-height: 20px; }
.message { margin: 0; color: var(--text-muted); font-size: var(--text-xs); line-height: 20px; }
.message[data-tone="error"] { color: var(--danger); }
.message[data-tone="success"] { color: var(--success); }

.bottom { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: var(--space-3) var(--space-6); margin-top: var(--space-12); border-top: 1px solid var(--border-subtle); padding-top: var(--space-5); }
.bottomStart, .bottomEnd { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-2) var(--space-5); }
.inline { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-1) var(--space-4); }
.copyright { color: var(--text-muted); font-size: var(--text-sm); }
.status { display: inline-flex; align-items: center; gap: var(--space-2); border: 0; padding: 2px 0; background: none; color: var(--text-secondary); font: inherit; font-size: var(--text-sm); text-decoration: none; cursor: pointer; }
.statusDot { --tone: var(--success); width: 7px; height: 7px; border-radius: 50%; background: var(--tone); box-shadow: 0 0 0 3px color-mix(in oklab, var(--tone) 18%, transparent); }
.status[data-tone="warning"] .statusDot { --tone: var(--warning); }
.status[data-tone="danger"] .statusDot { --tone: var(--danger); }
@media (hover: hover) and (pointer: fine) { .status:hover { color: var(--foreground); } }

/* Minimal: two quiet rows. */
.minimal { padding-block: var(--space-8) var(--space-6); }
.minimalRow { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: var(--space-3) var(--space-6); }
.minimalRow + .minimalRow { margin-top: var(--space-6); border-top: 1px solid var(--border-subtle); padding-top: var(--space-5); }

/* Logo: the arch closes the page, cropped by the bottom edge and fading out in the brand gradient. */
.logo { padding-bottom: 0; }
.markStage { margin: var(--space-10) calc(var(--footer-pad) * -1) 0; -webkit-mask-image: linear-gradient(to bottom, #000 18%, transparent 96%); mask-image: linear-gradient(to bottom, #000 18%, transparent 96%); pointer-events: none; }
.bigMark { display: block; width: 100%; height: auto; }

@container site-footer (max-width: 760px) {
  .top { grid-template-columns: minmax(0, 1fr); gap: var(--space-10); }
}
@container site-footer (max-width: 520px) {
  .columns { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-8) var(--space-4); }
  .newsletter { max-width: none; }
  .bottom { flex-direction: column; align-items: flex-start; }
}

.preview { display: grid; width: 100%; justify-items: center; gap: var(--space-4); }
.frame { width: 100%; overflow: hidden; border: 1px solid var(--border); border-radius: 20px; background: var(--background); }
.frameBody { overflow: hidden; }
.frame .footer { border-top: 0; }
.previewNote { min-height: 20px; margin: 0; color: var(--text-muted); font-size: var(--text-xs); }

@media (prefers-reduced-motion: reduce) {
  .link, .external, .newsletterRow { transition: none; }
}
```
