Template · React · HTML
Footer Statement
A closing footer with one large display line in an inverted tile, an email action and a quiet row of links.
Preview
React
Install the template with shadcn. It lands in your project as ordinary components that you edit yourself.
npx shadcn@latest add https://mikkelmanniche.dk/lab/r/footer-statement.jsonnpx shadcn@latest add @manniche/footer-statementfooter-statement.tsx
// A closing statement footer in the Tiles language. Signature: one very large display line in an inverted tile that
// spans the width. The line sets itself to the box with fluid type (a clamp on the container's width) and never breaks
// inside a word: it wraps between words, balanced, and the smallest size still fits the longest word at 280 px.
// Under the tile sits one quiet row of links and the legal line. The tile has one action, an email address in mono and
// a copy button that says "Copied" in a live region. Screen readers get a <footer> landmark, an h2 for the statement,
// a <nav> list for the links and the copy result announced politely. The ©-year is computed after mount unless `now`
// is passed. Under reduced motion nothing animates (the only motion is a short opacity/transform on press).
import { ArrowUpRight, Check, Copy } from 'lucide-react'
import { useEffect, useId, useRef, useState, useSyncExternalStore, type ComponentPropsWithoutRef } from 'react'
import { cn } from '@/lib/utils'
import { DataTile } from '@/registry/manniche/data-tile/data-tile'
export type FooterStatementLink = {
label: string
href: string
/** Opens in a new tab with rel="noopener noreferrer" and a hidden "(opens in a new tab)". */
external?: boolean
}
export type FooterStatementLabels = {
navigation?: string
copy?: string
copied?: string
failed?: string
emailLead?: string
newTab?: string
}
export type FooterStatementProps = Omit<ComponentPropsWithoutRef<'footer'>, 'children'> & {
/** The statement, one sentence, e.g. "Let's make the next one together." */
headline: string
/** The one primary action beside the email, e.g. "Start a project". */
action: FooterStatementLink
/** The email address shown in mono with a mailto link and a copy button. */
email?: string
/** A quiet row of links under the tile. */
links?: FooterStatementLink[]
/** The name after the © sign. */
owner: string
/** Legal links on the last line: privacy, terms, imprint. */
legalLinks?: FooterStatementLink[]
/** Pins the © year and keeps the first render pure. When absent the year appears after mount. */
now?: Date
/** UI strings, for translation. */
labels?: FooterStatementLabels
}
const DEFAULT_LABELS: Required<FooterStatementLabels> = {
navigation: 'Footer',
copy: 'Copy',
copied: 'Copied',
failed: 'Not copied',
emailLead: 'Or write to',
newTab: '(opens in a new tab)',
}
const noopSubscribe = () => () => {}
// null on the server and in the first client render, the real year once mounted.
function useYear(now?: Date) {
const clientYear = useSyncExternalStore(noopSubscribe, () => new Date().getFullYear(), () => null)
return now ? now.getFullYear() : clientYear
}
const QUIET =
'relative inline-flex min-h-11 items-center rounded-md text-[13px] text-muted-foreground transition-[color] duration-200 ease-out-quint hover:text-foreground focus-visible:text-foreground focus-visible:outline-offset-0'
function Anchor({ link, newTab, className }: { link: FooterStatementLink; newTab: string; className?: string }) {
return (
<a href={link.href} className={className} {...(link.external ? { target: '_blank', rel: 'noopener noreferrer' } : {})}>
{link.label}
{link.external && <span className="sr-only"> {newTab}</span>}
</a>
)
}
type CopyState = 'idle' | 'copied' | 'failed'
function EmailCopy({ email, labels }: { email: string; labels: Required<FooterStatementLabels> }) {
const [state, setState] = useState<CopyState>('idle')
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
useEffect(() => () => clearTimeout(timer.current), [])
const copy = async () => {
try {
await navigator.clipboard.writeText(email)
setState('copied')
} catch {
setState('failed')
}
clearTimeout(timer.current)
timer.current = setTimeout(() => setState('idle'), 1800)
}
const text = { idle: labels.copy, copied: labels.copied, failed: labels.failed }[state]
const Icon = state === 'copied' ? Check : Copy
return (
<>
<button
type="button"
onClick={copy}
className="inline-flex h-11 min-w-[5.75rem] cursor-pointer items-center justify-center gap-2 rounded-full border border-border px-4 text-[13px] font-medium text-foreground transition-[opacity,transform] duration-200 ease-out-quint hover:opacity-80 active:scale-[0.97] motion-reduce:transition-none"
>
<Icon className="size-3.5" aria-hidden />
{/* The visible word is the same text the live region announces. */}
<span aria-hidden>{text}</span>
<span className="sr-only">
{labels.copy} {email}
</span>
</button>
<span role="status" className="sr-only">
{state === 'idle' ? '' : text}
</span>
</>
)
}
/** A closing statement in an inverted tile with one action, then a quiet row of links and the legal line. */
export function FooterStatement({
headline,
action,
email,
links = [],
owner,
legalLinks = [],
now,
labels,
className,
...rest
}: FooterStatementProps) {
const l = { ...DEFAULT_LABELS, ...labels }
const year = useYear(now)
const headingId = useId()
return (
<footer {...rest} aria-labelledby={headingId} className={cn('@container w-full bg-background text-foreground', className)}>
<div className="mx-auto w-full max-w-6xl px-4 py-12 @min-[40rem]:py-16 sm:px-6">
<DataTile inverted>
<div className="flex flex-col gap-10 py-4 @min-[40rem]:gap-14 @min-[40rem]:px-4 @min-[40rem]:py-8">
<h2
id={headingId}
// Fluid: 11 % of the box's width, never below 36 px (the longest word still fits at 280 px) nor above 128 px.
className="max-w-[16ch] leading-[0.98] font-extrabold tracking-[-0.045em] text-balance [hyphens:none] [overflow-wrap:normal] [word-break:normal]"
style={{ fontFamily: 'var(--font-display, inherit)', fontStretch: '86%', fontSize: 'clamp(2.25rem, 11cqi, 8rem)' }}
>
{headline}
</h2>
<div className="flex flex-wrap items-center gap-x-6 gap-y-4">
<a
href={action.href}
{...(action.external ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
className="group/action inline-flex h-12 items-center gap-2 rounded-full bg-foreground px-6 text-[15px] font-medium text-background transition-[opacity,transform] duration-200 ease-out-quint hover:opacity-90 active:scale-[0.97] motion-reduce:transition-none"
>
{action.label}
{action.external && <span className="sr-only"> {l.newTab}</span>}
<ArrowUpRight className="size-4" aria-hidden />
</a>
{email && (
<div className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1">
<span className="text-sm text-muted-foreground">{l.emailLead}</span>
<a
href={`mailto:${email}`}
className="inline-flex min-h-11 items-center rounded-md font-mono text-sm break-all underline decoration-border underline-offset-4 transition-[opacity] duration-200 hover:opacity-80"
>
{email}
</a>
<EmailCopy email={email} labels={l} />
</div>
)}
</div>
</div>
</DataTile>
<div className="mt-4 flex flex-col gap-x-8 gap-y-1 px-1 @min-[40rem]:flex-row @min-[40rem]:items-center @min-[40rem]:justify-between @min-[40rem]:px-2">
{links.length > 0 && (
<nav aria-label={l.navigation}>
<ul className="flex flex-wrap gap-x-6">
{links.map((link) => (
<li key={link.href + link.label}>
<Anchor link={link} newTab={l.newTab} className={QUIET} />
</li>
))}
</ul>
</nav>
)}
<div className="flex flex-wrap items-center gap-x-5">
<p className="min-h-11 content-center font-mono text-xs text-muted-foreground tabular-nums">
© {year ?? ''}
{year ? ' ' : ''}
{owner}
</p>
{legalLinks.length > 0 && (
<ul className="flex flex-wrap gap-x-5">
{legalLinks.map((link) => (
<li key={link.href + link.label}>
<Anchor link={link} newTab={l.newTab} className={QUIET} />
</li>
))}
</ul>
)}
</div>
</div>
</div>
</footer>
)
}footer-statement-demo.tsx
import { FooterStatement } from '@/components/footer-statement'
// Halden Studio is an invented company. Every link goes to example.com.
export default function FooterStatementDemo() {
return (
<FooterStatement
headline="Let's make the next one together."
action={{ label: 'Start a project', href: 'https://example.com/start' }}
email="hello@example.com"
links={[
{ label: 'Work', href: 'https://example.com/work' },
{ label: 'Studio', href: 'https://example.com/about' },
{ label: 'Journal', href: 'https://example.com/journal' },
{ label: 'LinkedIn', href: 'https://example.com/linkedin', external: true },
]}
owner="Halden Studio, Munich and Aalborg"
legalLinks={[
{ label: 'Privacy', href: 'https://example.com/privacy' },
{ label: 'Imprint', href: 'https://example.com/imprint' },
]}
/>
)
}HTML
The whole page as one file with all CSS built in and no JavaScript. Toggles, tabs and filters work with HTML and CSS alone.
The colour in your own app
If you already have shadcn set up, these variables give the same colour. Light in :root, dark in .dark.
:root {
--primary: oklch(0.52 0.2 262);
--primary-foreground: oklch(0.99 0 0);
--accent: oklch(0.95 0.034 262);
--accent-foreground: oklch(0.36 0.140 262);
--ring: oklch(0.52 0.2 262);
}
.dark {
--primary: oklch(0.74 0.150 262);
--primary-foreground: oklch(0.18 0.024 262);
--accent: oklch(0.29 0.050 262);
--accent-foreground: oklch(0.9 0.050 262);
--ring: oklch(0.74 0.150 262);
}Install
npx shadcn@latest add https://mikkelmanniche.dk/lab/r/footer-statement.json