Component library
Back to catalog

Panel footer

A site footer drawn as a rounded dark panel inset from the page edge, with link groups, contact details and a call to action.

footernavigationcontact

Open preview in a new tab

Use this component

Three ways to get Panel 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 Panel footer from my Astro Component Library to this Astro project.

Library page, for reference only:
https://astro.baysixmedia.com/components/panel-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
“Panel footer” from my Astro Component Library:
https://astro.baysixmedia.com/components/panel-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/PanelFooter.astro

Installing this one is more than pasting a section

  • installOne required file, plus supporting files worth taking:
    • src/components/PanelFooter.astrocomponentrequired
    • src/components/PanelFooter.mdreadmesupporting
    Use Copy header kit above to get them all, each one labelled with the path it belongs at.
---
/**
 * PanelFooter — a site footer drawn as a rounded dark panel inset from the
 * page edge, with a brand column, link groups, a contact block with a call to
 * action, and a legal bar underneath.
 *
 * One self-contained file. No imports, no global stylesheet, no npm packages,
 * no bundled assets — and no JavaScript: every behaviour here is a CSS state.
 *
 * Measured behaviour:
 *   frame         12px inset around a 20px-radius panel, ring + drop + inset shadow
 *   columns       1 up to 768, 2 to 1024, then a 12-track row: 4 / 2 / 2 / 4
 *   link rows     8px apart, 14px/20px
 *   group titles  12px bold uppercase, 0.05em tracking
 *   cta           pill, 12px bold uppercase, recolours over 200ms
 *   links         recolour over 200ms; there is no underline and no motion
 *   legal bar     stacked and centred below 768, spread into a row above it
 *
 * The reference footer carries no entrance animation and no scroll effect —
 * only hover colour — which is why this file ships no script.
 */

export interface FooterLink {
	label: string;
	/**
	 * Destination. Omit it — or pass an empty string — and the item renders as
	 * plain, non-interactive text instead of a link that goes nowhere.
	 */
	href?: string;
	/** Open in a new tab, with the matching rel. */
	external?: boolean;
}

export interface FooterGroup {
	title: string;
	links: FooterLink[];
}

export interface FooterSocial {
	/** Accessible name, e.g. "Mastodon". Rendered as the link's label. */
	label: string;
	/** Destination. Omit it and the glyph is drawn as inert decoration. */
	href?: string;
	/**
	 * `d` attribute for a 24x24 stroked icon. Omit to use a neutral glyph.
	 * Supply your own so no icon set has to travel with the file.
	 */
	path?: string;
	external?: boolean;
}

export interface FooterContact {
	/** Column heading, e.g. "Studio & contact". */
	title: string;
	/** Small tracked line under the heading, e.g. a registered name. */
	legalName?: string;
	/** Postal address. Plain text unless `addressHref` is given. */
	address?: string;
	/** Makes the address a link — a map URL, for example. */
	addressHref?: string;
	/** Displayed phone number. */
	phone?: string;
	/** Dial target. Defaults to `phone` stripped of spacing. */
	phoneHref?: string;
	email?: string;
}

export interface FooterCta {
	label: string;
	href: string;
	external?: boolean;
}

interface Props {
	/** Brand name, set as a wordmark. Empty string hides it. */
	brand?: string;
	/** Where the wordmark links to. */
	brandHref?: string;
	/** Sentence under the wordmark. */
	description?: string;
	/** Small icon links under the description. */
	social?: FooterSocial[];
	/** The link columns between the brand and the contact block. */
	groups?: FooterGroup[];
	/** The contact column. Pass `null` to leave it out. */
	contact?: FooterContact | null;
	/** Button at the foot of the contact column. `null` leaves it out. */
	cta?: FooterCta | null;
	/** Left-hand line in the legal bar. */
	copyright?: string;
	/** Centre line in the legal bar. Empty string hides it. */
	note?: string;
	/** Right-hand links in the legal bar. */
	legal?: FooterLink[];
	/** Page colour behind the inset panel. */
	surround?: string;
	/** Top of the panel gradient. */
	panelTop?: string;
	/** Bottom of the panel gradient. */
	panelBottom?: string;
	/** Headings and wordmark colour. */
	ink?: string;
	/** Body and link colour. */
	body?: string;
	/** Button background. */
	accent?: string;
	/** Button background on hover. */
	accentHover?: string;
	/** Link colour on hover. */
	linkHover?: string;
	/** Corner radius of the panel. */
	radius?: string;
	class?: string;
	id?: string;
}

/*
 * Nothing about the business is assumed. Brand, description, links, contact
 * details, call to action, copyright and note all default to nothing, and a
 * block with nothing in it is not drawn — pass nothing at all and the footer
 * renders nothing. The catalog's demo studio lives in the registry's preview
 * props, not here, so it can never ship as someone's live contact details.
 */
const {
	brand,
	brandHref,
	description,
	social = [],
	groups = [],
	contact = null,
	/* A call to action has to go somewhere, so `href` stays required. */
	cta = null,
	copyright,
	note,
	legal = [],
	surround = "#eef0f2",
	panelTop = "#232120",
	panelBottom = "#151417",
	ink = "#ffffff",
	body = "#c9cdd4",
	accent = "#b0431e",
	accentHover = "#cf5a2e",
	linkHover = "#f08a5f",
	radius = "20px",
	class: className,
	id,
} = Astro.props;

const hasBrand = Boolean(brand || description || social.length > 0);
const hasTop = hasBrand || groups.length > 0 || Boolean(contact);
const hasBottom = Boolean(copyright || note || legal.length > 0);

/* 4 tracks for the brand, 2 for each group, 4 for contact — the desktop row. */
const columns = (hasBrand ? 4 : 0) + groups.length * 2 + (contact ? 4 : 0);

/**
 * A destination only counts if it is a non-empty string. Anything else — an
 * omitted prop, an empty string, a stray "#" — renders as text, so nothing in
 * this footer can look like a link while doing nothing.
 */
const isDestination = (href?: string) => {
	const trimmed = href?.trim();
	return !!trimmed && trimmed !== "#";
};

const relFor = (external?: boolean) => (external ? "noopener noreferrer" : undefined);
const targetFor = (external?: boolean) => (external ? "_blank" : undefined);
/* A dialable href from a displayed number: keep a leading + and the digits. */
const telHref = (number: string) => {
	const trimmed = number.trim();
	const digits = trimmed.replace(/\D/g, "");
	return digits ? `tel:${trimmed.startsWith("+") ? "+" : ""}${digits}` : "";
};
/* A call link only with somewhere to dial: words in `phone` are not a number. */
const callHref = contact?.phone?.trim()
	? isDestination(contact.phoneHref)
		? contact.phoneHref!.trim()
		: telHref(contact.phone)
	: "";
const hasContactRows = Boolean(contact && (contact.address || contact.phone || contact.email));

const NEUTRAL_GLYPH = "M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Z";
---

{(hasTop || hasBottom) && (
<footer
	class:list={["frame", className]}
	id={id}
	style={`--pf-surround:${surround};--pf-panel-top:${panelTop};--pf-panel-bottom:${panelBottom};--pf-ink:${ink};--pf-body:${body};--pf-accent:${accent};--pf-accent-hover:${accentHover};--pf-link-hover:${linkHover};--pf-radius:${radius};--pf-columns:${columns};`}
>
	<div class="panel">
		{hasTop && (
		<div class="top">
			<div class="grid">
				{hasBrand && (
				<div class="col brand-col">
					{
						brand &&
							(isDestination(brandHref) ? (
								<a class="wordmark" href={brandHref}>
									{brand}
								</a>
							) : (
								<p class="wordmark wordmark-static">{brand}</p>
							))
					}
					{description && <p class="description">{description}</p>}
					{
						social.length > 0 && (
							<ul class="social">
								{social.map((item) => {
									const Glyph = isDestination(item.href) ? "a" : "span";
									return (
										<li>
											<Glyph
												class:list={[
													"social-link",
													{ "social-static": !isDestination(item.href) },
												]}
												href={isDestination(item.href) ? item.href : undefined}
												target={isDestination(item.href) ? targetFor(item.external) : undefined}
												rel={isDestination(item.href) ? relFor(item.external) : undefined}
											>
												<svg
													viewBox="0 0 24 24"
													width="18"
													height="18"
													fill="none"
													stroke="currentColor"
													stroke-width="1.5"
													stroke-linecap="round"
													stroke-linejoin="round"
													aria-hidden="true"
												>
													<path d={item.path ?? NEUTRAL_GLYPH} />
												</svg>
												<span class="sr-only">{item.label}</span>
											</Glyph>
										</li>
									);
								})}
							</ul>
						)
					}
				</div>
				)}

				{
					groups.map((group) => (
						<div class="col group-col">
							<h3 class="col-title">{group.title}</h3>
							<ul class="links">
								{group.links.map((link) =>
									isDestination(link.href) ? (
										<li>
											<a
												class="link"
												href={link.href}
												target={targetFor(link.external)}
												rel={relFor(link.external)}
											>
												{link.label}
											</a>
										</li>
									) : (
										/* Same <li> wrapper as the link branch, so a group with no
										   destinations keeps the exact row rhythm of one that has them. */
										<li>
											<span class="link link-static">{link.label}</span>
										</li>
									),
								)}
							</ul>
						</div>
					))
				}

				{
					contact && (
						<div class="col contact-col">
							<div class="contact-head">
								<h3 class="col-title">{contact.title}</h3>
								{contact.legalName && <p class="legal-name">{contact.legalName}</p>}
							</div>

							{hasContactRows && (
							<ul class="contact-list">
								{contact.address && (
									<li class="contact-row">
										<svg
											class="contact-icon"
											viewBox="0 0 24 24"
											width="16"
											height="16"
											fill="none"
											stroke="currentColor"
											stroke-width="1.5"
											stroke-linecap="round"
											stroke-linejoin="round"
											aria-hidden="true"
										>
											<path d="M12 21s-6.5-5.3-6.5-10.5a6.5 6.5 0 1 1 13 0C18.5 15.7 12 21 12 21Z" />
											<circle cx="12" cy="10.5" r="2.4" />
										</svg>
										{contact.addressHref ? (
											<a class="link" href={contact.addressHref}>
												{contact.address}
											</a>
										) : (
											<span class="contact-text">{contact.address}</span>
										)}
									</li>
								)}

								{contact.phone && (
									<li class="contact-row">
										<svg
											class="contact-icon"
											viewBox="0 0 24 24"
											width="16"
											height="16"
											fill="none"
											stroke="currentColor"
											stroke-width="1.5"
											stroke-linecap="round"
											stroke-linejoin="round"
											aria-hidden="true"
										>
											<path d="M6.5 4h3l1.5 4-2 1.5a12 12 0 0 0 5.5 5.5L16 13l4 1.5v3a2 2 0 0 1-2.2 2A16 16 0 0 1 4.5 6.2 2 2 0 0 1 6.5 4Z" />
										</svg>
										{callHref ? (
											<a class="link" href={callHref}>
												{contact.phone}
											</a>
										) : (
											<span class="contact-text">{contact.phone}</span>
										)}
									</li>
								)}

								{contact.email && (
									<li class="contact-row">
										<svg
											class="contact-icon"
											viewBox="0 0 24 24"
											width="16"
											height="16"
											fill="none"
											stroke="currentColor"
											stroke-width="1.5"
											stroke-linecap="round"
											stroke-linejoin="round"
											aria-hidden="true"
										>
											<rect x="3" y="5.5" width="18" height="13" rx="2" />
											<path d="m3.5 7 8.5 6 8.5-6" />
										</svg>
										<a class="link" href={`mailto:${contact.email}`}>
											{contact.email}
										</a>
									</li>
								)}
							</ul>
							)}

							{cta && (
								<a
									class="cta"
									href={cta.href}
									target={targetFor(cta.external)}
									rel={relFor(cta.external)}
								>
									{cta.label}
								</a>
							)}
						</div>
					)
				}
			</div>
		</div>
		)}

		{hasBottom && (
		<div class="bottom">
			<div class="bottom-inner">
				{copyright && <p class="copyright">{copyright}</p>}
				{note && <p class="note">{note}</p>}
				{
					legal.length > 0 && (
						<ul class="legal">
							{legal.map((link) =>
								isDestination(link.href) ? (
									<li>
										<a
											class="link legal-link"
											href={link.href}
											target={targetFor(link.external)}
											rel={relFor(link.external)}
										>
											{link.label}
										</a>
									</li>
								) : (
									<li>
										<span class="link legal-link link-static">{link.label}</span>
									</li>
								),
							)}
						</ul>
					)
				}
			</div>
		</div>
		)}
	</div>
</footer>
)}

<style>
	.frame {
		--pf-muted: color-mix(in srgb, var(--pf-ink) 50%, transparent);
		/* Dimmer than body text so it reads as a label, but still held above
		   the 4.5:1 contrast floor rather than dropped to decoration. */
		--pf-static: color-mix(in srgb, var(--pf-ink) 64%, transparent);
		--pf-hairline: color-mix(in srgb, var(--pf-ink) 10%, transparent);

		--pf-font: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto,
			"Helvetica Neue", Arial, sans-serif;

		--pf-title: 0.75rem;
		--pf-link: 0.875rem;
		--pf-small: 0.75rem;

		box-sizing: border-box;
		display: block;
		padding: 12px;
		background: var(--pf-surround);
		font-family: var(--pf-font);
	}

	.frame :where(*, *::before, *::after) {
		box-sizing: border-box;
	}

	.panel {
		display: flex;
		flex-direction: column;
		/* Keeps the panel from collapsing when the columns are short. */
		min-height: min(30rem, 40vh);
		overflow: hidden;
		border-radius: var(--pf-radius);
		background: linear-gradient(var(--pf-panel-top) 0%, var(--pf-panel-bottom) 100%);
		color: var(--pf-body);
		box-shadow:
			0 0 0 1px rgb(0 0 0 / 0.05),
			0 8px 30px rgb(0 0 0 / 0.12),
			inset 0 1px 0 rgb(255 255 255 / 0.1);
	}

	.top {
		flex: 1;
		padding: 40px 24px 0;
	}

	/* ---------- columns ---------- */

	.grid {
		display: grid;
		grid-template-columns: 1fr;
		gap: 36px;
		margin-bottom: 40px;
	}

	.col {
		display: flex;
		flex-direction: column;
		gap: 14px;
		min-width: 0;
	}

	.wordmark {
		align-self: flex-start;
		font-size: 1.25rem;
		font-weight: 650;
		letter-spacing: -0.02em;
		color: var(--pf-ink);
		text-decoration: none;
	}

	.description {
		max-width: 24rem;
		margin: 0;
		font-size: var(--pf-link);
		line-height: 1.375;
		text-wrap: pretty;
	}

	.social {
		display: flex;
		align-items: center;
		gap: 16px;
		list-style: none;
		margin: 0;
		padding: 4px 0 0;
	}

	.social-link {
		display: inline-flex;
		align-items: center;
		justify-content: center;
		color: var(--pf-body);
		text-decoration: none;
		transition: color 200ms cubic-bezier(0.4, 0, 0.2, 1);
	}

	.col-title {
		margin: 0;
		font-size: var(--pf-title);
		font-weight: 700;
		line-height: 1.33;
		letter-spacing: 0.05em;
		text-transform: uppercase;
		color: var(--pf-ink);
	}

	.links {
		display: flex;
		flex-direction: column;
		gap: 8px;
		list-style: none;
		margin: 0;
		padding: 0;
	}

	.link {
		font-size: var(--pf-link);
		line-height: 1.43;
		color: var(--pf-body);
		text-decoration: none;
		transition: color 200ms cubic-bezier(0.4, 0, 0.2, 1);
	}

	/*
	 * An item with no destination. It is a <li> or <span> rather than an <a>,
	 * so it is already out of the tab order and unclickable; these rules make
	 * that legible by dimming it and dropping the text cursor, so it reads as
	 * a label rather than as a link that is failing to work.
	 */
	.link-static,
	.social-static {
		color: var(--pf-static);
		cursor: default;
		transition: none;
	}

	/* A brand name that is not a link is a heading, not a broken link, so it
	   keeps its full weight and colour — it only stops being clickable. */
	.wordmark-static {
		margin: 0;
		cursor: default;
		transition: none;
	}

	/* ---------- contact ---------- */

	.contact-head {
		display: flex;
		flex-direction: column;
		gap: 8px;
	}

	.legal-name {
		margin: 0;
		font-size: 0.6875rem;
		font-weight: 500;
		letter-spacing: 0.12em;
		text-transform: uppercase;
		color: var(--pf-muted);
	}

	.contact-list {
		display: flex;
		flex-direction: column;
		gap: 14px;
		list-style: none;
		margin: 0;
		padding: 0;
	}

	.contact-row {
		display: flex;
		gap: 10px;
		align-items: flex-start;
	}

	.contact-icon {
		flex-shrink: 0;
		/* Sits on the first line's optical centre rather than its top edge. */
		margin-top: 0.15em;
		color: var(--pf-muted);
	}

	.contact-text {
		font-size: var(--pf-link);
		line-height: 1.43;
		text-wrap: pretty;
	}

	.cta {
		align-self: flex-start;
		margin-top: 2px;
		padding: 8px 20px;
		border-radius: 999px;
		background: var(--pf-accent);
		color: #fff;
		font-size: var(--pf-title);
		font-weight: 700;
		line-height: 1.33;
		letter-spacing: 0.05em;
		text-transform: uppercase;
		text-decoration: none;
		transition: background-color 200ms cubic-bezier(0.4, 0, 0.2, 1);
	}

	/* ---------- legal bar ---------- */

	.bottom {
		margin-top: auto;
		border-top: 1px solid var(--pf-hairline);
		padding: 24px;
	}

	.bottom-inner {
		display: flex;
		flex-direction: column;
		align-items: center;
		gap: 12px;
	}

	.copyright,
	.note {
		margin: 0;
		font-size: var(--pf-small);
		line-height: 1.67;
		text-align: center;
	}

	.note {
		color: var(--pf-muted);
	}

	.legal {
		display: flex;
		flex-wrap: wrap;
		align-items: center;
		justify-content: center;
		gap: 12px;
		list-style: none;
		margin: 0;
		padding: 0;
	}

	/* ---------- states ----------
	 * The reference leaves the browser's default focus ring in place, which is
	 * a dark outline on a dark panel and effectively invisible. This draws its
	 * own so a keyboard reader can see where they are.
	 */

	.link:focus-visible,
	.social-link:focus-visible,
	.wordmark:focus-visible,
	.cta:focus-visible {
		outline: 2px solid var(--pf-ink);
		outline-offset: 3px;
		border-radius: 2px;
	}

	.sr-only {
		position: absolute;
		width: 1px;
		height: 1px;
		margin: -1px;
		padding: 0;
		overflow: hidden;
		clip-path: inset(50%);
		white-space: nowrap;
		border: 0;
	}

	/* Hover is a pointer affordance. On a touch screen :hover sticks after the
	   finger lifts, which would strand a link in its hover colour. */
	@media (hover: hover) and (pointer: fine) {
		.link:hover,
		.social-link:hover {
			color: var(--pf-link-hover);
		}

		.wordmark:hover {
			color: var(--pf-link-hover);
		}

		.cta:hover {
			background: var(--pf-accent-hover);
		}
	}

	/* ---------- breakpoints ---------- */

	@media (min-width: 640px) {
		.frame {
			--pf-small: 0.875rem;
		}

		.top {
			padding: 48px 40px 0;
		}

		.bottom {
			padding: 32px 40px;
		}
	}

	@media (min-width: 768px) {
		.grid {
			grid-template-columns: repeat(2, minmax(0, 1fr));
			gap: 44px;
		}

		.bottom-inner {
			flex-direction: row;
			justify-content: space-between;
			gap: 16px;
		}

		.copyright {
			text-align: left;
		}

		.legal {
			justify-content: flex-end;
			gap: 16px;
		}
	}

	@media (min-width: 1024px) {
		.top {
			padding: 56px 56px 0;
		}

		/*
		 * One row of narrow tracks rather than four equal columns: the brand
		 * and contact blocks take four each and every link group takes two, so
		 * the wide blocks stay wide however many groups there are.
		 */
		.grid {
			grid-template-columns: repeat(var(--pf-columns), minmax(0, 1fr));
			gap: 28px;
			margin-bottom: 48px;
		}

		.brand-col,
		.contact-col {
			grid-column: span 4;
		}

		.group-col {
			grid-column: span 2;
		}

		.bottom {
			padding: 32px 56px 40px;
		}
	}

	@media (prefers-reduced-motion: reduce) {
		.link,
		.social-link,
		.wordmark,
		.cta {
			transition: none;
		}
	}
</style>