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 "Directory 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: Directory footer
Library page: https://astro.baysixmedia.com/components/directory-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 {
/** The wordmark. */
brand?: string;
/** Where the wordmark goes. Usually home. */
brandHref?: string;
/** A sentence or two under it. `""` drops it. */
blurb?: string;
/** Icon links under the blurb. `[]` drops the row. */
social?: DirectoryFooterSocial[];
/** The link directory. Four columns is what the layout is drawn for. */
columns?: DirectoryFooterColumn[];
/** The line at the foot, left side. */
copyright?: string;
/** Small links beside the copyright. */
legal?: DirectoryFooterLink[];
/** Text for the back-to-top control. `""` drops it. */
backToTopLabel?: string;
/**
* Where back-to-top points.
*
* `"#top"` is deliberate rather than arbitrary: when no element carries
* that id, browsers treat it as the top of the document, so the control
* works in any host without the host adding anything.
*/
backToTopHref?: string;
/** Heading level for the column headings. */
headingLevel?: "h2" | "h3" | "h4";
/** The footer's ground. */
background?: string;
/** Wordmark and link text. */
ink?: string;
/** Blurb, headings, copyright, marks — everything secondary. */
mutedInk?: string;
/** The underline that wipes in under a link on hover and focus. */
accent?: string;
/** The hairline over the bottom bar. */
rule?: string;
/**
* Where the CONTENT stops widening. The ground stays edge to edge; only
* the columns inside are capped and centred, which is what the reference
* does and what keeps a four-column directory from stretching to the far
* corners of a wide monitor.
*/
maxWidth?: string;
/** Added to the root. */
class?: string;
/** Root element id. */
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>.
- It is a <footer> element. At the top level of the page that is the contentinfo landmark, so do not wrap it in another one.
- Paints its own ground edge to edge, so the page behind it does not need to match.
- Back to top is an anchor to #top, which browsers resolve to the top of the document when nothing carries that id. Nothing to add in the host, and it works with JavaScript disabled. If the host wants it to glide rather than jump, that is scroll-behavior: smooth on the host's own root, guarded by prefers-reduced-motion.
- Each column is a <nav> labelled by its own heading, so the footer contributes several navigation landmarks. That is deliberate and it is how a screen-reader user reaches Resources without walking every link, but a host that counts landmarks should expect them.
- Links and social marks render whatever hrefs you pass and the demo ships # throughout - check them against the destination's real routes rather than leaving the placeholders.
--- 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/DirectoryFooter.astro =====
---
/**
* Directory footer
*
* A full-bleed dark site footer: a brand block with a blurb and social marks
* on one side, a directory of link columns on the other, then a hairline and
* a bottom bar carrying the copyright, the legal links and a back-to-top
* control.
*
* No script. Back to top is an anchor to `#top`, which the HTML spec defines
* as the top of the document when nothing carries that id — so it works with
* JavaScript disabled, it can be opened in a new tab, and it needs nothing
* from the host page. The reference uses a button, which can do none of that.
*
* Self-contained: no imports, no packages, no global stylesheet, no webfont,
* no image. Every colour and measurement is a `--dfr-*` custom property on
* the root, and the icons are inline SVG.
*/
export interface DirectoryFooterLink {
label: string;
href: string;
}
export interface DirectoryFooterColumn {
/** The small capitalised heading over the column. */
heading: string;
links: DirectoryFooterLink[];
}
export interface DirectoryFooterSocial {
/**
* What it is. Not shown — it is the link's accessible name, and it is the
* whole of what a screen reader gets, since the mark itself is a glyph.
* The reference ships these with no name at all.
*/
label: string;
href: string;
icon?: "x" | "instagram" | "linkedin" | "github" | "youtube" | "mail";
}
interface Props {
/** The wordmark. */
brand?: string;
/** Where the wordmark goes. Usually home. */
brandHref?: string;
/** A sentence or two under it. `""` drops it. */
blurb?: string;
/** Icon links under the blurb. `[]` drops the row. */
social?: DirectoryFooterSocial[];
/** The link directory. Four columns is what the layout is drawn for. */
columns?: DirectoryFooterColumn[];
/** The line at the foot, left side. */
copyright?: string;
/** Small links beside the copyright. */
legal?: DirectoryFooterLink[];
/** Text for the back-to-top control. `""` drops it. */
backToTopLabel?: string;
/**
* Where back-to-top points.
*
* `"#top"` is deliberate rather than arbitrary: when no element carries
* that id, browsers treat it as the top of the document, so the control
* works in any host without the host adding anything.
*/
backToTopHref?: string;
/** Heading level for the column headings. */
headingLevel?: "h2" | "h3" | "h4";
/** The footer's ground. */
background?: string;
/** Wordmark and link text. */
ink?: string;
/** Blurb, headings, copyright, marks — everything secondary. */
mutedInk?: string;
/** The underline that wipes in under a link on hover and focus. */
accent?: string;
/** The hairline over the bottom bar. */
rule?: string;
/**
* Where the CONTENT stops widening. The ground stays edge to edge; only
* the columns inside are capped and centred, which is what the reference
* does and what keeps a four-column directory from stretching to the far
* corners of a wide monitor.
*/
maxWidth?: string;
/** Added to the root. */
class?: string;
/** Root element id. */
id?: string;
}
/*
* Nothing about the business is assumed: brand, blurb, social marks, the
* directory, copyright and legal links all default to nothing, and a block
* with nothing in it is not drawn. With none of them the footer renders
* nothing — a back-to-top control on its own is not a footer.
*/
const {
brand,
brandHref = "/",
blurb,
social = [],
columns = [],
copyright,
legal = [],
backToTopLabel = "Back to top",
backToTopHref = "#top",
headingLevel = "h2",
background = "#0e0e0e",
ink = "#ffffff",
mutedInk = "rgb(255 255 255 / 0.45)",
accent = "#c44b2b",
rule = "rgb(255 255 255 / 0.12)",
maxWidth = "1280px",
class: className,
id,
} = Astro.props;
const Heading = headingLevel;
/*
* Only schemes that navigate. A footer is copied into other people's projects
* where these often come from a content record rather than from the person
* writing the template.
*/
const SAFE_SCHEME = /^(?:https?:|mailto:|tel:|[#/.?])/i;
const safe = (value?: string) =>
value && SAFE_SCHEME.test(value.trim()) ? value.trim() : undefined;
/* A column with no links is a heading over nothing, so it is dropped. */
const groups = (columns ?? []).filter((c) => c?.heading && c.links?.length);
const marks = (social ?? []).filter((s) => s?.label && safe(s.href));
const smallLinks = (legal ?? []).filter((l) => l?.label && safe(l.href));
const hasBrand = Boolean(brand || blurb || marks.length > 0);
const hasTop = hasBrand || groups.length > 0;
const hasContent = hasTop || Boolean(copyright) || smallLinks.length > 0;
/*
* Icons drawn on a 24 grid at stroke 1.75, inlined so the footer makes no
* request of its own. `x` is a filled glyph rather than a stroked one — the
* mark has no stroked form that reads at 17px.
*/
const GLYPHS: Record<string, { paths: string[]; fill?: boolean; circles?: [number, number, number][] }> = {
x: {
fill: true,
paths: [
"M18.24 2.25h3.31l-7.23 8.26 8.5 11.24h-6.66l-5.22-6.82-5.96 6.82H1.67l7.73-8.84L1.25 2.25h6.83l4.71 6.23zm-1.16 17.52h1.83L7.08 4.13H5.12z",
],
},
instagram: {
paths: ["M3 7.5A4.5 4.5 0 0 1 7.5 3h9A4.5 4.5 0 0 1 21 7.5v9a4.5 4.5 0 0 1-4.5 4.5h-9A4.5 4.5 0 0 1 3 16.5z"],
circles: [
[12, 12, 4],
[17.2, 6.8, 0.6],
],
},
linkedin: {
paths: [
"M4.5 3.5h15A1.5 1.5 0 0 1 21 5v14a1.5 1.5 0 0 1-1.5 1.5h-15A1.5 1.5 0 0 1 3 19V5a1.5 1.5 0 0 1 1.5-1.5z",
"M7.5 10.5v6",
"M11 16.5v-3.4a2.1 2.1 0 0 1 4.2 0v3.4",
"M11 16.5v-6",
],
circles: [[7.5, 7.6, 0.6]],
},
github: {
paths: [
"M9 19.5c-4 1.2-4-2.1-5.6-2.5m11.2 5v-3.2c0-.9.1-1.3-.4-1.8 2.4-.3 4.8-1.2 4.8-5.2a4 4 0 0 0-1.1-2.8 3.7 3.7 0 0 0-.1-2.8s-.9-.3-3 1.1a10.3 10.3 0 0 0-5.4 0C7.3 6.1 6.4 6.4 6.4 6.4a3.7 3.7 0 0 0-.1 2.8 4 4 0 0 0-1.1 2.8c0 4 2.4 4.9 4.8 5.2-.5.5-.5 1-.4 1.8V22",
],
},
youtube: {
paths: [
"M21.6 7.6a2.5 2.5 0 0 0-1.8-1.8C18.2 5.4 12 5.4 12 5.4s-6.2 0-7.8.4A2.5 2.5 0 0 0 2.4 7.6 26 26 0 0 0 2 12a26 26 0 0 0 .4 4.4 2.5 2.5 0 0 0 1.8 1.8c1.6.4 7.8.4 7.8.4s6.2 0 7.8-.4a2.5 2.5 0 0 0 1.8-1.8A26 26 0 0 0 22 12a26 26 0 0 0-.4-4.4z",
"m10.2 15 5.2-3-5.2-3z",
],
},
mail: {
paths: ["M3 6.5A1.5 1.5 0 0 1 4.5 5h15A1.5 1.5 0 0 1 21 6.5v11a1.5 1.5 0 0 1-1.5 1.5h-15A1.5 1.5 0 0 1 3 17.5z", "m3.6 6.7 8.4 6 8.4-6"],
},
};
const style = [
`--dfr-bg:${background}`,
`--dfr-ink:${ink}`,
`--dfr-muted:${mutedInk}`,
`--dfr-accent:${accent}`,
`--dfr-rule:${rule}`,
`--dfr-max:${maxWidth}`,
].join(";");
---
{hasContent && (
<footer class:list={["directory-footer", className]} id={id} style={style} data-directory-footer>
<div class="dfr-inner">
{hasTop && (
<div class="dfr-top">
{hasBrand && (
<div class="dfr-brand">
{brand && (safe(brandHref) ? (
<a class="dfr-word" href={safe(brandHref)}>{brand}</a>
) : (
<span class="dfr-word">{brand}</span>
))}
{blurb && <p class="dfr-blurb">{blurb}</p>}
{marks.length > 0 && (
<ul class="dfr-social">
{marks.map((mark) => {
const glyph = GLYPHS[mark.icon ?? "x"] ?? GLYPHS.x;
return (
<li>
<a class="dfr-mark" href={safe(mark.href)}>
<svg
viewBox="0 0 24 24"
fill={glyph.fill ? "currentColor" : "none"}
stroke={glyph.fill ? "none" : "currentColor"}
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
{glyph.paths.map((d) => <path d={d}></path>)}
{(glyph.circles ?? []).map(([cx, cy, r]) => (
<circle cx={cx} cy={cy} r={r}></circle>
))}
</svg>
{/* The mark is a glyph; this is the whole of its
accessible name. */}
<span class="dfr-a11y">{mark.label}</span>
</a>
</li>
);
})}
</ul>
)}
</div>
)}
{groups.length > 0 && (
<div class="dfr-directory">
{groups.map((group) => {
return (
<nav class="dfr-column" aria-label={group.heading}>
{/*
A real heading, and a nav named the same thing.
Four named sections is what lets a screen-reader
user jump to "Resources" instead of walking
eleven undifferentiated links.
`aria-label` rather than `aria-labelledby`, and
that is a fix rather than a preference: the
labelledby version needed a generated id per
column, and two footers on one page with no `id`
prop between them emitted the same four ids —
duplicate ids, and every nav in the second
footer pointing at a heading in the first. The
fixture masked it by giving its second instance
an id. A label carries no id and cannot collide.
*/}
<Heading class="dfr-heading">{group.heading}</Heading>
<ul class="dfr-links">
{group.links
.filter((link) => link?.label && safe(link.href))
.map((link) => (
<li>
<a class="dfr-link" href={safe(link.href)}>
{link.label}
{/* The wipe. Its own element because a
pseudo-element cannot be transformed
independently of the link's own
focus ring. */}
<span class="dfr-wipe" aria-hidden="true"></span>
</a>
</li>
))}
</ul>
</nav>
);
})}
</div>
)}
</div>
)}
{(copyright || smallLinks.length > 0 || backToTopLabel) && (
<div class="dfr-bottom">
{copyright && <p class="dfr-copy">{copyright}</p>}
{(smallLinks.length > 0 || backToTopLabel) && (
<div class="dfr-tail">
{smallLinks.length > 0 && (
<ul class="dfr-legal">
{smallLinks.map((link) => (
<li>
<a class="dfr-link dfr-link--small" href={safe(link.href)}>
{link.label}
<span class="dfr-wipe" aria-hidden="true"></span>
</a>
</li>
))}
</ul>
)}
{backToTopLabel && (
/*
An anchor, not a button. `#top` is the top of the document
when nothing carries that id, so this needs no script, no
id in the host, and still works with JavaScript off — none
of which is true of the reference's button.
*/
<a class="dfr-top-link" href={safe(backToTopHref) ?? "#top"}>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M12 19V5"></path>
<path d="m5 12 7-7 7 7"></path>
</svg>
<span class="dfr-top-label">
{backToTopLabel}
<span class="dfr-wipe" aria-hidden="true"></span>
</span>
</a>
)}
</div>
)}
</div>
)}
</div>
</footer>
)}
<style>
.directory-footer {
/* ---- measured from the reference at a 1280px content width ---- */
--dfr-pad-block: 64px;
--dfr-pad-inline: 48px;
--dfr-brand-width: 280px;
--dfr-column-gap: 48px;
/*
* The column's rhythm is a PITCH, not a gap, and the pitch is the
* measurement: read off the live reference, one link's text sits 30px
* below the last, and the first sits 16px below the heading's text.
*
* The reference gets that from an 18px link box in a 12px gap. The box
* here is 26.2 because the tap padding lives inside it, so the gap is
* whatever the pitch has left over — and the two ends hand their
* padding back as negative margin on .dfr-links. The text therefore
* lands exactly where the reference's does while the targets are half
* as tall again.
*
* Deriving the gap rather than writing 3.8px means changing the font
* size or the tap padding keeps the rhythm instead of breaking it.
*/
--dfr-link-pitch: 30px;
--dfr-link-tap: 4px;
--dfr-link-box: calc(var(--dfr-link-size) * 1.2 + var(--dfr-link-tap) * 2 + 2px);
--dfr-link-gap: calc(var(--dfr-link-pitch) - var(--dfr-link-box));
--dfr-word-size: 1.0625rem;
--dfr-blurb-size: 0.8125rem;
--dfr-heading-size: 0.6875rem;
--dfr-link-size: 0.84375rem;
--dfr-small-size: 0.75rem;
/*
* 300ms on cubic-bezier(0.16, 1, 0.3, 1) — read straight off the
* reference's own inline style, and a curve this library already uses
* in fifteen other places.
*/
--dfr-duration: 300ms;
--dfr-ease: cubic-bezier(0.16, 1, 0.3, 1);
/*
* The query container. Every breakpoint below is a share of the
* footer's own width rather than the window's, so it lays out
* correctly inside a constrained shell as well as edge to edge.
*/
container-type: inline-size;
box-sizing: border-box;
display: block;
background: var(--dfr-bg);
color: var(--dfr-ink);
/* A system stack on purpose: a component in this library fetches
nothing, so it can never be waiting on a webfont. */
font-family:
system-ui,
-apple-system,
"Segoe UI",
Roboto,
Helvetica,
Arial,
sans-serif;
}
.directory-footer *,
.directory-footer *::before,
.directory-footer *::after {
box-sizing: border-box;
}
/*
* A scoped reset, and it earns its place in a footer more than anywhere
* else: this is the one component guaranteed to be installed into someone
* else's global stylesheet, where `ul`, `li`, `a` and `h2` all already
* have opinions. `:where()` keeps it at the specificity of the root alone,
* so the rules below still win on source order while a host's element
* selector loses.
*
* The `:global()` is load-bearing. Astro cannot put its scope attribute
* inside `:where()`, so it adds a second one beside it and the emitted
* selector lands a class higher than the class rules it is supposed to
* lose to.
*/
.directory-footer :global(:where(h1, h2, h3, h4, h5, h6, p, ul, ol, li, a, span, nav)) {
margin: 0;
padding: 0;
border: 0;
background: none;
font: inherit;
color: inherit;
letter-spacing: inherit;
text-align: inherit;
text-transform: none;
list-style: none;
}
.dfr-inner {
/* The ground is full-bleed; the content is not. The cap includes the
side padding, so 1280 here is the reference's own 1280-wide footer
with 1184 of content inside it. */
max-inline-size: var(--dfr-max);
margin-inline: auto;
padding: var(--dfr-pad-block) var(--dfr-pad-inline) 0;
}
/* ---- the top half ---------------------------------------------------- */
.dfr-top {
display: flex;
justify-content: space-between;
gap: 40px;
}
.dfr-brand {
flex: 0 0 auto;
inline-size: var(--dfr-brand-width);
max-inline-size: 100%;
}
.dfr-word {
display: inline-block;
/* The last target under 24px once the directory links were fixed: the
wordmark read 20.4. Padded out and pulled back in, so the brand
block is laid out exactly as before. */
padding-block: var(--dfr-link-tap);
margin-block: calc(var(--dfr-link-tap) * -1);
color: var(--dfr-ink);
font-size: var(--dfr-word-size);
font-weight: 400;
/* Measured at -0.17px on a 17px face, which is -0.01em. */
letter-spacing: -0.01em;
line-height: 1.2;
text-decoration: none;
}
.dfr-blurb {
margin-block-start: 16px;
color: var(--dfr-muted);
/* The blurb is the one place the reference runs a lighter ink than the
rest of the muted text: 0.6 against 0.45. */
color: color-mix(in srgb, var(--dfr-ink) 60%, transparent);
font-size: var(--dfr-blurb-size);
line-height: 1.6;
}
.dfr-social {
display: flex;
gap: 14px;
margin-block-start: 20px;
}
.dfr-mark {
display: flex;
align-items: center;
justify-content: center;
/*
* 25px in the reference, which is under the 44px a touch target wants.
* The box is grown to 40px here and the glyph kept at 17, so the mark
* looks the same and can actually be hit — the row is only 3px taller
* for it because the icons were already 25 tall inside a 25 box.
*/
inline-size: 40px;
block-size: 40px;
margin: -7.5px;
color: var(--dfr-muted);
border-radius: 999px;
transition: color var(--dfr-duration) var(--dfr-ease);
}
.dfr-mark svg {
inline-size: 17px;
block-size: 17px;
}
.dfr-mark:hover,
.dfr-mark:focus-visible {
color: var(--dfr-ink);
}
/* ---- the directory --------------------------------------------------- */
.dfr-directory {
display: grid;
/*
* `minmax` rather than the reference's fixed tracks, and it is the one
* layout departure worth naming: at 834px the reference squeezes these
* to 78.6px, which is narrower than the word "Documentation" and wraps
* it. A floor of 5.5rem keeps a column readable, and the grid drops to
* two columns rather than crushing four.
*/
grid-template-columns: repeat(4, minmax(5.5rem, auto));
gap: var(--dfr-column-gap);
}
.dfr-heading {
color: var(--dfr-muted);
font-size: var(--dfr-heading-size);
font-weight: 400;
/* 1.65px on an 11px face. */
letter-spacing: 0.15em;
line-height: 1.1;
text-transform: uppercase;
}
.dfr-links {
display: flex;
flex-direction: column;
gap: var(--dfr-link-gap);
/*
* Both ends give the tap padding back, so the list occupies exactly
* the reference's 78px: 16px from the heading's text to the first
* link's text, and nothing hanging below the last one.
*
* 17.5 rather than 16 because the heading sets line-height 1.1 where
* the reference leaves it normal, which pulls its text box up by about
* 1.5px. Measured text-to-text rather than box-to-box, on both, this
* is what puts the first link 16px under the heading.
*/
margin-block-start: calc(17.5px - var(--dfr-link-tap));
margin-block-end: calc(var(--dfr-link-tap) * -1);
}
/*
* The list item hugs its anchor. Left as a list-item it is 22.2px tall
* around an 18.2px anchor, and the 4px difference is a strip that looks
* like part of the link and is not clickable. Collapsing it moves that
* space inside the target.
*/
.dfr-links > li {
display: flex;
}
/* ---- links and the wipe ---------------------------------------------- */
/*
* Measured at 390px, a directory link was a 16.4px-tall target — under the
* 24px floor, and the smallest thing in the footer by some way. The social
* marks were already widened to 40px and back-to-top already carried its
* own padding; these were the ones left. The padding grows the box to
* 26.2px and the list gap gives back exactly what the padding takes, so
* nothing moves on screen.
*/
.dfr-link {
position: relative;
display: inline-flex;
inline-size: fit-content;
padding-block: var(--dfr-link-tap) calc(var(--dfr-link-tap) + 2px);
color: var(--dfr-ink);
font-size: var(--dfr-link-size);
line-height: 1.2;
text-decoration: none;
}
.dfr-link--small {
/*
* Same target, no effect on the bottom bar's height: the padding is
* given back as negative margin, so the box overlaps its neighbours
* rather than pushing them. The same trick the social marks use.
*/
margin-block: calc(var(--dfr-link-tap) * -1) calc(var(--dfr-link-tap) * -1);
color: var(--dfr-muted);
font-size: var(--dfr-small-size);
transition: color var(--dfr-duration) var(--dfr-ease);
}
.dfr-link--small:hover,
.dfr-link--small:focus-visible {
color: var(--dfr-ink);
}
.dfr-wipe {
position: absolute;
inset-inline: 0;
/* Rides at the foot of the TEXT, not of the padded box, so the
underline sits 2px under the words as it did before the box grew. */
inset-block-end: var(--dfr-link-tap);
block-size: 1px;
background: var(--dfr-accent);
transform: scaleX(0);
transform-origin: left;
transition: transform var(--dfr-duration) var(--dfr-ease);
}
.dfr-link:hover .dfr-wipe,
.dfr-link:focus-visible .dfr-wipe,
.dfr-top-link:hover .dfr-wipe,
.dfr-top-link:focus-visible .dfr-wipe {
transform: scaleX(1);
}
/* ---- the bottom bar -------------------------------------------------- */
.dfr-bottom {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
margin-block-start: 56px;
padding-block: 24px;
border-block-start: 1px solid var(--dfr-rule);
}
.dfr-copy {
color: var(--dfr-muted);
font-size: var(--dfr-small-size);
line-height: 1.2;
}
.dfr-tail {
display: flex;
align-items: center;
gap: 32px;
}
.dfr-legal {
display: flex;
gap: 24px;
}
.dfr-top-link {
position: relative;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 6px;
margin: -4px -6px;
color: var(--dfr-muted);
font-size: var(--dfr-small-size);
line-height: 1.2;
text-decoration: none;
border-radius: 4px;
transition: color var(--dfr-duration) var(--dfr-ease);
}
.dfr-top-link:hover,
.dfr-top-link:focus-visible {
color: var(--dfr-ink);
}
.dfr-top-link svg {
inline-size: 11px;
block-size: 11px;
flex: 0 0 auto;
}
.dfr-top-label {
position: relative;
padding-block-end: 2px;
}
/* ---- focus ----------------------------------------------------------- */
/*
* Named on the classes rather than through `:where(a)`, and that is a fix.
* Astro cannot put its scope attribute inside `:where()`, so it adds a
* second one beside it and the emitted selector stops matching the anchor
* — measured, the focus ring was `medium none currentColor`, which is to
* say the initial value and no ring at all. The same trap is written up
* on the reset above; this rule walked into it anyway.
*/
.dfr-word:focus-visible,
.dfr-mark:focus-visible,
.dfr-link:focus-visible,
.dfr-top-link:focus-visible {
outline: 2px solid var(--dfr-accent);
outline-offset: 3px;
border-radius: 2px;
}
/* ---- visually hidden, still announced -------------------------------- */
.dfr-a11y {
position: absolute;
inline-size: 1px;
block-size: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
/* ---- narrow ----------------------------------------------------------
*
* The reference switches at a window width of 810. This switches on the
* footer's own width instead, and in two steps rather than one.
*
* The first step is not a taste judgement, it is arithmetic. Side by side
* the row cannot go below 912px: 48px of padding either side, a 280px
* brand, the 40px that separates it from the directory, and a directory
* that is rigid at 496 (four 88px tracks and three 48px gaps, and the
* tracks hold that floor rather than crushing). Under 912 the directory
* starts eating its own gutter; under about 864 it runs off the viewport,
* the gap between the two being the end-side padding, which Chromium
* leaves out of scrollable overflow. Measured at 834: 30px overhang.
*
* So the brand moves above the directory at 58rem, with 16px to spare.
* Only that — the padding stays wide and the directory stays at four
* columns, because stacked at 900px it has 800px to put them in. Going
* straight to the narrow treatment here would give a tablet 24px margins
* and two columns it has no need of.
*/
@container (max-width: 58rem) {
.dfr-top {
flex-direction: column;
gap: 40px;
}
.dfr-brand {
inline-size: 100%;
}
}
/*
* The second step is the narrow one: four columns need 496px plus padding,
* so below about 45rem they have to halve.
*/
@container (max-width: 44rem) {
/*
* Set on .dfr-inner, and that is not a style preference. An element is
* not matched by its own container query — a query container resolves
* against its nearest ANCESTOR container, so a `.directory-footer`
* rule in here addresses an outer container that does not exist and
* never applies. Written on the root first, it silently did nothing:
* measured at 320px, the padding was still the wide 48px. The tokens
* belong on the element that consumes them.
*/
.dfr-inner {
--dfr-pad-block: 48px;
--dfr-pad-inline: 24px;
}
.dfr-directory {
/* Two columns, as the reference does at its own narrow size. */
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 32px 24px;
}
.dfr-bottom {
flex-direction: column;
align-items: flex-start;
gap: 16px;
margin-block-start: 40px;
}
.dfr-tail {
flex-wrap: wrap;
gap: 16px 24px;
}
}
/* One column is the floor: below this a two-column grid gives each link
about 9rem, which "Documentation" does not fit in. */
@container (max-width: 22rem) {
.dfr-directory {
grid-template-columns: minmax(0, 1fr);
}
}
/* ---- preferences ----------------------------------------------------- */
@media (prefers-reduced-motion: reduce) {
/*
* One token, because every transition in the file resolves through it.
* The wipe still appears on hover and focus — it simply arrives rather
* than travelling, which keeps the affordance and drops the movement.
*/
.directory-footer {
--dfr-duration: 0.01ms;
}
}
@media (forced-colors: active) {
/* The hairline and the wipe are both colour on colour, and neither
survives a forced palette; they take system colours instead. */
.dfr-bottom {
border-block-start-color: CanvasText;
}
.dfr-wipe {
background: LinkText;
}
.dfr-word:focus-visible,
.dfr-mark:focus-visible,
.dfr-link:focus-visible,
.dfr-top-link:focus-visible {
outline-color: Highlight;
}
}
</style>
===== END src/components/DirectoryFooter.astro =====
===== FILE (supporting): src/components/DirectoryFooter.md =====
# Directory footer
A full-bleed dark site footer: a brand block with a blurb and social marks on
one side, a directory of link columns on the other, then a hairline and a
bottom bar carrying the copyright, the legal links and a back-to-top control.
Catalog page: `/components/directory-footer`
## Copying it into another Astro project
Copy **`DirectoryFooter.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 `--dfr-*` property on the root |
| Webfonts | none — system stack |
| Images, logos, icon sets | **none** — the marks are inline `<svg>` paths |
| **JavaScript** | **none** — see below |
| Network requests | none |
| Host requirement | Astro compiles `.astro` files |
**There is no script**, and the built page contains zero `<script>` elements
from this component. Everything that moves is a CSS state, so there is nothing
to hydrate, nothing that can fail to boot, and nothing a client-side router
can strand.
### Exact steps
1. Copy `DirectoryFooter.astro` into your components folder, e.g.
`src/components/DirectoryFooter.astro`.
2. Import it in your layout, at the end, **after `<slot />` and outside
`<main>`**:
```astro
---
import DirectoryFooter from "../components/DirectoryFooter.astro";
---
<body>
<main><slot /></main>
<DirectoryFooter
brand="Your Company"
columns={[
{ heading: "Product", links: [{ label: "Pricing", href: "/pricing" }] },
]}
/>
</body>
```
3. Pass your own content. Nothing about the business is assumed: brand,
blurb, social marks, columns, copyright and legal links all default to
empty, and a block with nothing in it is not drawn. With none of them the
component renders nothing. The "Northbound" demo in the catalog preview is
passed from the catalog registry and does not travel with the file.
The root **is** the `<footer>` element. At the top level of a page that is the
`contentinfo` landmark, so do not wrap it in another one.
## Sibling components
[Panel footer](./PanelFooter.md) is the same furniture drawn as an inset
rounded panel with a contact column and a call to action. Use this one when
the footer should read as the page's ground rather than as a card on it, and
when the job is a directory of links rather than a route to contact.
## Props
| Prop | Type | Default | What it does |
| :--- | :--- | :--- | :--- |
| `brand` | `string` | — | The wordmark. Omitted hides it. |
| `brandHref` | `string` | `"/"` | Where the wordmark goes. |
| `blurb` | `string` | — | The line under it. Omitted or `""` drops it. |
| `social` | `DirectoryFooterSocial[]` | `[]` | Icon links under the blurb. `[]` drops the row. |
| `columns` | `DirectoryFooterColumn[]` | `[]` | The directory. Four is what the layout is drawn for. |
| `copyright` | `string` | — | Left side of the bottom bar. Never generated; omitted hides it. |
| `legal` | `DirectoryFooterLink[]` | `[]` | Small links beside it. |
| `backToTopLabel` | `string` | `"Back to top"` | `""` drops the control. |
| `backToTopHref` | `string` | `"#top"` | Where it points. See below. |
| `headingLevel` | `"h2" \| "h3" \| "h4"` | `"h2"` | Level for the column headings. |
| `background` | `string` | `"#0e0e0e"` | The footer's ground, edge to edge. |
| `ink` | `string` | `"#ffffff"` | Wordmark and link text. |
| `mutedInk` | `string` | `rgb(255 255 255 / 0.45)` | Blurb, headings, copyright, marks. |
| `accent` | `string` | `"#c44b2b"` | The underline that wipes in, and the focus ring. |
| `rule` | `string` | `rgb(255 255 255 / 0.12)` | The hairline over the bottom bar. |
| `maxWidth` | `string` | `"1280px"` | Where the **content** stops widening. The ground stays full-bleed. |
| `class` | `string` | — | Added to the root. |
| `id` | `string` | — | Root element id. |
### The types
```ts
interface DirectoryFooterLink {
label: string;
href: string;
}
interface DirectoryFooterColumn {
heading: string; // the small capitalised label over the column
links: DirectoryFooterLink[];
}
interface DirectoryFooterSocial {
label: string; // NOT shown — it is the accessible name
href: string;
icon?: "x" | "instagram" | "linkedin" | "github" | "youtube" | "mail";
}
```
`label` on a social mark is the whole of what a screen reader gets, because
the mark itself is a glyph. Write it as a destination — `"Northbound on X"`,
not `"X"`.
A column with no links is a heading over nothing, so it is dropped. An `href`
that is not `http:`, `https:`, `mailto:`, `tel:` or a path/fragment is dropped
too — a footer gets copied into projects where these come from a content
record rather than from the person writing the template.
## Back to top is an anchor
`backToTopHref` defaults to `#top`, and when **no element on the page carries
that id** browsers resolve it to the top of the document. So the control:
- needs no script;
- needs no `id` added to your layout;
- works with JavaScript disabled;
- can be middle-clicked, opened in a new tab, or copied.
None of that is true of the reference's, which is a `<button>`.
If you want it to glide rather than jump, that is `scroll-behavior: smooth` on
**your** root element, guarded by `prefers-reduced-motion` — it is a host
decision, not a component one, because it affects every in-page link you have.
## Layout
The ground is edge to edge; only the content inside is capped at `maxWidth`
and centred. At 1440px that is a 1280-wide content column with 48px of side
padding and 64px above.
The rhythm inside a directory column is a **pitch**, not a gap: 16px from a
heading's text to its first link's text, then 30px from one link to the next.
That is measured off the reference. Because the links carry vertical padding
for the sake of their hit targets, the gap is derived rather than written:
```css
--dfr-link-gap: calc(var(--dfr-link-pitch) - var(--dfr-link-box));
```
Change the link size or the tap padding and the rhythm survives.
### Responsive
Laid out by **container query**, not media query, so it lays out correctly
inside a constrained shell as well as edge to edge. Three steps:
| Container width | What happens |
| :--- | :--- |
| above `58rem` | Brand beside the directory, four columns, 48px padding |
| `44rem`–`58rem` | Brand **above** the directory, still four columns, still 48px |
| `22rem`–`44rem` | Two columns, padding drops to 24px, the bottom bar stacks |
| below `22rem` | One column |
The first step is arithmetic rather than taste. Side by side the row cannot go
below **912px**: 96px of padding, a 280px brand, 40px between it and the
directory, and a directory that is rigid at 496 — four 88px tracks and three
48px gaps, and the tracks hold that floor rather than crushing. So the brand
moves above the directory at 58rem, with 16px to spare.
Because the breakpoints are in `rem`, text-only zoom walks the layout down
this ladder instead of overflowing it.
## Behaviour and accessibility
- **Each column is its own `<nav>`**, labelled by its heading. A screen-reader
user can reach *Resources* without walking every link in the footer. It does
mean the footer contributes several navigation landmarks — deliberate, but a
host that counts them should expect it.
- The labels are on `aria-label`, not `aria-labelledby`. Two instances without
an explicit `id` would otherwise emit the same heading ids twice.
- **Every link is an ordinary anchor in document order.** Nothing is hidden
from the tab order and nothing is pointer-only.
- The focus ring is the accent at 2px with 3px of offset, and **focus raises
the same underline hover does**.
- **Nothing is under a 24px target.** Links carry vertical padding, which is
handed back at both ends of each list as negative margin, so the targets
grow without anything moving on screen. The social marks are 40px boxes
around 17px glyphs by the same trick.
- `prefers-reduced-motion: reduce` collapses the one duration token to
0.01ms. The underline still appears under a hovered or focused link — it
arrives rather than travelling, which keeps the affordance and drops the
movement. Nothing here rests at zero opacity, so nothing goes missing.
- `forced-colors: active` takes `Canvas`, `CanvasText`, `LinkText` and
`Highlight`. The rule and the underline are colour on colour and neither
survives a forced palette on its own.
- `@media print` drops the ground to white and the ink to black.
## Two traps this component walked into
Both are worth knowing if you edit it, because neither fails loudly.
**Astro's scope attribute cannot go inside `:where()`.** The focus ring was
written as `.directory-footer :where(a):focus-visible`; Astro adds a second
scope attribute beside the `:where()` and the selector stops matching. There
was no error — the ring simply computed as `medium none currentColor`, which
is to say no ring at all. Name the classes directly.
**An element is not matched by its own container query.** A query container
resolves against its nearest *ancestor* container, so
`@container { .directory-footer { --dfr-pad-inline: 24px } }` addresses an
outer container that does not exist. The narrow padding silently never
applied. Custom properties that a container query changes belong on a
**descendant** — here, `.dfr-inner`.
## Relationship to the reference
Rebuilt from a live page by measurement. No markup, CSS or copy was taken from
it: the demo text, the brand name and all four column headings are original to
this library, and the icons are drawn here.
Measured against the reference at 1440px: content 1280 wide, brand column 280,
directory 500 across four tracks at 48px gaps, bottom bar 71px tall, total
height **336px against its 336px**, and a 30px link pitch matching exactly.
Three deliberate departures, all of them improvements:
1. The reference's four tracks are fixed and squeeze to 78.6px at an 834px
window — narrower than the word *Documentation*, which wraps. Here the
tracks have a floor and the layout changes shape instead.
2. Its social marks are 25px hit targets and carry no accessible name at all.
3. Its back-to-top is a `<button>` that needs JavaScript.
===== END src/components/DirectoryFooter.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.