Pediment Footer
A symmetric heritage footer under a shallow pediment drawn in one gold hairline: the roundel in the tympanum, centred columns, a catalogue sign-up on a ledger line and roman-numeral small print.
- Category
- Sections
- Added
- 2026-09-26
- Deps
- none
- Updated
- 2026-09-26
Props
"use client";
import { useEffect, useId, useRef, useState } from "react";
import type { FormEvent } from "react";
/**
* PedimentFooter — a symmetric heritage footer under a shallow pediment
* drawn in one gold hairline.
*
* The pediment is abstract: a low gable (about ten degrees, far flatter than
* a temple front) over a doubled lintel, with the house's roundel set in the
* tympanum. When the footer comes into view the two raking lines draw from
* their ends and meet at the apex while the lintel grows out from the
* centre, once. Everything under it is centred: the name, the address in
* italic, link columns set on the axis, an optional catalogue sign-up on a
* ledger line, and the small print, with the year in roman numerals if you
* like.
*
* The sign-up validates the address and runs your async onSubscribe with a
* sending state; nothing is sent by this file. Type reads var(--font-didone)
* and var(--font-text). Needs Tailwind v4, or v3.4+ with
* @tailwindcss/container-queries. No dependencies beyond React. The sign-up
* posts (method="post"), so without JavaScript no address lands in a URL.
*/
const DIDONE = 'var(--font-didone, "Bodoni Moda", "Didot", "Bodoni 72", "Bodoni MT", Georgia, serif)';
const TEXT = 'var(--font-text, "Iowan Old Style", "Baskerville", "Libre Baskerville", Georgia, "Times New Roman", serif)';
const TONES = {
ivory: { ground: "#f4f1ea", ink: "#14120f", muted: "rgba(20,18,15,0.62)", rule: "rgba(20,18,15,0.18)", error: "#8a2323" },
charcoal: { ground: "#14120f", ink: "#f4f1ea", muted: "rgba(244,241,234,0.6)", rule: "rgba(244,241,234,0.18)", error: "#e08a7e" },
} as const;
const HEX = /^#[0-9a-fA-F]{6}$/;
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
// Static rules only: nothing a prop controls is written into this sheet.
const CSS = [
"@keyframes pf-draw { from { stroke-dashoffset: 1; } to { stroke-dashoffset: 0; } }",
"@keyframes pf-grow { from { transform: scaleX(0); } to { transform: scaleX(1); } }",
"@keyframes pf-rise { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }",
".pf-armed .pf-s { stroke-dasharray: 1; stroke-dashoffset: 1; }",
".pf-armed .pf-g { transform: scaleX(0); }",
".pf-armed .pf-r { opacity: 0; }",
".pf-play .pf-s { stroke-dasharray: 1; animation: pf-draw 1400ms cubic-bezier(0.22,1,0.36,1) both; }",
".pf-play .pf-g { transform-origin: 50% 50%; animation: pf-grow 900ms cubic-bezier(0.22,1,0.36,1) both; }",
".pf-play .pf-r { animation: pf-rise 900ms cubic-bezier(0.22,1,0.36,1) 1450ms both; }",
"@media (scripting: none) { .pf-armed .pf-s { stroke-dashoffset: 0; } .pf-armed .pf-g { transform: none; } .pf-armed .pf-r { opacity: 1; } }",
"@media (prefers-reduced-motion: reduce) { .pf-armed .pf-s, .pf-play .pf-s, .pf-armed .pf-g, .pf-play .pf-g, .pf-armed .pf-r, .pf-play .pf-r { animation: none; stroke-dashoffset: 0; transform: none; opacity: 1; } }",
].join("\n");
const ROMAN: [number, string][] = [
[1000, "M"], [900, "CM"], [500, "D"], [400, "CD"], [100, "C"], [90, "XC"],
[50, "L"], [40, "XL"], [10, "X"], [9, "IX"], [5, "V"], [4, "IV"], [1, "I"],
];
function toRoman(n: number) {
let v = Math.round(n);
if (v < 1 || v > 3999) return String(n);
let out = "";
for (const [k, s] of ROMAN) {
while (v >= k) {
out += s;
v -= k;
}
}
return out;
}
/** Links: strip tabs and newlines, refuse backslashes, allow http(s), mailto, tel and same-site paths. */
function safeHref(raw: string) {
const v = raw.replace(/[\t\n\r]/g, "").trim();
if (!v || v.includes("\\")) return "#";
if (/^(https?:|mailto:|tel:)/i.test(v)) return v;
if (/^[/#?]/.test(v) && !/^\/\//.test(v)) return v;
return "#";
}
/**
* True once the given share of the element is on screen, or as much of it as
* the view can hold. isIntersecting alone is true at the first visible pixel.
*/
function seen(e: IntersectionObserverEntry, share: number) {
if (!e.isIntersecting) return false;
const rootH = e.rootBounds ? e.rootBounds.height : window.innerHeight;
return e.intersectionRatio >= share - 0.01 || e.intersectionRect.height >= rootH * share - 1;
}
export type PedimentLink = { label: string; href: string };
export type PedimentColumn = { title: string; links: PedimentLink[] };
export type PedimentFooterProps = {
name: string;
/** One or two initials for the roundel in the pediment. */
initials: [string] | [string, string];
address?: string;
columns: PedimentColumn[];
/** A catalogue sign-up on a ledger line. Omit to leave it out. */
subscribe?: {
label: string;
button?: string;
done?: string;
onSubscribe: (email: string) => Promise<void> | void;
};
/** The registered name for the small print. */
company?: string;
year: number;
/** Set the year in roman numerals. */
roman?: boolean;
legal?: PedimentLink[];
social?: PedimentLink[];
tone?: "ivory" | "charcoal";
/** The pediment's metal, as #rrggbb. */
metal?: string;
className?: string;
};
export function PedimentFooter({
name,
initials,
address,
columns,
subscribe,
company,
year,
roman = false,
legal = [],
social = [],
tone = "ivory",
metal = "#b8964f",
className = "",
}: PedimentFooterProps) {
const t = tone === "charcoal" ? TONES.charcoal : TONES.ivory;
const gold = HEX.test(metal) ? metal : "#b8964f";
const uid = "pf" + useId().replace(/[^a-zA-Z0-9]/g, "");
const rootRef = useRef<HTMLElement>(null);
const pedRef = useRef<HTMLDivElement>(null);
const [w, setW] = useState(1088);
const [phase, setPhase] = useState<"armed" | "play">("armed");
const [email, setEmail] = useState("");
const [state, setState] = useState<"idle" | "error" | "sending" | "done" | "failed">("idle");
const run = useRef(0);
const inputRef = useRef<HTMLInputElement>(null);
const doneRef = useRef<HTMLParagraphElement>(null);
// The form unmounts on success: hand focus to the line that replaces it.
useEffect(() => {
if (state === "done") doneRef.current?.focus();
}, [state]);
useEffect(() => {
const el = pedRef.current;
if (!el) return;
const ro = new ResizeObserver(() => setW(el.offsetWidth || 1088));
ro.observe(el);
return () => ro.disconnect();
}, []);
useEffect(() => {
const el = rootRef.current;
if (!el) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
Promise.resolve().then(() => setPhase("play"));
return;
}
const io = new IntersectionObserver(
(entries) => {
if (seen(entries[entries.length - 1], 0.2)) {
setPhase("play");
io.disconnect();
}
},
{ threshold: 0.2 },
);
io.observe(el);
return () => io.disconnect();
}, []);
async function join(e: FormEvent) {
e.preventDefault();
if (!subscribe || state === "sending") return;
const value = email.trim();
// Length first: it keeps the pattern test cheap on pathological input.
if (value.length > 254 || !EMAIL.test(value)) {
setState("error");
inputRef.current?.focus();
return;
}
const mine = ++run.current;
setState("sending");
try {
await subscribe.onSubscribe(email.trim());
if (mine === run.current) setState("done");
} catch {
if (mine === run.current) setState("failed");
}
}
// The pediment: a low gable (rise about 9% of the width) over a doubled lintel.
const rise = Math.max(28, Math.min(96, w * 0.09));
const H = rise + 9;
const f = (n: number) => n.toFixed(2);
const left = "M0.5 " + f(rise) + "L" + f(w / 2) + " 0.5";
const right = "M" + f(w - 0.5) + " " + f(rise) + "L" + f(w / 2) + " 0.5";
const [first, second] = initials;
const note = state === "error" ? "That email address looks incomplete." : state === "failed" ? "It did not go through; please try again." : "";
return (
<footer ref={rootRef} className={"@container overflow-x-clip " + (phase === "play" ? "pf-play " : "pf-armed ") + className} style={{ background: t.ground, color: t.ink }}>
<style>{CSS}</style>
<div className="mx-auto w-full max-w-[1280px] px-6 pb-10 pt-20 @3xl:px-24 @3xl:pt-24">
<div ref={pedRef} className="relative mx-auto w-full max-w-[1088px]" style={{ height: H }}>
<svg aria-hidden width={w} height={H} className="absolute inset-0 overflow-visible" fill="none">
<path className="pf-s" pathLength={1} d={left} stroke={gold} strokeWidth={1} />
<path className="pf-s" pathLength={1} d={right} stroke={gold} strokeWidth={1} />
</svg>
<span aria-hidden className="pf-g absolute inset-x-0 h-px" style={{ top: rise, background: t.ink, opacity: 0.55 }} />
<span aria-hidden className="pf-g absolute inset-x-0 h-px" style={{ top: rise + 5, background: t.rule }} />
{/* The roundel in the tympanum. */}
<svg
aria-hidden
width="40"
height="40"
viewBox="0 0 40 40"
className="pf-r absolute left-1/2 -translate-x-1/2 overflow-visible"
style={{ top: Math.max(4, rise * 0.5 - 16) }}
>
<circle cx="20" cy="20" r="19.5" fill={t.ground} stroke={t.ink} strokeOpacity={0.5} strokeWidth="1" />
<text x="20" y="24.2" textAnchor="middle" fontSize={second ? 11.5 : 14} fill={t.ink} style={{ fontFamily: DIDONE, letterSpacing: "0.04em" }}>
{first}
{second && (
<>
<tspan fontStyle="italic" fontSize="10" dx="0.5">
&
</tspan>
<tspan dx="0.5">{second}</tspan>
</>
)}
</text>
</svg>
</div>
<div className="pf-r mt-10 text-center">
<p className="text-[12px] uppercase tracking-[0.28em]" style={{ fontFamily: DIDONE }}>
{name}
</p>
{address && (
<p className="mx-auto mt-3 max-w-[48ch] text-[15px] italic leading-[1.6]" style={{ fontFamily: TEXT, color: t.muted }}>
{address}
</p>
)}
</div>
{columns.length > 0 && (
<nav aria-label="Footer" className="mx-auto mt-14 grid max-w-[880px] gap-10 text-center @2xl:grid-cols-3">
{columns.map((c, i) => (
<div key={i}>
<h2 className="text-[10.5px] font-normal uppercase tracking-[0.22em]" style={{ fontFamily: DIDONE, color: t.muted }}>
{c.title}
</h2>
<ul role="list" className="mt-4 space-y-1.5">
{c.links.map((l, j) => (
<li key={j}>
<a
href={safeHref(l.href)}
className="group relative inline-block py-1 text-[17px] outline-offset-4 focus-visible:outline focus-visible:outline-1"
style={{ fontFamily: DIDONE, outlineColor: t.ink }}
>
{l.label}
<span
aria-hidden
className="absolute inset-x-0 bottom-0.5 h-px origin-center scale-x-0 transition-transform duration-[600ms] ease-[cubic-bezier(0.22,1,0.36,1)] group-hover:scale-x-100 group-focus-visible:scale-x-100 motion-reduce:transition-none"
style={{ background: t.ink }}
/>
</a>
</li>
))}
</ul>
</div>
))}
</nav>
)}
{subscribe && (
<div className="mx-auto mt-16 max-w-[460px] text-center">
<p id={uid + "-s"} className="text-[10.5px] uppercase tracking-[0.22em]" style={{ fontFamily: DIDONE, color: t.muted }}>
{subscribe.label}
</p>
{state === "done" ? (
<p ref={doneRef} tabIndex={-1} role="status" className="mt-5 text-[17px] italic outline-none" style={{ fontFamily: TEXT }}>
{subscribe.done || "Thank you. The next catalogue will find you."}
</p>
) : (
<form method="post" noValidate onSubmit={join} aria-labelledby={uid + "-s"} className="mt-4 flex items-end gap-6">
<div className="group/pf relative flex-1">
<label htmlFor={uid + "-e"} className="sr-only">
Email address
</label>
<input
ref={inputRef}
id={uid + "-e"}
name="email"
type="email"
autoComplete="email"
maxLength={254}
value={email}
readOnly={state === "sending"}
onChange={(e) => {
if (state === "sending") return;
setEmail(e.target.value);
if (state === "error" || state === "failed") setState("idle");
}}
placeholder="Email address"
aria-invalid={state === "error" || undefined}
aria-describedby={note ? uid + "-n" : undefined}
className="block w-full rounded-none bg-transparent pb-2.5 pt-2 text-center text-[17px] outline-none placeholder:italic placeholder:text-[color:var(--pf-ph)]"
style={{ fontFamily: TEXT, color: t.ink, ["--pf-ph" as string]: t.muted }}
/>
<span aria-hidden className="absolute inset-x-0 bottom-0 h-px" style={{ background: t.rule }} />
<span
aria-hidden
className={
"absolute inset-x-0 bottom-0 h-px origin-center transition-transform duration-[600ms] ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none " +
(note ? "scale-x-100" : "scale-x-0 group-focus-within/pf:scale-x-100")
}
style={{ background: note ? t.error : t.ink }}
/>
</div>
<button
type="submit"
aria-disabled={state === "sending" || undefined}
className="h-11 shrink-0 text-[11px] uppercase tracking-[0.2em] outline-offset-4 focus-visible:outline focus-visible:outline-1 aria-disabled:opacity-60"
style={{ fontFamily: DIDONE, outlineColor: t.ink }}
>
{state === "sending" ? "Sending" : subscribe.button || "Subscribe"}
</button>
</form>
)}
{note && (
<p id={uid + "-n"} role="alert" className="mt-2.5 text-[13.5px] italic" style={{ fontFamily: TEXT, color: t.error }}>
{note}
</p>
)}
</div>
)}
<div className="mt-16 pt-6 text-center" style={{ borderTop: "1px solid " + t.rule }}>
{social.length > 0 && (
<ul role="list" className="flex flex-wrap justify-center gap-x-7 gap-y-2">
{social.map((l, i) => (
<li key={i}>
<a
href={safeHref(l.href)}
className="text-[10.5px] uppercase tracking-[0.22em] underline-offset-4 outline-offset-4 hover:underline focus-visible:outline focus-visible:outline-1"
style={{ fontFamily: DIDONE, outlineColor: t.ink }}
>
{l.label}
</a>
</li>
))}
</ul>
)}
<p className="mt-4 text-[13px] leading-relaxed" style={{ fontFamily: TEXT, color: t.muted }}>
{"© " + (roman ? toRoman(year) : String(year)) + " " + (company || name)}
{legal.map((l, i) => (
<span key={i}>
<span aria-hidden className="mx-2.5">
·
</span>
<a
href={safeHref(l.href)}
className="underline-offset-4 outline-offset-2 hover:underline focus-visible:outline focus-visible:outline-1"
style={{ outlineColor: t.ink }}
>
{l.label}
</a>
</span>
))}
</p>
</div>
</div>
</footer>
);
}
About this section
A heritage page ends the way a building does, with something overhead. This footer sits under a shallow pediment, a low gable of about ten degrees drawn in one gold hairline over a doubled lintel, far flatter than a temple front so it reads as architecture rather than a costume. The house's roundel sits in the tympanum. When the footer comes into view the two raking lines draw from their ends and meet at the apex while the lintel grows from the centre, once. Everything under it is centred on the same axis: the name in spaced capitals, the address in italic, the link columns, an optional catalogue sign-up on a ledger line that validates the address and runs your own handler, and the small print, with the year in roman numerals if you like. Social links are words, not icons. It works on ivory or charcoal, in gold, burgundy or ink.
Curator’s note
Part of the Neoclassical kit, drop 3, phase 9: the footer. Free as the kit's footer slot, like every drop's footer. By the letter of the tier rule (1.5) a section of several parts with a validating sign-up reads Pro; it is the phase's borderline entry, kept Free so the phase stays 2/2 and the kit's closing piece stays open to everyone.
Pairs well with
Matched on shared tags and category — the entries most likely to be used alongside this one.
Hairline Arch Hero
heroesA centred Didone headline under a segmental arch drawn in one gold hairline: it rises from both bases, meets at a keystone, and catches a raking light under the pointer.
Engraved Rule Button
componentsA heritage button in spaced Didone capitals: on hover a gold hairline engraves itself under the label from the centre out. No fill, no scale, no spring.
Plinth Object Card
componentsAn object standing on one gold plinth line with a catalogue caption: lot number, Didone title, date, provenance and estimate. The line draws out on hover.