Header — start here
Install as site header
The one to use for a header. It carries every file inline with its destination path, and tells the destination agent to inventory the existing routes and CSS before replacing anything — the parts that go wrong when a header meets a real layout.
Read it first
Install "Panel footer" from my Astro Component Library as this project's site header.
This is a header, not a page section. It replaces something that already exists, over routes that already exist, under CSS that already exists. Work through the steps in order and report as you go.
--- WHAT THIS HEADER IS ---
Component: Panel footer
Library page: https://astro.baysixmedia.com/components/panel-footer/
Navigation mode: static — a flat list of links, with no panels to open
Position mode: normal
Desktop dropdowns: no
Separate mobile menu: no
Locks body scroll while open: no
Astro client navigation: supported — it re-initialises on astro:page-load and detaches its listeners before each swap
Required files: 1
Dependencies: none — no npm packages, no CSS framework, no UI framework
Assets: none
Prop interface, verbatim from the component:
interface Props {
/** Brand name, set as a wordmark. Empty string hides it. */
brand?: string;
/** Where the wordmark links to. */
brandHref?: string;
/** Sentence under the wordmark. */
description?: string;
/** Small icon links under the description. */
social?: FooterSocial[];
/** The link columns between the brand and the contact block. */
groups?: FooterGroup[];
/** The contact column. Pass `null` to leave it out. */
contact?: FooterContact | null;
/** Button at the foot of the contact column. `null` leaves it out. */
cta?: FooterCta | null;
/** Left-hand line in the legal bar. */
copyright?: string;
/** Centre line in the legal bar. Empty string hides it. */
note?: string;
/** Right-hand links in the legal bar. */
legal?: FooterLink[];
/** Page colour behind the inset panel. */
surround?: string;
/** Top of the panel gradient. */
panelTop?: string;
/** Bottom of the panel gradient. */
panelBottom?: string;
/** Headings and wordmark colour. */
ink?: string;
/** Body and link colour. */
body?: string;
/** Button background. */
accent?: string;
/** Button background on hover. */
accentHover?: string;
/** Link colour on hover. */
linkHover?: string;
/** Corner radius of the panel. */
radius?: string;
class?: string;
id?: string;
}
Route data is passed through props. The prop interface above is the shape to map the destination's routes into.
Known integration notes for this header:
- Goes at the end of the layout, after <slot />, outside <main>.
- Paints its own surround colour edge to edge, so the page behind it does not need to match.
- Contact details render as real tel: and mailto: links — check them against the destination's actual details rather than leaving the demo values.
--- EXPECTED BEHAVIOUR ---
Desktop: every link is reachable in one tab pass, with no panels to open.
Mobile: the same markup reflows; there is no separate menu to open.
Accessibility: a single landmark <nav> with an accessible name, aria-current on the link for the current route, visible focus on every control, 44x44px minimum targets, full prefers-reduced-motion support, and a working no-JavaScript fallback. None of this may be removed while adapting the design.
--- STEPS ---
1. Inspect this project before editing anything. Find its Astro version, whether it uses <ClientRouter /> or view transitions, its global stylesheet, and its existing header. Report what you found before changing a file.
2. Find the real shared layout — the one every page actually renders through — and the current header inside it. Do not assume src/layouts/Layout.astro.
3. Inventory every current navigation route: read the existing header, src/pages/, and any route or nav-data file. List every label and href before touching anything.
4. Map those real routes into this component's navigation structure. Use the destination's own routes and labels. Do not ship the demo tree, and do not invent routes to fill out a panel that looks empty.
5. Preserve the destination's branding and approved copy: its brand name, wordmark, logo mark, button labels and tone. Adapt the component's colour and type props to the destination's tokens.
6. Identify global CSS in this project that targets header, nav, a, button, ul or body, and anything that sets overflow or transform on an ancestor of the header. Those are what break an installed header. Report the collisions and how you resolved each one.
7. Build the replacement alongside the existing header first. Remove the old one only once the new one renders and works on every route.
8. Remove the old header's JavaScript and CSS once it is gone — menu toggles, scroll listeners, body-scroll locks, media-query handlers and their stylesheets. Leaving them causes double body locks and phantom listeners.
9. Make sure exactly one header renders. Check for a second one in a nested layout, in an individual page, or left behind in the old markup.
10. Wire up positioning. It sits in the normal flow and scrolls away with the page. Nothing needs reserving for it.
11. No layout height needs reserving for this one — say so explicitly rather than adding padding that would leave a gap.
12. Preserve the skip link. If the project has one it must still be the first focusable element and must still land on the main content; if a normal header would cover the target, add scroll-margin-top to it. If the project has no skip link, add one.
13. Leave analytics, meta tags, SEO, structured data and unrelated scripts in the layout exactly as they are. Removing a header is not a reason to touch anything else in <head>.
14. Make it work on a normal page load and, if this project uses client-side routing, across client navigation too: state reset between pages, no listeners accumulating, no body scroll lock surviving a navigation.
15. Visit every real route in this project and confirm the header renders, the correct link is marked current, and every href resolves. No 404s.
16. Test the whole site at 1440, 1280, 1024, 834, 390 and 320px: no horizontal overflow, no clipped panels, keyboard operation throughout, and the header never covering focused content.
--- FILES ---
Write each block below to the path in its header. Only the file marked (required) is needed to render; the supporting files are documentation and worked examples.
===== FILE (required): src/components/PanelFooter.astro =====
---
/**
* PanelFooter — a site footer drawn as a rounded dark panel inset from the
* page edge, with a brand column, link groups, a contact block with a call to
* action, and a legal bar underneath.
*
* One self-contained file. No imports, no global stylesheet, no npm packages,
* no bundled assets — and no JavaScript: every behaviour here is a CSS state.
*
* Measured behaviour:
* frame 12px inset around a 20px-radius panel, ring + drop + inset shadow
* columns 1 up to 768, 2 to 1024, then a 12-track row: 4 / 2 / 2 / 4
* link rows 8px apart, 14px/20px
* group titles 12px bold uppercase, 0.05em tracking
* cta pill, 12px bold uppercase, recolours over 200ms
* links recolour over 200ms; there is no underline and no motion
* legal bar stacked and centred below 768, spread into a row above it
*
* The reference footer carries no entrance animation and no scroll effect —
* only hover colour — which is why this file ships no script.
*/
export interface FooterLink {
label: string;
/**
* Destination. Omit it — or pass an empty string — and the item renders as
* plain, non-interactive text instead of a link that goes nowhere.
*/
href?: string;
/** Open in a new tab, with the matching rel. */
external?: boolean;
}
export interface FooterGroup {
title: string;
links: FooterLink[];
}
export interface FooterSocial {
/** Accessible name, e.g. "Mastodon". Rendered as the link's label. */
label: string;
/** Destination. Omit it and the glyph is drawn as inert decoration. */
href?: string;
/**
* `d` attribute for a 24x24 stroked icon. Omit to use a neutral glyph.
* Supply your own so no icon set has to travel with the file.
*/
path?: string;
external?: boolean;
}
export interface FooterContact {
/** Column heading, e.g. "Studio & contact". */
title: string;
/** Small tracked line under the heading, e.g. a registered name. */
legalName?: string;
/** Postal address. Plain text unless `addressHref` is given. */
address?: string;
/** Makes the address a link — a map URL, for example. */
addressHref?: string;
/** Displayed phone number. */
phone?: string;
/** Dial target. Defaults to `phone` stripped of spacing. */
phoneHref?: string;
email?: string;
}
export interface FooterCta {
label: string;
href: string;
external?: boolean;
}
interface Props {
/** Brand name, set as a wordmark. Empty string hides it. */
brand?: string;
/** Where the wordmark links to. */
brandHref?: string;
/** Sentence under the wordmark. */
description?: string;
/** Small icon links under the description. */
social?: FooterSocial[];
/** The link columns between the brand and the contact block. */
groups?: FooterGroup[];
/** The contact column. Pass `null` to leave it out. */
contact?: FooterContact | null;
/** Button at the foot of the contact column. `null` leaves it out. */
cta?: FooterCta | null;
/** Left-hand line in the legal bar. */
copyright?: string;
/** Centre line in the legal bar. Empty string hides it. */
note?: string;
/** Right-hand links in the legal bar. */
legal?: FooterLink[];
/** Page colour behind the inset panel. */
surround?: string;
/** Top of the panel gradient. */
panelTop?: string;
/** Bottom of the panel gradient. */
panelBottom?: string;
/** Headings and wordmark colour. */
ink?: string;
/** Body and link colour. */
body?: string;
/** Button background. */
accent?: string;
/** Button background on hover. */
accentHover?: string;
/** Link colour on hover. */
linkHover?: string;
/** Corner radius of the panel. */
radius?: string;
class?: string;
id?: string;
}
/*
* Nothing about the business is assumed. Brand, description, links, contact
* details, call to action, copyright and note all default to nothing, and a
* block with nothing in it is not drawn — pass nothing at all and the footer
* renders nothing. The catalog's demo studio lives in the registry's preview
* props, not here, so it can never ship as someone's live contact details.
*/
const {
brand,
brandHref,
description,
social = [],
groups = [],
contact = null,
/* A call to action has to go somewhere, so `href` stays required. */
cta = null,
copyright,
note,
legal = [],
surround = "#eef0f2",
panelTop = "#232120",
panelBottom = "#151417",
ink = "#ffffff",
body = "#c9cdd4",
accent = "#b0431e",
accentHover = "#cf5a2e",
linkHover = "#f08a5f",
radius = "20px",
class: className,
id,
} = Astro.props;
const hasBrand = Boolean(brand || description || social.length > 0);
const hasTop = hasBrand || groups.length > 0 || Boolean(contact);
const hasBottom = Boolean(copyright || note || legal.length > 0);
/* 4 tracks for the brand, 2 for each group, 4 for contact — the desktop row. */
const columns = (hasBrand ? 4 : 0) + groups.length * 2 + (contact ? 4 : 0);
/**
* A destination only counts if it is a non-empty string. Anything else — an
* omitted prop, an empty string, a stray "#" — renders as text, so nothing in
* this footer can look like a link while doing nothing.
*/
const isDestination = (href?: string) => {
const trimmed = href?.trim();
return !!trimmed && trimmed !== "#";
};
const relFor = (external?: boolean) => (external ? "noopener noreferrer" : undefined);
const targetFor = (external?: boolean) => (external ? "_blank" : undefined);
/* A dialable href from a displayed number: keep a leading + and the digits. */
const telHref = (number: string) => {
const trimmed = number.trim();
const digits = trimmed.replace(/\D/g, "");
return digits ? `tel:${trimmed.startsWith("+") ? "+" : ""}${digits}` : "";
};
/* A call link only with somewhere to dial: words in `phone` are not a number. */
const callHref = contact?.phone?.trim()
? isDestination(contact.phoneHref)
? contact.phoneHref!.trim()
: telHref(contact.phone)
: "";
const hasContactRows = Boolean(contact && (contact.address || contact.phone || contact.email));
const NEUTRAL_GLYPH = "M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Z";
---
{(hasTop || hasBottom) && (
<footer
class:list={["frame", className]}
id={id}
style={`--pf-surround:${surround};--pf-panel-top:${panelTop};--pf-panel-bottom:${panelBottom};--pf-ink:${ink};--pf-body:${body};--pf-accent:${accent};--pf-accent-hover:${accentHover};--pf-link-hover:${linkHover};--pf-radius:${radius};--pf-columns:${columns};`}
>
<div class="panel">
{hasTop && (
<div class="top">
<div class="grid">
{hasBrand && (
<div class="col brand-col">
{
brand &&
(isDestination(brandHref) ? (
<a class="wordmark" href={brandHref}>
{brand}
</a>
) : (
<p class="wordmark wordmark-static">{brand}</p>
))
}
{description && <p class="description">{description}</p>}
{
social.length > 0 && (
<ul class="social">
{social.map((item) => {
const Glyph = isDestination(item.href) ? "a" : "span";
return (
<li>
<Glyph
class:list={[
"social-link",
{ "social-static": !isDestination(item.href) },
]}
href={isDestination(item.href) ? item.href : undefined}
target={isDestination(item.href) ? targetFor(item.external) : undefined}
rel={isDestination(item.href) ? relFor(item.external) : undefined}
>
<svg
viewBox="0 0 24 24"
width="18"
height="18"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d={item.path ?? NEUTRAL_GLYPH} />
</svg>
<span class="sr-only">{item.label}</span>
</Glyph>
</li>
);
})}
</ul>
)
}
</div>
)}
{
groups.map((group) => (
<div class="col group-col">
<h3 class="col-title">{group.title}</h3>
<ul class="links">
{group.links.map((link) =>
isDestination(link.href) ? (
<li>
<a
class="link"
href={link.href}
target={targetFor(link.external)}
rel={relFor(link.external)}
>
{link.label}
</a>
</li>
) : (
/* Same <li> wrapper as the link branch, so a group with no
destinations keeps the exact row rhythm of one that has them. */
<li>
<span class="link link-static">{link.label}</span>
</li>
),
)}
</ul>
</div>
))
}
{
contact && (
<div class="col contact-col">
<div class="contact-head">
<h3 class="col-title">{contact.title}</h3>
{contact.legalName && <p class="legal-name">{contact.legalName}</p>}
</div>
{hasContactRows && (
<ul class="contact-list">
{contact.address && (
<li class="contact-row">
<svg
class="contact-icon"
viewBox="0 0 24 24"
width="16"
height="16"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M12 21s-6.5-5.3-6.5-10.5a6.5 6.5 0 1 1 13 0C18.5 15.7 12 21 12 21Z" />
<circle cx="12" cy="10.5" r="2.4" />
</svg>
{contact.addressHref ? (
<a class="link" href={contact.addressHref}>
{contact.address}
</a>
) : (
<span class="contact-text">{contact.address}</span>
)}
</li>
)}
{contact.phone && (
<li class="contact-row">
<svg
class="contact-icon"
viewBox="0 0 24 24"
width="16"
height="16"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M6.5 4h3l1.5 4-2 1.5a12 12 0 0 0 5.5 5.5L16 13l4 1.5v3a2 2 0 0 1-2.2 2A16 16 0 0 1 4.5 6.2 2 2 0 0 1 6.5 4Z" />
</svg>
{callHref ? (
<a class="link" href={callHref}>
{contact.phone}
</a>
) : (
<span class="contact-text">{contact.phone}</span>
)}
</li>
)}
{contact.email && (
<li class="contact-row">
<svg
class="contact-icon"
viewBox="0 0 24 24"
width="16"
height="16"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<rect x="3" y="5.5" width="18" height="13" rx="2" />
<path d="m3.5 7 8.5 6 8.5-6" />
</svg>
<a class="link" href={`mailto:${contact.email}`}>
{contact.email}
</a>
</li>
)}
</ul>
)}
{cta && (
<a
class="cta"
href={cta.href}
target={targetFor(cta.external)}
rel={relFor(cta.external)}
>
{cta.label}
</a>
)}
</div>
)
}
</div>
</div>
)}
{hasBottom && (
<div class="bottom">
<div class="bottom-inner">
{copyright && <p class="copyright">{copyright}</p>}
{note && <p class="note">{note}</p>}
{
legal.length > 0 && (
<ul class="legal">
{legal.map((link) =>
isDestination(link.href) ? (
<li>
<a
class="link legal-link"
href={link.href}
target={targetFor(link.external)}
rel={relFor(link.external)}
>
{link.label}
</a>
</li>
) : (
<li>
<span class="link legal-link link-static">{link.label}</span>
</li>
),
)}
</ul>
)
}
</div>
</div>
)}
</div>
</footer>
)}
<style>
.frame {
--pf-muted: color-mix(in srgb, var(--pf-ink) 50%, transparent);
/* Dimmer than body text so it reads as a label, but still held above
the 4.5:1 contrast floor rather than dropped to decoration. */
--pf-static: color-mix(in srgb, var(--pf-ink) 64%, transparent);
--pf-hairline: color-mix(in srgb, var(--pf-ink) 10%, transparent);
--pf-font: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto,
"Helvetica Neue", Arial, sans-serif;
--pf-title: 0.75rem;
--pf-link: 0.875rem;
--pf-small: 0.75rem;
box-sizing: border-box;
display: block;
padding: 12px;
background: var(--pf-surround);
font-family: var(--pf-font);
}
.frame :where(*, *::before, *::after) {
box-sizing: border-box;
}
.panel {
display: flex;
flex-direction: column;
/* Keeps the panel from collapsing when the columns are short. */
min-height: min(30rem, 40vh);
overflow: hidden;
border-radius: var(--pf-radius);
background: linear-gradient(var(--pf-panel-top) 0%, var(--pf-panel-bottom) 100%);
color: var(--pf-body);
box-shadow:
0 0 0 1px rgb(0 0 0 / 0.05),
0 8px 30px rgb(0 0 0 / 0.12),
inset 0 1px 0 rgb(255 255 255 / 0.1);
}
.top {
flex: 1;
padding: 40px 24px 0;
}
/* ---------- columns ---------- */
.grid {
display: grid;
grid-template-columns: 1fr;
gap: 36px;
margin-bottom: 40px;
}
.col {
display: flex;
flex-direction: column;
gap: 14px;
min-width: 0;
}
.wordmark {
align-self: flex-start;
font-size: 1.25rem;
font-weight: 650;
letter-spacing: -0.02em;
color: var(--pf-ink);
text-decoration: none;
}
.description {
max-width: 24rem;
margin: 0;
font-size: var(--pf-link);
line-height: 1.375;
text-wrap: pretty;
}
.social {
display: flex;
align-items: center;
gap: 16px;
list-style: none;
margin: 0;
padding: 4px 0 0;
}
.social-link {
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--pf-body);
text-decoration: none;
transition: color 200ms cubic-bezier(0.4, 0, 0.2, 1);
}
.col-title {
margin: 0;
font-size: var(--pf-title);
font-weight: 700;
line-height: 1.33;
letter-spacing: 0.05em;
text-transform: uppercase;
color: var(--pf-ink);
}
.links {
display: flex;
flex-direction: column;
gap: 8px;
list-style: none;
margin: 0;
padding: 0;
}
.link {
font-size: var(--pf-link);
line-height: 1.43;
color: var(--pf-body);
text-decoration: none;
transition: color 200ms cubic-bezier(0.4, 0, 0.2, 1);
}
/*
* An item with no destination. It is a <li> or <span> rather than an <a>,
* so it is already out of the tab order and unclickable; these rules make
* that legible by dimming it and dropping the text cursor, so it reads as
* a label rather than as a link that is failing to work.
*/
.link-static,
.social-static {
color: var(--pf-static);
cursor: default;
transition: none;
}
/* A brand name that is not a link is a heading, not a broken link, so it
keeps its full weight and colour — it only stops being clickable. */
.wordmark-static {
margin: 0;
cursor: default;
transition: none;
}
/* ---------- contact ---------- */
.contact-head {
display: flex;
flex-direction: column;
gap: 8px;
}
.legal-name {
margin: 0;
font-size: 0.6875rem;
font-weight: 500;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--pf-muted);
}
.contact-list {
display: flex;
flex-direction: column;
gap: 14px;
list-style: none;
margin: 0;
padding: 0;
}
.contact-row {
display: flex;
gap: 10px;
align-items: flex-start;
}
.contact-icon {
flex-shrink: 0;
/* Sits on the first line's optical centre rather than its top edge. */
margin-top: 0.15em;
color: var(--pf-muted);
}
.contact-text {
font-size: var(--pf-link);
line-height: 1.43;
text-wrap: pretty;
}
.cta {
align-self: flex-start;
margin-top: 2px;
padding: 8px 20px;
border-radius: 999px;
background: var(--pf-accent);
color: #fff;
font-size: var(--pf-title);
font-weight: 700;
line-height: 1.33;
letter-spacing: 0.05em;
text-transform: uppercase;
text-decoration: none;
transition: background-color 200ms cubic-bezier(0.4, 0, 0.2, 1);
}
/* ---------- legal bar ---------- */
.bottom {
margin-top: auto;
border-top: 1px solid var(--pf-hairline);
padding: 24px;
}
.bottom-inner {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.copyright,
.note {
margin: 0;
font-size: var(--pf-small);
line-height: 1.67;
text-align: center;
}
.note {
color: var(--pf-muted);
}
.legal {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 12px;
list-style: none;
margin: 0;
padding: 0;
}
/* ---------- states ----------
* The reference leaves the browser's default focus ring in place, which is
* a dark outline on a dark panel and effectively invisible. This draws its
* own so a keyboard reader can see where they are.
*/
.link:focus-visible,
.social-link:focus-visible,
.wordmark:focus-visible,
.cta:focus-visible {
outline: 2px solid var(--pf-ink);
outline-offset: 3px;
border-radius: 2px;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
/* Hover is a pointer affordance. On a touch screen :hover sticks after the
finger lifts, which would strand a link in its hover colour. */
@media (hover: hover) and (pointer: fine) {
.link:hover,
.social-link:hover {
color: var(--pf-link-hover);
}
.wordmark:hover {
color: var(--pf-link-hover);
}
.cta:hover {
background: var(--pf-accent-hover);
}
}
/* ---------- breakpoints ---------- */
@media (min-width: 640px) {
.frame {
--pf-small: 0.875rem;
}
.top {
padding: 48px 40px 0;
}
.bottom {
padding: 32px 40px;
}
}
@media (min-width: 768px) {
.grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 44px;
}
.bottom-inner {
flex-direction: row;
justify-content: space-between;
gap: 16px;
}
.copyright {
text-align: left;
}
.legal {
justify-content: flex-end;
gap: 16px;
}
}
@media (min-width: 1024px) {
.top {
padding: 56px 56px 0;
}
/*
* One row of narrow tracks rather than four equal columns: the brand
* and contact blocks take four each and every link group takes two, so
* the wide blocks stay wide however many groups there are.
*/
.grid {
grid-template-columns: repeat(var(--pf-columns), minmax(0, 1fr));
gap: 28px;
margin-bottom: 48px;
}
.brand-col,
.contact-col {
grid-column: span 4;
}
.group-col {
grid-column: span 2;
}
.bottom {
padding: 32px 56px 40px;
}
}
@media (prefers-reduced-motion: reduce) {
.link,
.social-link,
.wordmark,
.cta {
transition: none;
}
}
</style>
===== END src/components/PanelFooter.astro =====
===== FILE (supporting): src/components/PanelFooter.md =====
# Panel footer
A site footer drawn as a rounded dark panel inset from the page edge, rather
than a full-bleed bar. A brand column, any number of link groups, a contact
block with a call to action, and a legal bar underneath.
Catalog page: `/components/panel-footer`
## Copying it into another Astro project
Copy **`PanelFooter.astro`**. That is the whole component.
| What it needs | Status |
| :--- | :--- |
| Imports from this catalog | none |
| npm packages | none |
| Astro built-ins (`astro:assets`, …) | none |
| Global stylesheet | none — every value is a `--pf-*` property on the root |
| Webfonts | none — system stack |
| Images, logos, icon sets, fonts | **none** — icons are inline `<svg>` paths |
| **JavaScript** | **none** — see below |
| Network requests | none |
| Host requirement | Astro compiles `.astro` files |
**There is no script.** The reference footer carries no entrance animation and
no scroll effect — only hover colour — so everything here is a CSS state. That
means nothing to hydrate, nothing to wait for, and nothing that can break on a
page with its own JavaScript.
### Exact steps
1. Copy `PanelFooter.astro` into your components folder, e.g.
`src/components/PanelFooter.astro`.
2. Import it in your layout, **outside** your main content wrapper, and pass
your own content:
```astro
---
import PanelFooter from "../components/PanelFooter.astro";
---
<body>
<main><slot /></main>
<PanelFooter
brand="Your Studio"
brandHref="/"
description="One sentence about what you do."
groups={[
{ title: "Explore", links: [
{ label: "Work", href: "/work" },
{ label: "Contact", href: "/contact" },
]},
]}
contact={{
title: "Studio & contact",
legalName: "YOUR STUDIO LLC",
address: "Your address",
phone: "+1 (555) 0142",
email: "hello@yourstudio.com",
}}
cta={{ label: "Start a project", href: "/contact" }}
copyright="© 2026 Your Studio. All rights reserved."
/>
</body>
```
3. Set `surround` to whatever your page background is, so the 12px frame around
the panel blends into the page rather than showing a band of the wrong
colour.
## Destinations, and what happens without one
**`href` is optional on every navigation item.** Give one and you get a real
`<a>`. Leave it out — or pass `""` or `"#"` — and that item renders as a
`<span>` of plain text instead: dimmed, with the default cursor, out of the tab
order, and with no hover state.
Nothing in this footer can look like a link while doing nothing.
**Nothing about your business is assumed.** Brand, description, social
links, link groups, contact block, call to action, copyright, note and legal
links all default to empty, and a block with nothing in it is not drawn — no
empty column, no bare legal bar. Pass nothing at all and the component
renders nothing. The catalog preview's studio ("Meridian Works") is demo data
passed from the catalog registry; it does not travel with the file.
In that demo the Home / Work / Studio / Journal / Privacy / Terms entries have
no `href`, so they render as text. The phone becomes a `tel:` link only when
the number actually contains digits (or you pass a `phoneHref`); otherwise it
is plain text.
Point the items at your own pages and they all become links:
```astro
<PanelFooter
brandHref="/"
groups={[
{ title: "Explore", links: [
{ label: "Work", href: "/work" },
{ label: "Journal", href: "/journal" },
{ label: "Careers", href: "https://jobs.example.com", external: true },
{ label: "Press kit" }, {/* no href -> plain text */}
]},
]}
social={[
{ label: "Mastodon", href: "https://example.social/@you", external: true,
path: "M4 12a8 8 0 1 1 16 0" },
]}
cta={{ label: "Start a project", href: "/contact" }}
legal={[
{ label: "Privacy", href: "/privacy" },
{ label: "Terms", href: "/terms" },
]}
/>
```
`FooterCta.href` is the one destination that stays **required** — a call to
action with nowhere to go is not a call to action. Point it at your contact
page, or at a `mailto:` if you have no such page. Pass `cta={null}` to drop the
button entirely.
## Props
| Prop | Type | Default | What it does |
| :--- | :--- | :--- | :--- |
| `brand` | `string` | — | Wordmark text. Omitted or `""` hides it. |
| `brandHref` | `string` | — | Where the wordmark links. Omit it and the wordmark is plain text. |
| `description` | `string` | — | Sentence under the wordmark. Omitted hides it. |
| `social` | `FooterSocial[]` | `[]` | Icon links under the description. `[]` hides the row. |
| `groups` | `FooterGroup[]` | `[]` | The link columns. Any count works. |
| `contact` | `FooterContact \| null` | `null` | The contact column. `null` leaves it out. |
| `cta` | `FooterCta \| null` | `null` | Button under the contact details. `href` is required. `null` leaves it out. |
| `copyright` | `string` | — | Left-hand line in the legal bar. Never generated for you. |
| `note` | `string` | — | Centre line in the legal bar. Omitted or `""` hides it. |
| `legal` | `FooterLink[]` | `[]` | Right-hand links in the legal bar. |
| `surround` | `string` | `"#eef0f2"` | Page colour behind the inset panel. |
| `panelTop` | `string` | `"#232120"` | Top of the panel gradient. |
| `panelBottom` | `string` | `"#151417"` | Bottom of the panel gradient. |
| `ink` | `string` | `"#ffffff"` | Headings and wordmark. The muted tone derives from it. |
| `body` | `string` | `"#c9cdd4"` | Body text and links. |
| `accent` | `string` | `"#b0431e"` | Button background. |
| `accentHover` | `string` | `"#cf5a2e"` | Button background on hover. |
| `linkHover` | `string` | `"#f08a5f"` | Link colour on hover. |
| `radius` | `string` | `"20px"` | Corner radius of the panel. |
| `class` | `string` | — | Added to the root `<footer>`. |
| `id` | `string` | — | Root element id. |
### The types
```ts
interface FooterLink {
label: string;
href?: string; // omit -> plain non-interactive text, not a dead link
external?: boolean; // adds target="_blank" rel="noopener noreferrer"
}
interface FooterGroup {
title: string; // 12px bold uppercase column heading
links: FooterLink[];
}
interface FooterSocial {
label: string; // accessible name — rendered visually hidden
href?: string; // omit -> the glyph is drawn as inert decoration
path?: string; // `d` for a 24x24 stroked icon; omit for a neutral glyph
external?: boolean;
}
interface FooterContact {
title: string; // column heading
legalName?: string; // small tracked line under it
address?: string; // plain text unless addressHref is given
addressHref?: string; // makes the address a link — a map URL, say
phone?: string; // as displayed
phoneHref?: string; // dial target; defaults to phone stripped to +digits
email?: string; // becomes a mailto: link
}
interface FooterCta { label: string; href: string; external?: boolean; }
```
**Icons come from you.** `FooterSocial.path` takes a raw SVG `d` string, so no
icon set has to travel with the file and no brand mark is baked in. Omit it and
a neutral glyph is drawn instead. The three demo glyphs are generic shapes, not
any company's logo.
## Layout
| | ≥1024 | 768–1023 | 640–767 | <640 |
| :--- | :--- | :--- | :--- | :--- |
| Columns | one row: 4 / 2 / 2 / 4 | two | one | one |
| Grid gap | 28px | 44px | 36px | 36px |
| Panel padding | 56px | 40px | 40px | 24px |
| Legal bar | row, spread | row, spread | column, centred | column, centred |
| Legal bar padding | 32/56/40px | 32px 40px | 32px 40px | 24px |
| Copyright | 14px, left | 14px, left | 14px, centred | 12px, centred |
The desktop row is built from `4 + 2 × groups + 4` equal tracks, so the brand
and contact blocks keep four tracks each and every link group takes two —
whatever number of groups you pass. Two groups gives the tested twelve.
The frame is 12px of `surround` around a panel with a 20px radius and three
stacked shadows: a 1px ring, a soft drop, and a 1px inset highlight along the
top edge.
## Behaviour and accessibility
- **Links and the button recolour on hover** over `200ms`, gated behind
`(hover: hover) and (pointer: fine)` so a tap does not strand a link in its
hover colour. There is no underline and no movement.
- **Focus rings are explicit.** Every link, the wordmark, the social icons and
the button draw a `2px` outline in `ink` at a `3px` offset on
`:focus-visible`.
- **Icon-only social links carry a visually hidden label**, so they announce as
"Website", "Newsletter" and so on rather than as bare links.
- **Items with no destination are never anchors.** They render as `<span>`, so
they are already unfocusable and unclickable without needing `tabindex="-1"`
or `aria-disabled`; the dimmed colour and default cursor make that legible on
screen. The dimmed tone is held at roughly 7:1 against the panel, so it reads
as a quiet label rather than as disabled text.
- **Contact details are real targets**: the phone becomes a `tel:` link with
spacing and punctuation stripped (plain text if it has no digits and no
`phoneHref`), the email a `mailto:`. The address stays
plain text unless you give it an `addressHref`.
- **Reduced motion:** `prefers-reduced-motion: reduce` removes the hover
transitions. Nothing else moves, so nothing else needs disabling.
### Where this differs from the reference deliberately
- **The reference hides its Services column below 768px** (`hidden md:block`).
This does not hide anything — a reader on a phone gets the same links as a
reader on a laptop.
- **The reference leaves the browser's default focus ring**, which is a dark
outline on a dark panel and effectively invisible. This draws its own.
## Notes on the reference
The layout — an inset rounded dark panel, a brand column beside link groups, a
contact block ending in a pill button, a divided legal bar — was studied from a
live marketing footer. **None of that site's content came with it.** The brand
name, wordmark, description, link labels, address, phone number, email,
copyright and note here are invented for this catalog; the studio is fictional,
the address uses a placeholder street, the phone number is in the reserved
`555-01xx` fiction range, and the email uses the reserved `.example` domain. No
logo, icon, photograph, colour or asset was copied.
## Installing this as site furniture
**Installation mode:** single file, static, normal flow.
It is a Navigation-category **footer**, not a header. It goes through the same
installation machinery and its catalog page reads "Production-ready navigation
component" rather than "header": different landmark, different place in the
layout, different failure modes. Client navigation is recorded as
`not-applicable` because it ships no script at all — there is nothing a
client-side router could strand, so it gets no ClientRouter fixture.
This component is registered as **Navigation**, so its catalog page carries a
header-readiness panel, a header-installation prompt and a **Copy header kit**
action, and it has a full-page integration fixture at
`/integration-test/navigation/panel-footer/`. The difference between a visual
component and a production header, and what that machinery does, is in the
project README.
### Required files
| File | Destination | Required |
| :--- | :--- | :--- |
| `PanelFooter.astro` | `src/components/PanelFooter.astro` | yes |
| `PanelFooter.md` | — | documentation |
One required file. No package, no stylesheet, no script, no asset.
### Mapping routes into it
Link columns are `groups`, and the legal bar is `legal`. Both take the same
`FooterLink` shape, and a link with no `href` renders as plain text rather than
as a link that goes nowhere — which is what you want for a route that does not
exist yet.
```astro
---
import Footer from "../components/PanelFooter.astro";
---
<Footer
brand="Your name"
brandHref="/"
groups={[
{ title: "Work", links: [
{ label: "Case studies", href: "/work" },
{ label: "Process", href: "/process" },
] },
{ title: "Company", links: [
{ label: "About", href: "/about" },
{ label: "Careers" },
] },
]}
legal={[{ label: "Privacy", href: "/privacy" }]}
/>
```
Pass your real contact details. They render as `tel:` and `mailto:` links,
and nothing is shown for any detail you leave out.
### Replacing the existing footer
1. Find the shared layout and the footer inside it. Do not assume
`src/layouts/Layout.astro`.
2. Inventory every link in the current footer — label and href — before
deleting anything.
3. Render this one **after** `<slot />` and **outside** `<main>`.
4. Check for a second footer left in a nested layout or an individual page.
5. Delete the old footer's markup and its CSS once this one is correct.
### Positioning requirements
`position: normal` — it sits in the flow and needs nothing reserved. It paints
its own surround colour edge to edge, so the page behind it does not have to
match.
### Astro lifecycle behaviour
None needed. This component ships no JavaScript at all — no script tag, no
client directive, no listeners. There is nothing to re-initialise after a
client-side navigation, which is why its registry entry records
`supportsAstroNavigation: true`: the field means "survives it", not "listens
for it".
### Verification checklist
Work all of it on `/integration-test/navigation/panel-footer/`, **and** in the
isolated preview at `/preview/panel-footer/`. A header that passes in one environment
and not the other is not ready.
- [ ] Widths 1440, 1280, 1024, 834, 390, 320 — `scrollWidth === clientWidth`
- [ ] 200% zoom (emulate it by halving the CSS viewport, not with `body { zoom }`)
- [ ] scrollY 0, 1, 100, 500, and back to 0
- [ ] Keyboard only, start to finish: no trap, no focus behind a closed layer
- [ ] Every dropdown: click, Enter, Space, Escape, focus return, outside click
- [ ] Mobile menu: open, close, Escape, focus return, link-click close
- [ ] Resize across the breakpoint while open
- [ ] Skip link is the first focusable element and its target is not covered
- [ ] Hash navigation leaves the target heading visible
- [ ] Astro client navigation: `astro:page-load` re-boots, `astro:before-swap` detaches
- [ ] JavaScript disabled — every link still reachable
- [ ] `prefers-reduced-motion: reduce`
- [ ] Two instances, independent, no shared state and no shared lock
- [ ] Body scroll restored after every close
- [ ] No duplicate ids
- [ ] No horizontal overflow, no clipped panel, no header over focused content
- [ ] No console errors, no requests originating in the component
===== END src/components/PanelFooter.md =====
--- VERIFIED IN THE COMPONENT LIBRARY ---
Production-ready in the library.
No verification run has been recorded.
Passed: integration route, keyboard, no javascript, reduced motion, two instances, no overflow.
This record pre-dates source fingerprinting, so it is not tied to a specific revision.
--- NOT PROVEN ---
- ClientRouter: not applicable — this component ships no script.
--- WHAT THIS DOES NOT CERTIFY ---
Verification was performed on the unmodified component, in an isolated fixture, in the
library. It says nothing about this project's CSS, routes, layout or data, and nothing
about the component once you adapt it. A verified component is a good starting point,
not a guarantee about the installation you are about to build.
--- TEST IN THIS PROJECT AFTER INSTALLING OR CUSTOMISING ---
- Horizontal overflow at 1440, 1280, 1024, 834, 390 and 320px.
- Keyboard operation end to end, including visible focus.
- The no-JavaScript render.
- prefers-reduced-motion: reduce.
- Two instances on one page, if this project renders more than one.
- If this project uses <ClientRouter />: teardown and reinitialisation across a real navigation.
--- FINISH ---
Run this project's own check and build commands. Report: the layout file you changed, the routes you mapped and what you mapped them to, the CSS collisions you found, what you deleted from the old header, and anything you could not verify. Do not commit and do not deploy.