Site
Site footer
Site footer with brand information, link groups, badges, copyright, and an optional watermark.
$ npx context-ds add site-footerUsage
Use it for
The foot of every site page, once. Pass badges (the SOC 2 images) and asideLinks (FooterInlineLink, FooterStatusLink with the service's state), the link groups, the legal links, and the watermark (FooterWatermark) with a FooterCopyButton as its watermarkAction.
Avoid
- A gray or blue footer
- Bold link text
- More than four link columns
- The watermark band without the dark footer above it
Dependencies: section, lucide-react
Example
examples/site-footer.tsx
import { Mail } from "lucide-react";import { FooterCopyButton, FooterInlineLink, FooterStatusLink, SiteFooter } from "@/components/site/site-footer";import { FooterWatermark } from "@/components/visuals/footer-watermark";export function Site() { return ( <SiteFooter logo={ <a href="https://context.dev/" aria-label="Context.dev home" className="flex items-center gap-2 rounded-card"> <svg viewBox="0 0 772.32 531.2" aria-hidden="true" className="h-6 w-auto text-fg-brand"> <path fill="currentColor" fillRule="evenodd" d="M287.56,0H745.26A27.06,27.06,0,0,1,772.32,27.06V484.77A27.06,27.06,0,0,1,745.26,511.83H284.51A24.01,24.01,0,0,1,260.5,487.82V27.06A27.06,27.06,0,0,1,287.56,0ZM260.5,358.28a153.55,153.55,0,1,0,307.1,0a153.55,153.55,0,1,0,-307.1,0Z" /> <circle cx="153.55" cy="377.64" r="153.55" fill="currentColor" /> </svg> <span className="text-h5 leading-none font-medium text-white">Context.dev</span> </a> } description={ <> <span className="block">Web data infrastructure for AI agents.</span> <span className="block">Scrape anything. Get answers. Keep your data fresh.</span> </> } socials={ <a href="mailto:hello@context.dev" aria-label="Email us"> <Mail /> </a> } backedBy={<a href="https://www.ycombinator.com/companies/contextdev">Backed by Y Combinator</a>} asideLinks={[ <FooterInlineLink key="soc" href="https://trust.context.dev/" target="_blank" rel="noopener noreferrer"> SOC 2 Type 1 & Type 2 compliant </FooterInlineLink>, <FooterStatusLink key="status" href="https://status.context.dev" state="operational" />, ]} groups={[ { title: "Product", links: [{ label: "Scrape anything", href: "https://context.dev/web-scraping-api" }, { label: "Web search", href: "https://context.dev/search-api" }, { label: "Run batches", href: "https://context.dev/batches-api" }, { label: "Create monitors", href: "https://context.dev/monitors-api" }, { label: "Pricing", href: "https://context.dev/pricing" }] }, { title: "Developers", links: [{ label: "Documentation", href: "https://docs.context.dev" }, { label: "Playground", href: "https://context.dev/playground/scrape" }, { label: "Integrations & SDKs", href: "https://context.dev/integrations" }, { label: "MCP Server", href: "https://context.dev/mcp" }, { label: "CLI", href: "https://context.dev/cli" }] }, { title: "Resources", links: [{ label: "Blog", href: "https://context.dev/blog" }, { label: "Guides", href: "https://context.dev/guides" }, { label: "Customers", href: "https://context.dev/customers" }, { label: "Compare Alternatives", href: "https://context.dev/compare" }, { label: "Glossary", href: "https://context.dev/glossary" }] }, { title: "Company", links: [{ label: "Contact", href: "https://context.dev/contact" }, { label: "Request Brand Update", href: "https://context.dev/update" }] }, ]} copyright="© 2026 Context.dev" note={ <> Built with <svg viewBox="0 0 24 24" aria-hidden="true" className="inline-block size-3.5 fill-brand"> <path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z" /> </svg> in San Francisco </> } legal={[ { label: "Terms", href: "https://context.dev/term" }, { label: "DPA", href: "https://context.dev/dpa" }, { label: "Privacy", href: "https://context.dev/privacy" }, ]} watermark={<FooterWatermark className="max-sm:top-14 max-sm:h-auto" />} watermarkAction={<FooterCopyButton text="Signup for an account & get API key with context.dev/auth.md, then follow docs.context.dev/agent-quickstart to integrate into the codebase" label="Onboard your agent" aria-label="Onboard your agent: copy the setup prompt" />} /> );}Source
components/site/site-footer.tsx
"use client";import * as React from "react";import { Check, Copy } from "lucide-react";import { SectionEdgeLines } from "@/components/site/section";import { cn } from "@/lib/utils";type FooterLink = { label: React.ReactNode; href: string; external?: boolean };type FooterLinkGroup = { title: React.ReactNode; links: FooterLink[]; /** Extra rows after the links (a cookie settings button). */ extra?: React.ReactNode };type ServiceState = "operational" | "degraded" | "maintenance" | "downtime";const COLUMN = "relative mx-auto w-full max-w-(--ds-frame-column) px-6 sm:px-8 md:px-10 lg:px-12";// Where the copyright row's rules cross the column lines, as on the dark section grid.const NODE = "pointer-events-none absolute z-20 size-2 rounded-pill bg-neutral-60";const FOCUS = "outline-none focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-white";const INLINE_LINK = cn("transition-colors duration-150 ease-out hover:text-white motion-reduce:transition-none", FOCUS);const DIVIDER = <span aria-hidden="true" className="h-4 w-px bg-white/20" />;function external(link: { href: string; external?: boolean }) { return (link.external ?? link.href.startsWith("http")) ? { target: "_blank", rel: "noopener noreferrer" } : {};}const STATE_DOT: Record<ServiceState, string> = { operational: "bg-success", degraded: "bg-warning", maintenance: "bg-warning", downtime: "bg-danger",};const STATE_LABEL: Record<ServiceState, string> = { operational: "All systems operational", degraded: "Degraded performance", maintenance: "Scheduled maintenance", downtime: "Service outage",};/** The footer's "Service status" link with a dot in the service's state. A neutral dot holds the place until it is known. */function FooterStatusLink({ href, state, label = "Service status", className }: { href: string; state?: ServiceState | null; label?: React.ReactNode; className?: string }) { return ( <a href={href} target="_blank" rel="noopener noreferrer" title={state ? STATE_LABEL[state] : undefined} className={cn("inline-flex items-center gap-2", INLINE_LINK, className)}> <span aria-hidden="true" className={cn("size-2 shrink-0 rounded-pill transition-colors duration-300 ease-out motion-reduce:transition-none", state ? STATE_DOT[state] : "bg-white/25")} /> {label} {state && <span className="sr-only">: {STATE_LABEL[state]}</span>} </a> );}/** A quiet inline link for the footer's aside row (the compliance line). */function FooterInlineLink({ className, ...props }: React.ComponentProps<"a">) { return <a className={cn(INLINE_LINK, className)} {...props} />;}/** * The pill that sits on the watermark and copies a prompt ("Onboard your agent"). Both labels share one grid cell, so * the pill keeps its width when the label swaps. */function FooterCopyButton({ text, label, copiedLabel = "Copied", className, ...props}: Omit<React.ComponentProps<"button">, "children" | "onClick"> & { text: string; label: React.ReactNode; copiedLabel?: React.ReactNode }) { const [copied, setCopied] = React.useState(false); React.useEffect(() => { if (!copied) return; const timeout = setTimeout(() => setCopied(false), 2000); return () => clearTimeout(timeout); }, [copied]); return ( <button type="button" onClick={async () => { try { await navigator.clipboard.writeText(text); setCopied(true); } catch { // The clipboard can be blocked; the label stays as it was. } }} className={cn( "pointer-events-auto inline-flex items-center justify-center gap-2 rounded-pill border border-brand bg-dark px-4 py-2 text-body-sm leading-5 font-medium whitespace-nowrap text-white transition-[background-color,scale] duration-150 ease-out hover:bg-navy-30 active:scale-95 motion-reduce:transition-none motion-reduce:active:scale-100", "outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus-ring", className, )} {...props} > {copied ? <Check aria-hidden="true" className="size-4" /> : <Copy aria-hidden="true" className="size-4" />} <span className="grid justify-items-center"> <span className={cn("col-start-1 row-start-1", copied && "invisible")}>{label}</span> <span aria-hidden="true" className={cn("col-start-1 row-start-1", !copied && "invisible")}> {copiedLabel} </span> </span> </button> );}/** * The site's footer: near black, the column's lines continuing down it in the dark tone. The brand block (logo, * two-line description, socials and a "Backed by" line after a short divider) and an aside (compliance badges over a * row of inline links) sit over a white rule at 15 percent; the link groups run in four columns under it; then the * copyright row between two rules with a node on each crossing, and the watermark band at the foot with its button. */function SiteFooter({ logo, description, socials, backedBy, badges, asideLinks = [], aside, groups = [], copyright, note, legal = [], watermark, watermarkAction, className,}: { logo?: React.ReactNode; description?: React.ReactNode; /** Icon links; each is drawn at 16px, white at 60 percent. */ socials?: React.ReactNode; /** After the socials, past a short divider (the site's "Backed by Y Combinator" link). */ backedBy?: React.ReactNode; /** The top of the right side (the compliance badges, 82px tall). */ badges?: React.ReactNode; /** Inline links under the badges, divided by short rules (`FooterInlineLink`, `FooterStatusLink`). */ asideLinks?: React.ReactNode[]; /** Replaces the whole right side. */ aside?: React.ReactNode; groups?: FooterLinkGroup[]; copyright?: React.ReactNode; /** The middle of the copyright row. */ note?: React.ReactNode; legal?: FooterLink[]; /** What fills the band at the foot (the site's dot wordmark, `FooterWatermark`). */ watermark?: React.ReactNode; /** The button on the watermark, on its "e" crossbar (above it on phones); the watermark's cursor clicks it. */ watermarkAction?: React.ReactNode; className?: string;}) { const right = aside ?? ((badges || asideLinks.length > 0) && ( <> {badges && <div className="flex items-center gap-3 [&_img]:h-20.5 [&_img]:w-auto">{badges}</div>} {asideLinks.length > 0 && ( <div className="flex items-center gap-3 text-body-sm leading-none font-medium text-white/60"> {asideLinks.map((link, index) => ( <React.Fragment key={index}> {index > 0 && DIVIDER} {link} </React.Fragment> ))} </div> )} </> )); return ( <footer data-slot="site-footer" className={cn("relative isolate overflow-x-clip bg-dark text-white", className)}> <div className="relative"> <SectionEdgeLines tone="dark" /> <div className={cn(COLUMN, "z-10 py-14 sm:py-16 md:py-20")}> {(logo || description || socials || backedBy || right) && ( <div className="grid grid-cols-1 gap-8 border-b border-white/15 pb-8 md:grid-cols-[1fr_auto] md:gap-x-10"> <div className="flex min-w-0 flex-col items-start gap-5"> {logo && <div className="flex flex-col items-start gap-3">{logo}</div>} {description && <p className="max-w-md text-body leading-relaxed font-medium text-white/70">{description}</p>} {(socials || backedBy) && ( <div className="flex items-center gap-3"> {socials && ( <div className="flex items-center gap-3 [&_a]:-m-1.5 [&_a]:flex [&_a]:items-center [&_a]:justify-center [&_a]:p-1.5 [&_a]:text-white/60 [&_a]:transition-colors [&_a]:duration-150 [&_a]:ease-out [&_a:hover]:text-white [&_svg]:size-4"> {socials} </div> )} {socials && backedBy && DIVIDER} {backedBy && <span className="inline-flex items-center gap-1.5 text-body-sm leading-none font-medium text-white/60 [&_img]:size-4 [&_img]:rounded-sm">{backedBy}</span>} </div> )} </div> {right && <div className="flex shrink-0 flex-col items-start gap-4 md:items-end md:justify-between">{right}</div>} </div> )} {groups.length > 0 && ( <nav aria-label="Footer" className="grid grid-cols-2 gap-x-6 gap-y-10 pt-8 sm:gap-x-8 md:grid-cols-4"> {groups.map((group) => ( <div key={group.links.map((link) => link.href).join("|")} className="flex min-w-0 flex-col gap-3"> <p className="text-body font-medium text-white">{group.title}</p> <ul className="flex flex-col gap-1"> {group.links.map((link) => ( <li key={link.href}> <a href={link.href} {...external(link)} className={cn("block py-0.5 text-body leading-6 font-medium text-white/60", INLINE_LINK)}> {link.label} </a> </li> ))} {group.extra} </ul> </div> ))} </nav> )} </div> {(copyright || note || legal.length > 0) && ( // No z-index here: the corner nodes have to stack above the footer's column lines. <div className="relative w-full border-y border-white/15 bg-white/2"> <div className={cn(COLUMN, "grid grid-cols-1 items-center justify-items-center gap-x-6 gap-y-4 py-5 text-center md:grid-cols-3 md:justify-items-start md:text-left")}> <span aria-hidden="true" className={cn(NODE, "-top-px left-0 -translate-x-1/2 -translate-y-1/2")} /> <span aria-hidden="true" className={cn(NODE, "-top-px right-0 translate-x-1/2 -translate-y-1/2")} /> <span aria-hidden="true" className={cn(NODE, "-bottom-px left-0 -translate-x-1/2 translate-y-1/2")} /> <span aria-hidden="true" className={cn(NODE, "-bottom-px right-0 translate-x-1/2 translate-y-1/2")} /> <span className="text-body-sm leading-5 font-medium tracking-tight text-white/60">{copyright}</span> <span className="flex items-center justify-center gap-1 text-body-sm leading-5 font-medium tracking-tight text-white/60 md:justify-self-center">{note}</span> {legal.length > 0 && ( <nav aria-label="Legal" className="flex items-center gap-4 md:justify-self-end"> {legal.map((link) => ( <a key={link.href} href={link.href} {...external(link)} className={cn("py-0.5 text-body-sm leading-5 font-medium text-white/60", INLINE_LINK)}> {link.label} </a> ))} </nav> )} </div> </div> )} </div> {(watermark || watermarkAction) && ( // A band of faint dots, brightest in the middle, under the wordmark. <div data-slot="site-footer-watermark" className="pointer-events-none relative z-20 h-44 w-full overflow-hidden bg-dark leading-none select-none sm:h-[clamp(7.5rem,20vw,22rem)]"> <div aria-hidden="true" className="absolute inset-0 bg-[radial-gradient(circle,var(--ds-color-white)_1px,transparent_1.5px)] bg-size-[0.875rem_0.875rem] bg-center opacity-9 [mask-image:radial-gradient(ellipse_at_center,var(--ds-color-black)_20%,transparent_75%)]" /> {watermark} {/* The offset tracks the wordmark's scale, so the button sits on the "e" crossbar; a phone-width wordmark is too short to hold it, so there it sits in its own band above. */} {watermarkAction && ( // Marked as the watermark's cursor origin, so its dotted cursor clicks the button before gliding onto the letters. <span data-watermark-cursor-origin="" className="absolute top-[calc(50%+min(1.65vw,2.1rem))] left-1/2 z-10 -translate-x-1/2 -translate-y-1/2 max-sm:top-4 max-sm:translate-y-0">{watermarkAction}</span> )} </div> )} </footer> );}export { SiteFooter, FooterCopyButton, FooterInlineLink, FooterStatusLink, type FooterLink, type FooterLinkGroup, type ServiceState };Reference: src/components/sections/shared/footer.tsx; src/components/sections/shared/footer-service-status-link.tsx; src/components/sections/shared/footer-copy-prompt-button.tsx; src/components/sections/shared/section-grid-lines.tsx.