Component library
Back to catalog

Directory footer

A full-bleed dark site footer: a brand block with a blurb and social marks beside a directory of link columns, over a bottom bar carrying the copyright, the legal links and a back-to-top control.

footernavigationlinksdark

Open preview in a new tab

Use this component

Three ways to get Directory footer into one of your own projects. All pre-filled with this component's name and library page.

Or, for the design alone

Placement prompt

What to do, where it goes and what must not change. Paste it into Claude Code inside the destination project.

Read it first
Add Directory footer from my Astro Component Library to this Astro project.

Library page, for reference only:
https://astro.baysixmedia.com/components/directory-footer/

Place it in:
[ENTER PAGE, FILE, OR ROUTE]

Position:
[ENTER WHERE IT SHOULD APPEAR]

First read the component source, README, documented props, dependencies, and required assets from the library page. Copy the component and every required companion asset locally into this project. Do not import files from the deployed library at runtime.

Adapt its demo content, links, colors, typography, and images to this project through documented props where possible. Preserve its responsive behavior, accessibility, interaction logic, reduced-motion behavior, and support for multiple instances. Avoid changing unrelated project code.

Test it at desktop, tablet, and mobile widths. Run the project's available check and build commands. Report the files changed and any differences from the library version. Do not commit or deploy.

--- 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.

Component reference

A fragment for a request you write yourself, e.g. “I want to add [paste] to Section 3 of my homepage.” It names the component, links its page, and fixes what must not change — the design build and its effects — while leaving branding and content open. It does not carry the code.

Read it first
“Directory footer” from my Astro Component Library:
https://astro.baysixmedia.com/components/directory-footer/

Use the existing library component as the implementation source. Preserve its design structure, layout, proportions, spacing system, responsive behavior, visual effects, animations, transitions, hover states, interactions, and accessibility.

The copy, colors, typography, images, and other content may be adapted to match the destination website. Do not redesign, simplify, or reinterpret the component’s underlying design build or effects.

src/components/library/DirectoryFooter.astro

Installing this one is more than pasting a section

  • installOne required file, plus supporting files worth taking:
    • src/components/DirectoryFooter.astrocomponentrequired
    • src/components/DirectoryFooter.mdreadmesupporting
    Use Copy header kit above to get them all, each one labelled with the path it belongs at.
---
/**
 * 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>