Component library
Back to catalog

Centered logo footer

A site footer with a centred brand mark over one row of links, a dashed hairline, and a bottom row carrying the copyright opposite a set of labelled icon links.

footernavlinksbrandstatic

Open preview in a new tab

Use this component

Three ways to get Centered logo footer into one of your own projects. The first two carry the component's files with them, so nothing has to fetch this page.

Start here

Placement prompt

The whole thing: what to do, where it goes, what must not change, and every one of the component's 1 file appended underneath with the path each belongs at. Paste it into Claude Code inside the destination project — it fetches nothing and needs nothing else.

Read it first — the files are added underneath when you copy
Add Centered logo footer from my Astro Component Library to this Astro project.

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

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

Position:
[ENTER WHERE IT SHOULD APPEAR]

Every file you need is appended below, each fenced with the path it belongs at. Write them there, relative to the project root. Do not import anything from the deployed library at runtime, and do not fetch the library page — the files below are the source of truth.

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.

Install kit

The 1 file this component needs and nothing else, each fenced with the path it belongs at, followed by what the library's own verification covered and what it did not. No instructions attached — for placing it yourself, or handing it to something that has its own.

Read it first
Install kit: "Centered logo footer"
Library page: https://astro.baysixmedia.com/components/centered-logo-footer/
Files: 1 required, 1 supporting

Write each block below to the path named in its header, relative to the project root. A block marked (required) is one the component does not work without; (supporting) is documentation. Nothing here needs downloading except a block that says so in as many words.

===== FILE (required): src/components/CenteredLogoFooter.astro =====
---
/**
 * Centered logo footer
 *
 * A site footer that stacks a brand mark, a single row of links, a hairline
 * rule and a bottom row carrying the copyright and a set of small icon links.
 * Everything above the rule is centred; the row below it sits shoulder to
 * shoulder on desktop and stacks on a phone.
 *
 * Static: no script at all. Every interactive part is a real link, so the
 * whole thing works with JavaScript switched off and there is nothing for a
 * client-side router to tear down or re-initialise.
 *
 * Self-contained: no imports, no packages, no global stylesheet, no webfont.
 * Every colour, size and duration is a `--clf-*` custom property on the root.
 */

export interface FooterLink {
	/** The visible word. */
	label: string;
	/** Where it goes. Anything that is not a navigable scheme is dropped. */
	href: string;
}

export interface FooterSocialLink {
	/**
	 * The accessible name — "Email", "RSS". Not shown, and it is the ONLY
	 * thing a screen reader gets for an icon link, so it has to stand alone.
	 */
	label: string;
	href: string;
	/** Which glyph. See GLYPHS below; add to that map to extend the set. */
	icon: "mail" | "link" | "rss" | "chat" | "phone" | "pin";
}

interface Props {
	/** The wordmark beside the brand mark. `""` renders the mark alone. */
	brand?: string;
	/** Where the brand mark links. */
	brandHref?: string;
	/**
	 * An image to use instead of the built-in mark. Square; anything else is
	 * letterboxed into the same box. Left unset the component draws its own,
	 * so the footer renders correctly with no assets to install.
	 */
	logoSrc?: string;
	/** The row of links. */
	links?: FooterLink[];
	/** Accessible name for the link row, since a page may have several navs. */
	navLabel?: string;
	/** The line under the rule. No default: omitted, the line is not drawn. */
	copyright?: string;
	/** Small icon links on the other end of the bottom row. */
	social?: FooterSocialLink[];
	/** Page surface the footer paints, edge to edge. */
	background?: string;
	/** The wordmark and the brand mark. */
	ink?: string;
	/** The link row at rest. */
	linkColor?: string;
	/** The link row under a pointer or focus. */
	linkHoverColor?: string;
	/** Copyright and icon links. */
	mutedColor?: string;
	/** The hairline across the top of the footer. */
	borderColor?: string;
	/** The dashed rule between the two halves. */
	ruleColor?: string;
	/** Focus ring. */
	accent?: string;
	/** Content width before the gutters. */
	maxWidth?: string;
	/** Added to the root. */
	class?: string;
	/** Root element id. */
	id?: string;
}

/*
 * Nothing about the business is assumed: brand, links, copyright and icon
 * links all default to nothing, and a half with nothing in it is not drawn.
 * Pass nothing at all and the footer renders nothing. The copyright is never
 * generated — pass the line you want.
 */
const {
	brand,
	brandHref = "/",
	logoSrc,
	links = [],
	navLabel = "Footer",
	copyright,
	social = [],
	background = "#ffffff",
	ink = "#0a0a0a",
	linkColor = "#525252",
	linkHoverColor = "#171717",
	mutedColor = "#737373",
	borderColor = "#f5f5f5",
	ruleColor = "rgb(0 0 0 / 0.2)",
	accent = "#0a0a0a",
	maxWidth = "1280px",
	class: className,
	id,
} = Astro.props;

/*
 * Only schemes that navigate. A footer is copied into other people's projects
 * where the hrefs often come from a CMS record rather than from the person
 * writing the template.
 */
const SAFE_SCHEME = /^(?:https?:|mailto:|tel:|[#/.?])/i;
const safeHref = (value?: string) =>
	value && SAFE_SCHEME.test(value.trim()) ? value.trim() : undefined;

const navLinks = links
	.map((link) => ({ ...link, href: safeHref(link.href) }))
	.filter((link): link is FooterLink => Boolean(link.href && link.label));

const socialLinks = social
	.map((item) => ({ ...item, href: safeHref(item.href) }))
	.filter((item): item is FooterSocialLink => Boolean(item.href && item.label));

const brandLink = safeHref(brandHref);
const logo = safeHref(logoSrc);

/*
 * The brand block renders only when asked for: a brand string (`""` is the
 * documented way to show the generic built-in mark alone) or a logo. Omitted,
 * there is no brand block at all and no name is invented.
 */
const hasBrand = typeof brand === "string" || Boolean(logo);
const hasTop = hasBrand || navLinks.length > 0;
const hasBottom = Boolean(copyright || socialLinks.length > 0);

/*
 * Inlined rather than fetched, so the footer makes no request of its own.
 * Drawn on a 24 grid at stroke 2, and deliberately generic: this library does
 * not ship other companies' trademarks, so there are no network logos here.
 * Copy this file into your project and add to the map to use your own.
 */
const GLYPHS: Record<string, string[]> = {
	mail: [
		"M3 7.5A2.5 2.5 0 0 1 5.5 5h13A2.5 2.5 0 0 1 21 7.5v9a2.5 2.5 0 0 1-2.5 2.5h-13A2.5 2.5 0 0 1 3 16.5z",
		"m3.5 7 8.5 6 8.5-6",
	],
	link: [
		"M10.5 13.5a4 4 0 0 0 5.66 0l3-3a4 4 0 0 0-5.66-5.66l-1.5 1.5",
		"M13.5 10.5a4 4 0 0 0-5.66 0l-3 3a4 4 0 1 0 5.66 5.66l1.5-1.5",
	],
	rss: ["M5 19a1 1 0 1 0 0-2 1 1 0 0 0 0 2z", "M5 12a7 7 0 0 1 7 7", "M5 5a14 14 0 0 1 14 14"],
	chat: ["M21 11.5a8 8 0 0 1-8 8H8l-5 3 1.2-4.3A8 8 0 0 1 13 3.5a8 8 0 0 1 8 8z"],
	phone: [
		"M6.5 3h3l1.5 4-2 1.5a12 12 0 0 0 5.5 5.5L16 12l4 1.5v3a2 2 0 0 1-2.2 2A16.5 16.5 0 0 1 4.5 5.2 2 2 0 0 1 6.5 3z",
	],
	pin: ["M19 10.5c0 5-7 11-7 11s-7-6-7-11a7 7 0 0 1 14 0z", "M12 13a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5z"],
};

const style = [
	`--clf-bg:${background}`,
	`--clf-ink:${ink}`,
	`--clf-link:${linkColor}`,
	`--clf-link-hover:${linkHoverColor}`,
	`--clf-muted:${mutedColor}`,
	`--clf-border:${borderColor}`,
	`--clf-rule:${ruleColor}`,
	`--clf-accent:${accent}`,
	`--clf-max:${maxWidth}`,
].join(";");
---

{(hasTop || hasBottom) && (
<footer
	class:list={["centered-logo-footer", className]}
	id={id}
	style={style}
	data-centered-logo-footer
>
	<div class="clf-inner">
		{hasTop && (
		<div class="clf-top">
			{hasBrand && (
			<a class="clf-brand" href={brandLink}>
				{logo ? (
					<img class="clf-mark" src={logo} alt="" width="60" height="60" loading="lazy" decoding="async" />
				) : (
					/*
					  An original mark, drawn here rather than shipped as a file,
					  so the component installs as one .astro and still renders.
					*/
					<svg class="clf-mark" viewBox="0 0 32 32" fill="none" aria-hidden="true">
						<circle cx="16" cy="16" r="13" stroke="currentColor" stroke-width="3"></circle>
						<circle cx="16" cy="16" r="4.5" fill="currentColor"></circle>
					</svg>
				)}
				{brand && <span class="clf-wordmark">{brand}</span>}
			</a>
			)}

			{navLinks.length > 0 && (
				<nav class="clf-nav" aria-label={navLabel}>
					<ul class="clf-links">
						{navLinks.map((link) => (
							<li>
								<a class="clf-link" href={link.href}>{link.label}</a>
							</li>
						))}
					</ul>
				</nav>
			)}
		</div>
		)}

		{/*
		  The rule. Dashes come from a repeating gradient rather than
		  `border-style: dashed`, because a dashed border cannot control dash
		  length, and the reference's is 2.5px on, 2.5px off. The fade at both
		  ends is one mask; the reference stacks three and composites them with
		  `exclude`, which is the same picture through more machinery.
		*/}
		{hasTop && hasBottom && <div class="clf-rule" aria-hidden="true"></div>}

		{hasBottom && (
		<div class="clf-bottom">
			{copyright && <p class="clf-copy">{copyright}</p>}

			{socialLinks.length > 0 && (
				<ul class="clf-social">
					{socialLinks.map((item) => (
						<li>
							<a class="clf-social-link" href={item.href}>
								<svg
									class="clf-social-icon"
									viewBox="0 0 24 24"
									fill="none"
									stroke="currentColor"
									stroke-width="2"
									stroke-linecap="round"
									stroke-linejoin="round"
									aria-hidden="true"
								>
									{(GLYPHS[item.icon] ?? GLYPHS.link).map((d) => <path d={d}></path>)}
								</svg>
								{/* The whole accessible name. An icon link with no text is
								    an unnamed link, which is what the reference ships. */}
								<span class="clf-a11y">{item.label}</span>
							</a>
						</li>
					))}
				</ul>
			)}
		</div>
		)}
	</div>
</footer>
)}

<style>
	.centered-logo-footer {
		--clf-pad-block: 80px;
		--clf-pad-inline: 32px;
		--clf-gutter: 32px;
		/* Matches the gutter wherever the gutter exists, so the rule always
		   reaches the container edge and never past it. */
		--clf-rule-bleed: 0px;
		--clf-gap: 16px;
		--clf-mark: 30px;
		--clf-icon: 24px;
		--clf-text: 0.875rem;
		--clf-line: 1.4286;
		--clf-duration: 150ms;
		--clf-ease: cubic-bezier(0.4, 0, 0.2, 1);

		box-sizing: border-box;
		display: block;
		inline-size: 100%;
		/* The rule is masked and can reach past the content box; nothing should
		   ever be able to widen the page from in here. */
		overflow: hidden;
		padding: var(--clf-pad-block) var(--clf-pad-inline);
		border-block-start: 1px solid var(--clf-border);
		background: var(--clf-bg);
		color: var(--clf-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;
	}

	.centered-logo-footer *,
	.centered-logo-footer *::before,
	.centered-logo-footer *::after {
		box-sizing: border-box;
	}

	/*
	 * A scoped reset, and it earns its place. Everything below sets type on
	 * classes, which beats inheritance — but it does not beat a host rule
	 * aimed at the bare element, and a footer lands at the bottom of pages
	 * that style `ul`, `li`, `p` and `a` globally more often than not.
	 *
	 * `:where()` keeps this at the specificity of the root alone, so the rules
	 * underneath 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 without it the compiler emits a second
	 * attribute beside it and the whole thing outranks the class rules.
	 */
	.centered-logo-footer :global(:where(p, ul, li, a, span, nav, img, svg)) {
		margin: 0;
		padding: 0;
		border: 0;
		font: inherit;
		color: inherit;
		text-decoration: none;
		list-style: none;
	}

	.clf-inner {
		max-inline-size: var(--clf-max);
		margin-inline: auto;
	}

	/* ---- the centred half -------------------------------------------- */

	.clf-top {
		display: flex;
		flex-direction: column;
		align-items: center;
		justify-content: center;
	}

	.clf-brand {
		display: inline-flex;
		align-items: center;
		gap: 8px;
		margin-block-end: var(--clf-gap);
		padding: 4px 8px;
		border-radius: 8px;
		color: var(--clf-ink);
	}

	.clf-mark {
		flex: 0 0 auto;
		display: block;
		inline-size: var(--clf-mark);
		block-size: var(--clf-mark);
		object-fit: contain;
	}

	.clf-wordmark {
		font-size: var(--clf-text);
		line-height: var(--clf-line);
		font-weight: 500;
		color: var(--clf-ink);
	}

	.clf-links {
		display: flex;
		/*
		 * Mobile first, and that is not a style preference — it is what makes
		 * the breakpoint land where the reference's lands. Written the other
		 * way round as `max-width: 639px`, the query misses a viewport that
		 * reports 639.2px, where the reference's `min-width: 640px` correctly
		 * does not apply: the row stayed horizontal for that fractional pixel
		 * while the reference had already stacked.
		 */
		flex-direction: column;
		flex-wrap: wrap;
		align-items: center;
		justify-content: center;
		text-align: center;
		gap: var(--clf-gap);
	}

	/*
	 * The <li> is a flex container so the link is a block inside it. As an
	 * inline-block it sits on a baseline, and the line box reserves descender
	 * space under it — the row measured 21.6px against the reference's 20,
	 * and the footer carried the extra 1.6px all the way to its full height.
	 */
	.clf-links > li {
		display: flex;
	}

	.clf-link {
		display: block;
		/*
		 * Padding for the hit area, negative margin of exactly the same size
		 * to give it back. The margin box is therefore the text box: the row
		 * measures what the reference's measures, gap to gap, while the thing
		 * you can actually hit is 12px taller and 16px wider than the word.
		 * Without the second half the row grew 4px and the footer with it.
		 */
		padding: 6px 8px;
		margin: -6px -8px;
		border-radius: 6px;
		font-size: var(--clf-text);
		line-height: var(--clf-line);
		color: var(--clf-link);
		transition: color var(--clf-duration) var(--clf-ease);
	}

	/*
	 * The reference has no hover at all. Its class is `hover:text-text-
	 * neutral-800` — a doubled `text-` that Tailwind never generates — so the
	 * rule is simply absent and the links sit at one colour in every state.
	 * Measured rest and hover on the live page: both oklch(0.439 0 0).
	 */
	.clf-link:hover {
		color: var(--clf-link-hover);
	}

	/* ---- the rule ------------------------------------------------------ */

	.clf-rule {
		block-size: 1px;
		/*
		 * Out to the container edges, past the inner gutter, which is where
		 * the reference's sits: it escapes its own padding with a calc width
		 * and is then capped by the same max-width as everything else. Here
		 * it is a negative margin of one gutter, which needs no cap because
		 * it never exceeds the container in the first place.
		 */
		margin-inline: calc(var(--clf-rule-bleed) * -1);
		inline-size: calc(100% + var(--clf-rule-bleed) * 2);
		margin-block-start: 32px;
		background-image: repeating-linear-gradient(
			to right,
			var(--clf-rule) 0 2.5px,
			transparent 2.5px 5px
		);
		/* Fades out before either end rather than stopping dead. */
		-webkit-mask-image: linear-gradient(to right, transparent, #000 10%, #000 90%, transparent);
		mask-image: linear-gradient(to right, transparent, #000 10%, #000 90%, transparent);
	}

	/* ---- the bottom row ------------------------------------------------ */

	.clf-bottom {
		display: flex;
		flex-direction: column;
		align-items: center;
		justify-content: space-between;
		/*
		 * No gap while stacked: the copyright below carries its own 32px, and
		 * a flex gap on top of it put 48px between the two halves where the
		 * reference has 32 — 16px that the footer then carried as extra
		 * height at every width under the breakpoint.
		 */
		margin-block-start: 32px;
	}

	.clf-copy {
		/* Stacked, the copyright needs air under it before the icon row. */
		margin-block-end: 32px;
	}

	.clf-copy {
		font-size: var(--clf-text);
		line-height: var(--clf-line);
		color: var(--clf-muted);
	}

	.clf-social {
		display: flex;
		flex-direction: row;
		align-items: center;
		gap: var(--clf-gap);
	}

	.clf-social-link {
		position: relative;
		display: grid;
		place-items: center;
		/* 24px of glyph in a 40px hit area. The reference's icon links are
		   exactly 24px square, which is under every touch-target minimum
		   there is; the box grows, the glyph does not. */
		inline-size: 40px;
		block-size: 40px;
		margin: -8px;
		border-radius: 8px;
		color: var(--clf-muted);
		transition: color var(--clf-duration) var(--clf-ease);
	}

	.clf-social-link:hover {
		color: var(--clf-link-hover);
	}

	.clf-social-icon {
		inline-size: var(--clf-icon);
		block-size: var(--clf-icon);
	}

	/* ---- focus --------------------------------------------------------- */

	.clf-brand:focus-visible,
	.clf-link:focus-visible,
	.clf-social-link:focus-visible {
		outline: 2px solid var(--clf-accent);
		outline-offset: 2px;
	}

	/* Read, not seen. */
	.clf-a11y {
		position: absolute;
		inline-size: 1px;
		block-size: 1px;
		margin: -1px;
		padding: 0;
		overflow: hidden;
		clip-path: inset(50%);
		white-space: nowrap;
		border: 0;
	}

	/* ---- responsive ---------------------------------------------------- */

	/*
	 * The reference adds its inner gutter at 768 and stacks at 640. Both are
	 * reproduced; the widths between them are where a seven-link row is
	 * tightest, and it wraps rather than overflowing.
	 */
	@media (min-width: 768px) {
		.centered-logo-footer {
			--clf-rule-bleed: var(--clf-gutter);
		}

		.clf-inner {
			padding-inline: var(--clf-gutter);
		}
	}

	/*
	 * The reference's `sm:` — exactly, so the two change shape at the same
	 * pixel. Below it the links stack and the bottom row stacks with them.
	 *
	 * One divergence inside the stacked layout, and it is deliberate: the
	 * links are centred here. The reference's column has no cross-axis
	 * alignment, so its list box shrinks to its widest item and every shorter
	 * link left-aligns inside it — a ragged column sitting under a centred
	 * logo and above a centred copyright line.
	 */
	@media (min-width: 640px) {
		.clf-links {
			flex-direction: row;
		}

		.clf-bottom {
			flex-direction: row;
			/* Side by side the margin is gone, so a gap is what keeps a long
			   copyright off the icons. Invisible until they would collide. */
			gap: var(--clf-gap);
		}

		.clf-copy {
			margin-block-end: 0;
		}
	}

	/* ---- preferences --------------------------------------------------- */

	@media (prefers-reduced-motion: reduce) {
		/* One token, because every transition in the file resolves through it.
		   Nothing rests at zero opacity or zero size, so removing the motion
		   leaves the footer correct rather than blank. */
		.centered-logo-footer {
			--clf-duration: 0.01ms;
		}
	}

	@media (forced-colors: active) {
		/* The rule is a background image, which does not survive here, and the
		   top hairline is a near-white that may not either. */
		.centered-logo-footer {
			border-block-start-color: CanvasText;
		}

		.clf-rule {
			background-image: none;
			border-block-start: 1px dashed CanvasText;
		}
	}
</style>

===== END src/components/CenteredLogoFooter.astro =====

===== FILE (supporting): src/components/CenteredLogoFooter.md =====
# Centered logo footer

A site footer that stacks a brand mark over one row of links, a dashed
hairline, and a bottom row carrying the copyright opposite a set of small
icon links.

Catalog page: `/components/centered-logo-footer`

## Purpose

The end of a marketing page, where the job is to be quietly complete rather
than to be looked at: the brand once more, the handful of links people
actually go looking for in a footer, and the legal line.

Use something else if the footer has to carry four columns of sitemap. This
one is a single row by design — past nine or ten links it wraps into a block
of text and stops reading as navigation.

## Files required

**`CenteredLogoFooter.astro`** — one file, the whole component.

- No imports at all, not even a type.
- No npm packages, no icon library, no React, no Tailwind, no CSS reset.
- No global stylesheet. Every colour, size and duration is declared on the
  component's own root element as a `--clf-*` custom property.
- No webfonts, and **no images**. The brand mark and every icon are inline
  SVG, so the footer makes **no network request of its own** — there is
  nothing to copy into `public/` for it to render.
- The only assumption about the host is that Astro compiles `.astro` files.

`CenteredLogoFooter.md` — this file. Documentation, not required to run.

## Basic usage

```astro
---
import CenteredLogoFooter from "../components/CenteredLogoFooter.astro";
---

<CenteredLogoFooter
  brand="Fieldnote"
  brandHref="/"
  links={[
    { label: "Work", href: "/work" },
    { label: "Studio", href: "/studio" },
    { label: "Journal", href: "/journal" },
    { label: "Privacy", href: "/privacy" },
  ]}
  copyright="© 2026 Fieldnote Studio"
  social={[
    { label: "Email", href: "mailto:studio@example.com", icon: "mail" },
    { label: "Journal feed", href: "/rss.xml", icon: "rss" },
  ]}
/>
```

No client directive, no wrapper, no init call — the component ships **no
script at all**.

It goes at the end of the layout, after `<slot />` and outside `<main>`. It
paints its own surface edge to edge, so the page behind it does not need to
match.

## Props

Nothing about the business is assumed. Brand, links, copyright and icon links
default to empty, and a part with nothing in it is not drawn: no brand means
no brand block, no copyright means no copyright line, and the rule appears
only when there is something both above and below it. Pass nothing at all and
the component renders nothing. The "Fieldnote" studio in the catalog preview
is demo data passed from the catalog registry; it does not travel with the
file.

| Prop | Type | Default | What it does |
| :--- | :--- | :--- | :--- |
| `brand` | `string?` | — | The wordmark beside the mark. Omitted or `""` with a `logoSrc` renders the image alone; with neither, the brand block is not drawn. |
| `brandHref` | `string?` | `"/"` | Where the brand links. |
| `logoSrc` | `string?` | — | An image to use instead of the built-in mark. Square; anything else is letterboxed into the same box. |
| `links` | `FooterLink[]?` | `[]` | The row of links. |
| `navLabel` | `string?` | `"Footer"` | Accessible name for the row, since a page may have several navs. |
| `copyright` | `string?` | — | The line under the rule. Never generated; omitted hides it. |
| `social` | `FooterSocialLink[]?` | `[]` | Icon links at the other end of the bottom row. |
| `background` | `string?` | `#ffffff` | The surface it paints, edge to edge. |
| `ink` | `string?` | `#0a0a0a` | Wordmark and brand mark. |
| `linkColor` | `string?` | `#525252` | The link row at rest. |
| `linkHoverColor` | `string?` | `#171717` | The link row under a pointer or focus. |
| `mutedColor` | `string?` | `#737373` | Copyright and icon links. |
| `borderColor` | `string?` | `#f5f5f5` | The hairline across the top. |
| `ruleColor` | `string?` | `rgb(0 0 0 / 0.2)` | The dashed rule between the halves. |
| `accent` | `string?` | `#0a0a0a` | Focus ring. |
| `maxWidth` | `string?` | `1280px` | Content width before the gutters. |
| `class` | `string?` | — | Extra class on the root. |
| `id` | `string?` | — | Element id. |

### One link

| Field | Type | What it does |
| :--- | :--- | :--- |
| `label` | `string` | The visible word. |
| `href` | `string` | Where it goes. Anything that is not `http:`, `https:`, `mailto:`, `tel:` or a relative path is **dropped**, not rendered. |

### One icon link

| Field | Type | What it does |
| :--- | :--- | :--- |
| `label` | `string` | The accessible name. Not shown, and it is the **only** thing a screen reader gets, so it has to stand alone — `"Email"`, not `"icon"`. |
| `href` | `string` | Same scheme rules as above. |
| `icon` | `"mail" \| "link" \| "rss" \| "chat" \| "phone" \| "pin"` | Which glyph. |

```ts
import type { FooterLink, FooterSocialLink } from "../components/CenteredLogoFooter.astro";
```

### A dark footer

Every colour is a prop, so the dark version is seven of them:

```astro
<CenteredLogoFooter
  background="#0b0d10"
  ink="#ffffff"
  linkColor="#a1a1aa"
  linkHoverColor="#ffffff"
  mutedColor="#8b8f98"
  borderColor="rgb(255 255 255 / 0.1)"
  ruleColor="rgb(255 255 255 / 0.25)"
  accent="#ffffff"
/>
```

There is no `prefers-color-scheme` rule in the file, on purpose: no component
in this library ships one, because a component that picks its own theme
fights the page it lands in. Drive the props from whatever the host already
uses to decide.

## Why there are no network logos

The icon set is `mail`, `link`, `rss`, `chat`, `phone` and `pin` — all
generic. There is no Twitter bird, no GitHub cat, no Instagram glyph, and
that is deliberate rather than an omission: those are trademarks belonging to
other companies, and this library does not redistribute them.

The glyphs live in a `GLYPHS` map near the top of the file. Once the
component is in your project it is your file — add the paths you need:

```ts
const GLYPHS: Record<string, string[]> = {
  mail: [ /* ... */ ],
  yourNetwork: ["M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20z"],
};
```

and widen the `icon` union to match.

## Behaviour

There is none to speak of, and that is the point.

| Input | What happens |
| :--- | :--- |
| Click anything | It is a real link. It navigates. |
| <kbd>Tab</kbd> | Walks the brand, then each link in order, then each icon link. |
| Hover or focus a link | Colour moves from `linkColor` to `linkHoverColor` over 150ms. |

### Hit areas

Every link is padded well past its text and then given back the same amount
in negative margin, so the thing you can hit is larger than the word while
the row still measures exactly what it looks like it measures. The icon links
are 40×40 around a 24px glyph by the same trick.

## Accessibility

- It renders a real `<footer>`. That is a `contentinfo` landmark **only when
  it is not nested inside another sectioning element** — keep it at the top
  level of the body, not inside `<main>`.
- The link row is a `<nav>` with an accessible name, so a screen reader
  listing the page's navigations can tell it from the header's.
- **Every icon link has a name.** The label is in the markup, visually
  hidden. An icon link without one is an unnamed link, which is what the
  reference ships: five `<a>` elements whose entire content is an
  `aria-hidden` SVG.
- The brand mark is decorative — the wordmark beside it is the accessible
  name. With a `logoSrc` and no `brand` the link has no text name, so give
  it a wordmark or wrap it yourself.
- Focus ring is the accent at 2px with 2px of offset, on all three kinds of
  link.
- Under `forced-colors: active` the dashed rule is redrawn as a real dashed
  border, because it is a background image and those do not survive.

## Reduced motion

Under `prefers-reduced-motion: reduce` the one duration token collapses to
`0.01ms`, so the colour changes become cuts. Nothing rests at zero opacity or
zero size, so the footer is correct rather than blank.

## Without JavaScript

Identical. The component ships no script, so there is nothing to degrade —
every link works, the layout is CSS, and the rendered HTML is the whole
component.

## Content length

- **Brand** — up to about 14 characters before it crowds the mark.
- **Links** — four to nine. The row wraps rather than overflowing, but a
  wrapped footer row reads as a paragraph.
- **Copyright** — one line. At desktop it shares the row with the icons.
- **Icon links** — up to six. Past that the bottom row gets busier than the
  thing it sits under.

## Sizing

Full width, painting its own background edge to edge, with content capped at
`maxWidth` and centred. `80px` of block padding, `32px` of inline padding,
plus another `32px` of inner gutter at 768px and up.

Retune from CSS without touching the file:

```css
.my-page .centered-logo-footer {
  --clf-pad-block: 56px;
  --clf-gap: 24px;
  --clf-duration: 0ms;
}
```

## Differences from the reference

Rebuilt from a live page, measured rather than copied. The rendered box
matches at every width tested: 323.8px tall above the breakpoint and 591.8px
below it, flipping at the same pixel, with the same 80/32 padding, the same
1280px cap, the same 1px rule at 2.5px on and 2.5px off, and the same 14px
type at 16px gaps. What differs:

- **The copy, the brand and the mark are original.** Nothing of the
  reference's wording or artwork is reproduced.
- **No third-party logos**, for the reason given above.
- **The links have a hover state.** The reference's do not: its class is
  `hover:text-text-neutral-800`, a doubled `text-` that Tailwind never
  generates, so the rule is simply absent. Measured on the live page — rest
  and hover are both `oklch(0.439 0 0)`.
- **The icon links have accessible names.** The reference's have none.
- **The icon links are 40px targets**, not 24px.
- **The stacked link column is centred.** The reference's column has no
  cross-axis alignment, so its list box shrinks to its widest item and every
  shorter link left-aligns inside it — a ragged column under a centred logo.
- **The rule is one mask, not three.** The reference stacks three gradient
  masks and composites them with `exclude` to fade both ends; one
  `linear-gradient` does the same thing.
- **The breakpoint is written mobile-first**, which is not a style
  preference: as `max-width: 639px` it missed a viewport reporting 639.2px,
  where the reference's `min-width: 640px` correctly did not apply.
- The reference sets its text in Inter, a webfont. This uses the system
  stack, because a component in this library fetches nothing.

===== END src/components/CenteredLogoFooter.md =====

--- VERIFIED IN THE COMPONENT LIBRARY ---
Production-ready in the library.
Verified 2026-09-19 at 1440, 1280, 1024, 834, 768, 640, 639, 480, 390, 320px.
Passed: full-page context, keyboard, no javascript, reduced motion, two instances, no overflow, declarations corroborated, lifecycle.
Library source fingerprint: sha256:a6b34539068d9f136ac551d1c7d2c9397bcec36b35fa7de472a4d4add34a863a — MATCHES the files below.

--- 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
“Centered logo footer” from my Astro Component Library:
https://astro.baysixmedia.com/components/centered-logo-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.

Component readiness

Judged on recorded evidence, not on how it looks in the frame above. A description of a component is not a result.

Interactivity
Static
Profile
core only
Installation files
1 required + 1 supporting
Verified
2026-09-19
Client navigation
Not applicable — ships no script
Source fingerprint
Matches the verified source
Production ready
Yes

Production-ready component

Every required check passed against the code below, and the recorded fingerprint still matches it.

Verified 2026-09-19 at 1440, 1280, 1024, 834, 768, 640, 639, 480, 390, 320px

  • Full-page contextpass
  • Keyboardpass
  • No JavaScriptpass
  • Reduced motionpass
  • Two instancespass
  • No overflowpass
  • Declarations corroboratedpass
  • Lifecycle — declared to ship no scriptnot-applicable
25 verification notes
  • Fingerprint re-recorded 2026-09-24 (Step 13, content safety) after a no-behaviour content change, following the precedent above: the default brand (Fieldnote), link row, copyright and icon links — one a studio@example.com mailto — became empty and moved into the registry demo props; with none of them the footer renders nothing. Style and script blocks are byte-identical to the verified revision. Evidence for keeping this record: every preview, integration-test and template page was built before and after the change and compared — identical apart from per-build random ids — so each check below still describes what renders with the catalog's configuration. The new no-data behaviour is pinned by rendering tests in src/lib/content-safety.test.ts rather than by a fixture, and was checked in Chromium at 320, 390, 768 and 1440 (no overflow, no console errors, no duplicate ids, nothing rendered). No new full verification run was performed.
  • Every check below was run against the dev server, not a production build, and then re-run against the production build served by `astro preview`. Both agreed.
  • Rebuilt from a live page by measurement, not by copying. Read at 1440px and compared box by box; the rendered footer matches the reference on every value taken. Root padding 80px block and 32px inline over a 1px top hairline at #f5f5f5; content capped at 1280px and centred, with a further 32px inner gutter from 768px up; brand row 38px tall with a 30px mark and an 8px gap, 16px clear beneath it; the link row 20px tall at 14px/20px weight 400 in #525252 with 16px between words; a 1px rule 32px below that, dashed 2.5px on and 2.5px off in rgb(0 0 0 / .2), reaching the full 1280px container rather than the text column; and a bottom row 32px below the rule, 24px tall, carrying 14px/20px #737373 against 24px icons. Total 323.8px tall above the breakpoint and 591.8px below it, both identical to the reference.
  • The two shape changes happen at the same pixel as the reference's, and getting that right needed the media query inverted. Written as `max-width: 639px` the rule missed a viewport that reports 639.2px, where the reference's `min-width: 640px` correctly does not apply - the link row stayed horizontal for that fractional pixel while the reference had already stacked. Rewritten mobile-first, so the base is the stacked layout and 640 adds the row. Measured at 640 and 639 on both: row then column, on both the link list and the bottom row, with the inner gutter switching separately at 768 exactly as the reference's `md:` does.
  • The rule is one mask rather than three. The reference fades both ends by stacking three gradient masks and compositing them with `mask-composite: exclude`; a single `linear-gradient(to right, transparent, #000 10%, #000 90%, transparent)` produces the same picture with less machinery and fewer engine caveats. The dashes are a repeating gradient rather than `border-style: dashed`, because a dashed border cannot be told how long its dashes are and the reference's are 2.5px.
  • The reference's links have no hover state, and this is not a judgement call - it is a typo in their markup. The class is `hover:text-text-neutral-800`, with `text-` doubled, which Tailwind never generates, so no rule is emitted at all. Measured on the live page: rest and hover both oklch(0.439 0 0), unchanged. Here the links move from #525252 to #171717 over 150ms, verified in the browser as rgb(82, 82, 82) to rgb(23, 23, 23).
  • The reference's icon links have no accessible name. Each is an `<a href="#">` whose entire content is an `aria-hidden` SVG - no text, no aria-label, no title - so a screen reader announces five links called nothing. Here each carries a visually hidden label and that label is the whole accessible name. Verified: four icon links, zero of them unnamed, reading "Email", "Journal feed", "Book a call" and "Find the studio".
  • Hit areas are larger than the reference's without costing a pixel of layout. Every link is padded past its text and given the same amount back as negative margin, so the margin box is the text box: the row measures what it appears to measure, gap for gap, while the target is 48.6 x 32 instead of the word. The icon links are 40 x 40 around a 24px glyph by the same trick, against the reference's 24 x 24, which is under every touch-target minimum there is. An earlier pass had the padding without the compensation and the footer stood 4px taller than the reference at every width.
  • The stacked link column is centred, which the reference's is not. Its column has no cross-axis alignment, so the list box shrinks to its widest item and every shorter link left-aligns inside it - a ragged column sitting directly under a centred logo and above a centred copyright line. Recorded as a deliberate divergence rather than absorbed.
  • No third-party logos, and this is a licensing decision rather than an omission. The reference's bottom row carries five network marks; those are trademarks belonging to other companies and this library does not redistribute them. The icon set here is generic - mail, link, rss, chat, phone, pin - drawn on the same 24 grid at stroke 2 as the rest of the library. The GLYPHS map is at the top of the component file for anyone who wants to add their own once it is in their project, and the README says so.
  • Keyboard: twelve tab stops in document order - the brand, then the seven links, then the four icon links - and nothing after them inside the footer. The focus ring is the accent at 2px with 2px of offset, measured as `2px solid rgb(10, 10, 10)` at `2px`, and it is declared on all three kinds of link rather than only the obvious one. The root sets `overflow: hidden` to contain the masked rule; every ring was checked to sit inside it and none is clipped.
  • Semantics: a real `<footer>`, which is a contentinfo landmark only while it is not nested in another sectioning element - stated in the integration notes because the component cannot enforce where it is placed. The link row is a `<nav>` with a settable accessible name, so a page with a header nav and this one can tell them apart. The brand mark is `aria-hidden` and the wordmark beside it is the link's accessible name.
  • Static, and checked rather than assumed: zero `<script>` elements inside the rendered component, and the file contains no script block at all. With JavaScript disabled entirely the footer is byte-identical in behaviour - 324px tall, all seven links, all four icon links, twelve working anchors, the rule still drawn. Nothing degrades because nothing was ever doing anything.
  • Lifecycle and client navigation are recorded not-applicable rather than passed, and the distinction is the point: the component registers no listeners, holds no state and writes nothing to the document, so there is nothing for a router to strand and nothing to re-initialise. A pass would imply a teardown was observed working; there is no teardown.
  • Reduced motion: the single duration token collapses to 1e-05s, so both colour transitions - the links and the icon links - become cuts. Nothing in the footer rests at zero opacity or zero size, so removing the motion leaves it correct rather than blank; measured at full opacity and full height under emulation.
  • Forced colours: the top hairline is a near-white that would disappear, so it is redeclared as CanvasText, and the rule is a background image, which does not render at all there - it is switched off and redrawn as a real 1px dashed border. Observed under forced-colors emulation, not declared from the stylesheet: the border resolves solid black at the footer edge and `1px dashed` on the rule, with the background image reporting `none`.
  • Two instances on one page are independent and were checked as such, and they differ in STATE rather than only in identity: the first is the default light footer with a brand word, seven links and four icon links; the second is driven dark through the colour props, with no wordmark at all, three links, a single icon link and its own nav label. Both rendered correctly side by side, zero duplicate ids, no page overflow.
  • Declarations corroborated against the rendered fixture: twelve focusable links against hasFocusableContent, a measured colour transition against hasMotion, a supplied id reaching the root against acceptsIdProp, two independently-configured instances against multiInstanceSupport, and zero script elements against interactivity: static.
  • No overflow anywhere. Checked at twelve widths from 1440 down to 320 on both the preview and the fixture route: no descendant escapes the footer box on either side, and `scrollWidth === clientWidth` on the document at every one.
  • Install kit confirmed from the component's own page: one required file carried inline with its destination path, plus the README as supporting. There are no assets to declare - the brand mark and all six icons are inline SVG, so the footer renders with nothing copied into `public/` and issues no network request of its own.
  • Zero console messages and zero page errors across the preview route, the fixture route and the component page.
  • The demo copy, the brand and the mark are original to this library. The reference's wording and artwork are not reproduced, and the built-in mark is a ring and a dot drawn here rather than anything taken from the page.
  • The default copyright carries the year the site was BUILT, not the year it is read, because it is computed at render time and a static deploy freezes it. That is the right trade for a footer and the wrong one for a long-lived deploy, so it is stated in the integration notes and the prop is there to override it.
  • Chromium only. No WebKit or Gecko build was available in this session, and no claim is made about either. The places that gap matters most are `mask-image` on the rule, which is the one property here that would fail visibly rather than gracefully, and `:focus-visible`.
  • A measurement caveat worth stating rather than hiding: the reference's top hairline reports 0.8px and this one reports 1px, and the footer totals 323.8px there against 324px in a headless 1x context. Both are authored as a 1px border; the 0.8 is the reference page being read at a fractional device pixel ratio. Read at matching scale the two totals agree.

Open the verification fixture

src/components/library/CenteredLogoFooter.astro

one fileNo imports, no companion files, no packages. Copy CenteredLogoFooter.astro into any Astro project's components folder and render it.

---
/**
 * Centered logo footer
 *
 * A site footer that stacks a brand mark, a single row of links, a hairline
 * rule and a bottom row carrying the copyright and a set of small icon links.
 * Everything above the rule is centred; the row below it sits shoulder to
 * shoulder on desktop and stacks on a phone.
 *
 * Static: no script at all. Every interactive part is a real link, so the
 * whole thing works with JavaScript switched off and there is nothing for a
 * client-side router to tear down or re-initialise.
 *
 * Self-contained: no imports, no packages, no global stylesheet, no webfont.
 * Every colour, size and duration is a `--clf-*` custom property on the root.
 */

export interface FooterLink {
	/** The visible word. */
	label: string;
	/** Where it goes. Anything that is not a navigable scheme is dropped. */
	href: string;
}

export interface FooterSocialLink {
	/**
	 * The accessible name — "Email", "RSS". Not shown, and it is the ONLY
	 * thing a screen reader gets for an icon link, so it has to stand alone.
	 */
	label: string;
	href: string;
	/** Which glyph. See GLYPHS below; add to that map to extend the set. */
	icon: "mail" | "link" | "rss" | "chat" | "phone" | "pin";
}

interface Props {
	/** The wordmark beside the brand mark. `""` renders the mark alone. */
	brand?: string;
	/** Where the brand mark links. */
	brandHref?: string;
	/**
	 * An image to use instead of the built-in mark. Square; anything else is
	 * letterboxed into the same box. Left unset the component draws its own,
	 * so the footer renders correctly with no assets to install.
	 */
	logoSrc?: string;
	/** The row of links. */
	links?: FooterLink[];
	/** Accessible name for the link row, since a page may have several navs. */
	navLabel?: string;
	/** The line under the rule. No default: omitted, the line is not drawn. */
	copyright?: string;
	/** Small icon links on the other end of the bottom row. */
	social?: FooterSocialLink[];
	/** Page surface the footer paints, edge to edge. */
	background?: string;
	/** The wordmark and the brand mark. */
	ink?: string;
	/** The link row at rest. */
	linkColor?: string;
	/** The link row under a pointer or focus. */
	linkHoverColor?: string;
	/** Copyright and icon links. */
	mutedColor?: string;
	/** The hairline across the top of the footer. */
	borderColor?: string;
	/** The dashed rule between the two halves. */
	ruleColor?: string;
	/** Focus ring. */
	accent?: string;
	/** Content width before the gutters. */
	maxWidth?: string;
	/** Added to the root. */
	class?: string;
	/** Root element id. */
	id?: string;
}

/*
 * Nothing about the business is assumed: brand, links, copyright and icon
 * links all default to nothing, and a half with nothing in it is not drawn.
 * Pass nothing at all and the footer renders nothing. The copyright is never
 * generated — pass the line you want.
 */
const {
	brand,
	brandHref = "/",
	logoSrc,
	links = [],
	navLabel = "Footer",
	copyright,
	social = [],
	background = "#ffffff",
	ink = "#0a0a0a",
	linkColor = "#525252",
	linkHoverColor = "#171717",
	mutedColor = "#737373",
	borderColor = "#f5f5f5",
	ruleColor = "rgb(0 0 0 / 0.2)",
	accent = "#0a0a0a",
	maxWidth = "1280px",
	class: className,
	id,
} = Astro.props;

/*
 * Only schemes that navigate. A footer is copied into other people's projects
 * where the hrefs often come from a CMS record rather than from the person
 * writing the template.
 */
const SAFE_SCHEME = /^(?:https?:|mailto:|tel:|[#/.?])/i;
const safeHref = (value?: string) =>
	value && SAFE_SCHEME.test(value.trim()) ? value.trim() : undefined;

const navLinks = links
	.map((link) => ({ ...link, href: safeHref(link.href) }))
	.filter((link): link is FooterLink => Boolean(link.href && link.label));

const socialLinks = social
	.map((item) => ({ ...item, href: safeHref(item.href) }))
	.filter((item): item is FooterSocialLink => Boolean(item.href && item.label));

const brandLink = safeHref(brandHref);
const logo = safeHref(logoSrc);

/*
 * The brand block renders only when asked for: a brand string (`""` is the
 * documented way to show the generic built-in mark alone) or a logo. Omitted,
 * there is no brand block at all and no name is invented.
 */
const hasBrand = typeof brand === "string" || Boolean(logo);
const hasTop = hasBrand || navLinks.length > 0;
const hasBottom = Boolean(copyright || socialLinks.length > 0);

/*
 * Inlined rather than fetched, so the footer makes no request of its own.
 * Drawn on a 24 grid at stroke 2, and deliberately generic: this library does
 * not ship other companies' trademarks, so there are no network logos here.
 * Copy this file into your project and add to the map to use your own.
 */
const GLYPHS: Record<string, string[]> = {
	mail: [
		"M3 7.5A2.5 2.5 0 0 1 5.5 5h13A2.5 2.5 0 0 1 21 7.5v9a2.5 2.5 0 0 1-2.5 2.5h-13A2.5 2.5 0 0 1 3 16.5z",
		"m3.5 7 8.5 6 8.5-6",
	],
	link: [
		"M10.5 13.5a4 4 0 0 0 5.66 0l3-3a4 4 0 0 0-5.66-5.66l-1.5 1.5",
		"M13.5 10.5a4 4 0 0 0-5.66 0l-3 3a4 4 0 1 0 5.66 5.66l1.5-1.5",
	],
	rss: ["M5 19a1 1 0 1 0 0-2 1 1 0 0 0 0 2z", "M5 12a7 7 0 0 1 7 7", "M5 5a14 14 0 0 1 14 14"],
	chat: ["M21 11.5a8 8 0 0 1-8 8H8l-5 3 1.2-4.3A8 8 0 0 1 13 3.5a8 8 0 0 1 8 8z"],
	phone: [
		"M6.5 3h3l1.5 4-2 1.5a12 12 0 0 0 5.5 5.5L16 12l4 1.5v3a2 2 0 0 1-2.2 2A16.5 16.5 0 0 1 4.5 5.2 2 2 0 0 1 6.5 3z",
	],
	pin: ["M19 10.5c0 5-7 11-7 11s-7-6-7-11a7 7 0 0 1 14 0z", "M12 13a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5z"],
};

const style = [
	`--clf-bg:${background}`,
	`--clf-ink:${ink}`,
	`--clf-link:${linkColor}`,
	`--clf-link-hover:${linkHoverColor}`,
	`--clf-muted:${mutedColor}`,
	`--clf-border:${borderColor}`,
	`--clf-rule:${ruleColor}`,
	`--clf-accent:${accent}`,
	`--clf-max:${maxWidth}`,
].join(";");
---

{(hasTop || hasBottom) && (
<footer
	class:list={["centered-logo-footer", className]}
	id={id}
	style={style}
	data-centered-logo-footer
>
	<div class="clf-inner">
		{hasTop && (
		<div class="clf-top">
			{hasBrand && (
			<a class="clf-brand" href={brandLink}>
				{logo ? (
					<img class="clf-mark" src={logo} alt="" width="60" height="60" loading="lazy" decoding="async" />
				) : (
					/*
					  An original mark, drawn here rather than shipped as a file,
					  so the component installs as one .astro and still renders.
					*/
					<svg class="clf-mark" viewBox="0 0 32 32" fill="none" aria-hidden="true">
						<circle cx="16" cy="16" r="13" stroke="currentColor" stroke-width="3"></circle>
						<circle cx="16" cy="16" r="4.5" fill="currentColor"></circle>
					</svg>
				)}
				{brand && <span class="clf-wordmark">{brand}</span>}
			</a>
			)}

			{navLinks.length > 0 && (
				<nav class="clf-nav" aria-label={navLabel}>
					<ul class="clf-links">
						{navLinks.map((link) => (
							<li>
								<a class="clf-link" href={link.href}>{link.label}</a>
							</li>
						))}
					</ul>
				</nav>
			)}
		</div>
		)}

		{/*
		  The rule. Dashes come from a repeating gradient rather than
		  `border-style: dashed`, because a dashed border cannot control dash
		  length, and the reference's is 2.5px on, 2.5px off. The fade at both
		  ends is one mask; the reference stacks three and composites them with
		  `exclude`, which is the same picture through more machinery.
		*/}
		{hasTop && hasBottom && <div class="clf-rule" aria-hidden="true"></div>}

		{hasBottom && (
		<div class="clf-bottom">
			{copyright && <p class="clf-copy">{copyright}</p>}

			{socialLinks.length > 0 && (
				<ul class="clf-social">
					{socialLinks.map((item) => (
						<li>
							<a class="clf-social-link" href={item.href}>
								<svg
									class="clf-social-icon"
									viewBox="0 0 24 24"
									fill="none"
									stroke="currentColor"
									stroke-width="2"
									stroke-linecap="round"
									stroke-linejoin="round"
									aria-hidden="true"
								>
									{(GLYPHS[item.icon] ?? GLYPHS.link).map((d) => <path d={d}></path>)}
								</svg>
								{/* The whole accessible name. An icon link with no text is
								    an unnamed link, which is what the reference ships. */}
								<span class="clf-a11y">{item.label}</span>
							</a>
						</li>
					))}
				</ul>
			)}
		</div>
		)}
	</div>
</footer>
)}

<style>
	.centered-logo-footer {
		--clf-pad-block: 80px;
		--clf-pad-inline: 32px;
		--clf-gutter: 32px;
		/* Matches the gutter wherever the gutter exists, so the rule always
		   reaches the container edge and never past it. */
		--clf-rule-bleed: 0px;
		--clf-gap: 16px;
		--clf-mark: 30px;
		--clf-icon: 24px;
		--clf-text: 0.875rem;
		--clf-line: 1.4286;
		--clf-duration: 150ms;
		--clf-ease: cubic-bezier(0.4, 0, 0.2, 1);

		box-sizing: border-box;
		display: block;
		inline-size: 100%;
		/* The rule is masked and can reach past the content box; nothing should
		   ever be able to widen the page from in here. */
		overflow: hidden;
		padding: var(--clf-pad-block) var(--clf-pad-inline);
		border-block-start: 1px solid var(--clf-border);
		background: var(--clf-bg);
		color: var(--clf-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;
	}

	.centered-logo-footer *,
	.centered-logo-footer *::before,
	.centered-logo-footer *::after {
		box-sizing: border-box;
	}

	/*
	 * A scoped reset, and it earns its place. Everything below sets type on
	 * classes, which beats inheritance — but it does not beat a host rule
	 * aimed at the bare element, and a footer lands at the bottom of pages
	 * that style `ul`, `li`, `p` and `a` globally more often than not.
	 *
	 * `:where()` keeps this at the specificity of the root alone, so the rules
	 * underneath 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 without it the compiler emits a second
	 * attribute beside it and the whole thing outranks the class rules.
	 */
	.centered-logo-footer :global(:where(p, ul, li, a, span, nav, img, svg)) {
		margin: 0;
		padding: 0;
		border: 0;
		font: inherit;
		color: inherit;
		text-decoration: none;
		list-style: none;
	}

	.clf-inner {
		max-inline-size: var(--clf-max);
		margin-inline: auto;
	}

	/* ---- the centred half -------------------------------------------- */

	.clf-top {
		display: flex;
		flex-direction: column;
		align-items: center;
		justify-content: center;
	}

	.clf-brand {
		display: inline-flex;
		align-items: center;
		gap: 8px;
		margin-block-end: var(--clf-gap);
		padding: 4px 8px;
		border-radius: 8px;
		color: var(--clf-ink);
	}

	.clf-mark {
		flex: 0 0 auto;
		display: block;
		inline-size: var(--clf-mark);
		block-size: var(--clf-mark);
		object-fit: contain;
	}

	.clf-wordmark {
		font-size: var(--clf-text);
		line-height: var(--clf-line);
		font-weight: 500;
		color: var(--clf-ink);
	}

	.clf-links {
		display: flex;
		/*
		 * Mobile first, and that is not a style preference — it is what makes
		 * the breakpoint land where the reference's lands. Written the other
		 * way round as `max-width: 639px`, the query misses a viewport that
		 * reports 639.2px, where the reference's `min-width: 640px` correctly
		 * does not apply: the row stayed horizontal for that fractional pixel
		 * while the reference had already stacked.
		 */
		flex-direction: column;
		flex-wrap: wrap;
		align-items: center;
		justify-content: center;
		text-align: center;
		gap: var(--clf-gap);
	}

	/*
	 * The <li> is a flex container so the link is a block inside it. As an
	 * inline-block it sits on a baseline, and the line box reserves descender
	 * space under it — the row measured 21.6px against the reference's 20,
	 * and the footer carried the extra 1.6px all the way to its full height.
	 */
	.clf-links > li {
		display: flex;
	}

	.clf-link {
		display: block;
		/*
		 * Padding for the hit area, negative margin of exactly the same size
		 * to give it back. The margin box is therefore the text box: the row
		 * measures what the reference's measures, gap to gap, while the thing
		 * you can actually hit is 12px taller and 16px wider than the word.
		 * Without the second half the row grew 4px and the footer with it.
		 */
		padding: 6px 8px;
		margin: -6px -8px;
		border-radius: 6px;
		font-size: var(--clf-text);
		line-height: var(--clf-line);
		color: var(--clf-link);
		transition: color var(--clf-duration) var(--clf-ease);
	}

	/*
	 * The reference has no hover at all. Its class is `hover:text-text-
	 * neutral-800` — a doubled `text-` that Tailwind never generates — so the
	 * rule is simply absent and the links sit at one colour in every state.
	 * Measured rest and hover on the live page: both oklch(0.439 0 0).
	 */
	.clf-link:hover {
		color: var(--clf-link-hover);
	}

	/* ---- the rule ------------------------------------------------------ */

	.clf-rule {
		block-size: 1px;
		/*
		 * Out to the container edges, past the inner gutter, which is where
		 * the reference's sits: it escapes its own padding with a calc width
		 * and is then capped by the same max-width as everything else. Here
		 * it is a negative margin of one gutter, which needs no cap because
		 * it never exceeds the container in the first place.
		 */
		margin-inline: calc(var(--clf-rule-bleed) * -1);
		inline-size: calc(100% + var(--clf-rule-bleed) * 2);
		margin-block-start: 32px;
		background-image: repeating-linear-gradient(
			to right,
			var(--clf-rule) 0 2.5px,
			transparent 2.5px 5px
		);
		/* Fades out before either end rather than stopping dead. */
		-webkit-mask-image: linear-gradient(to right, transparent, #000 10%, #000 90%, transparent);
		mask-image: linear-gradient(to right, transparent, #000 10%, #000 90%, transparent);
	}

	/* ---- the bottom row ------------------------------------------------ */

	.clf-bottom {
		display: flex;
		flex-direction: column;
		align-items: center;
		justify-content: space-between;
		/*
		 * No gap while stacked: the copyright below carries its own 32px, and
		 * a flex gap on top of it put 48px between the two halves where the
		 * reference has 32 — 16px that the footer then carried as extra
		 * height at every width under the breakpoint.
		 */
		margin-block-start: 32px;
	}

	.clf-copy {
		/* Stacked, the copyright needs air under it before the icon row. */
		margin-block-end: 32px;
	}

	.clf-copy {
		font-size: var(--clf-text);
		line-height: var(--clf-line);
		color: var(--clf-muted);
	}

	.clf-social {
		display: flex;
		flex-direction: row;
		align-items: center;
		gap: var(--clf-gap);
	}

	.clf-social-link {
		position: relative;
		display: grid;
		place-items: center;
		/* 24px of glyph in a 40px hit area. The reference's icon links are
		   exactly 24px square, which is under every touch-target minimum
		   there is; the box grows, the glyph does not. */
		inline-size: 40px;
		block-size: 40px;
		margin: -8px;
		border-radius: 8px;
		color: var(--clf-muted);
		transition: color var(--clf-duration) var(--clf-ease);
	}

	.clf-social-link:hover {
		color: var(--clf-link-hover);
	}

	.clf-social-icon {
		inline-size: var(--clf-icon);
		block-size: var(--clf-icon);
	}

	/* ---- focus --------------------------------------------------------- */

	.clf-brand:focus-visible,
	.clf-link:focus-visible,
	.clf-social-link:focus-visible {
		outline: 2px solid var(--clf-accent);
		outline-offset: 2px;
	}

	/* Read, not seen. */
	.clf-a11y {
		position: absolute;
		inline-size: 1px;
		block-size: 1px;
		margin: -1px;
		padding: 0;
		overflow: hidden;
		clip-path: inset(50%);
		white-space: nowrap;
		border: 0;
	}

	/* ---- responsive ---------------------------------------------------- */

	/*
	 * The reference adds its inner gutter at 768 and stacks at 640. Both are
	 * reproduced; the widths between them are where a seven-link row is
	 * tightest, and it wraps rather than overflowing.
	 */
	@media (min-width: 768px) {
		.centered-logo-footer {
			--clf-rule-bleed: var(--clf-gutter);
		}

		.clf-inner {
			padding-inline: var(--clf-gutter);
		}
	}

	/*
	 * The reference's `sm:` — exactly, so the two change shape at the same
	 * pixel. Below it the links stack and the bottom row stacks with them.
	 *
	 * One divergence inside the stacked layout, and it is deliberate: the
	 * links are centred here. The reference's column has no cross-axis
	 * alignment, so its list box shrinks to its widest item and every shorter
	 * link left-aligns inside it — a ragged column sitting under a centred
	 * logo and above a centred copyright line.
	 */
	@media (min-width: 640px) {
		.clf-links {
			flex-direction: row;
		}

		.clf-bottom {
			flex-direction: row;
			/* Side by side the margin is gone, so a gap is what keeps a long
			   copyright off the icons. Invisible until they would collide. */
			gap: var(--clf-gap);
		}

		.clf-copy {
			margin-block-end: 0;
		}
	}

	/* ---- preferences --------------------------------------------------- */

	@media (prefers-reduced-motion: reduce) {
		/* One token, because every transition in the file resolves through it.
		   Nothing rests at zero opacity or zero size, so removing the motion
		   leaves the footer correct rather than blank. */
		.centered-logo-footer {
			--clf-duration: 0.01ms;
		}
	}

	@media (forced-colors: active) {
		/* The rule is a background image, which does not survive here, and the
		   top hairline is a near-white that may not either. */
		.centered-logo-footer {
			border-block-start-color: CanvasText;
		}

		.clf-rule {
			background-image: none;
			border-block-start: 1px dashed CanvasText;
		}
	}
</style>