Component librarylocal catalog
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.

Start here

Component reference

Paste it into 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 the branding and content open.

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.

ChatGPT brief

Ask ChatGPT to plan a more detailed customization. Use it when you are still deciding which project the component belongs in, where on the page it sits, and what needs changing. It answers with a Claude Code prompt to run.

Read it first
Help me add this component to one of my Astro websites:

Component: Panel footer
Library page: https://astro.baysixmedia.com/components/panel-footer

I want it added to:
[ENTER PROJECT, PAGE, OR ROUTE]

Place it:
[ENTER LOCATION OR SELECTOR]

Customize it for:
[ENTER BUSINESS, CONTENT, COLORS, IMAGES, OR OTHER CHANGES]

Create a complete Claude Code implementation prompt. Tell Claude to read the component's source, README, documented props, dependencies, and required assets from the library page; copy everything locally into the target project; preserve its responsive behavior, accessibility, interactions, reduced-motion behavior, and multiple-instance safety; test it at desktop, tablet, and mobile widths; run the target project's available check and build commands; report all changed files and differences from the library version; and avoid committing or deploying.

Placement prompt

Send the implementation request straight to Claude Code. Use it when you already know which project, which page and where on that page it goes. Paste it into Claude Code inside the destination project and it does the work.

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

Library page:
https://astro.baysixmedia.com/components/panel-footer

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.

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

Position:
[ENTER WHERE IT SHOULD APPEAR]

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.

src/components/library/PanelFooter.astro

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

---
/**
 * 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;
}

/*
 * The demo content describes a studio that does not exist, so none of its
 * navigation has a real destination. Every item below is therefore left
 * without an `href` and renders as plain text rather than as a link that
 * looks live and goes nowhere.
 *
 * These defaults travel with the file, so they deliberately point at nothing
 * at all: a default aimed at this catalog's own routes would 404 the moment
 * the component was copied into another project.
 *
 * The contact details are the exception — `tel:` and `mailto:` built from the
 * demo values are genuinely valid URIs, so those stay live.
 */
const defaultGroups: FooterGroup[] = [
	{
		title: "Explore",
		links: [
			{ label: "Home" },
			{ label: "Work" },
			{ label: "Studio" },
			{ label: "Journal" },
			{ label: "Contact" },
		],
	},
	{
		title: "Services",
		links: [
			{ label: "Product design" },
			{ label: "Design systems" },
			{ label: "Front-end build" },
			{ label: "Accessibility audits" },
		],
	},
];

const defaultSocial: FooterSocial[] = [
	{
		label: "Website",
		path: "M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Zm0 0c2.5 2.4 3.8 5.5 3.8 9s-1.3 6.6-3.8 9m0-18C9.5 5.4 8.2 8.5 8.2 12s1.3 6.6 3.8 9M3.5 9h17m-17 6h17",
	},
	{
		label: "Discussion",
		path: "M20 12a7.5 7.5 0 0 1-10.9 6.7L4 20l1.4-4.3A7.5 7.5 0 1 1 20 12Z",
	},
	{
		label: "Newsletter",
		path: "M21 4 3 11l7 3 3 7 8-17Zm0 0-11 10",
	},
];

const {
	brand = "Meridian Works",
	brandHref,
	description = "We design and build interfaces that stay maintainable long after launch.",
	social = defaultSocial,
	groups = defaultGroups,
	contact = {
		title: "Studio & contact",
		legalName: "MERIDIAN WORKS LLC",
		address: "Suite 200, 1400 Example Avenue, Portland, OR 97209",
		phone: "+1 (555) 0142",
		email: "studio@meridianworks.example",
	},
	/*
	 * A call to action has to go somewhere, so `href` stays required. The demo
	 * sends it to the demo mailbox: with no contact page to point at, that is
	 * the one destination here that genuinely works.
	 */
	cta = { label: "Start a project", href: "mailto:studio@meridianworks.example" },
	copyright = "© 2026 Meridian Works. All rights reserved.",
	note = "Built to be maintained, not just launched.",
	legal = [{ label: "Privacy" }, { label: "Terms" }],
	surround = "#eef0f2",
	panelTop = "#232120",
	panelBottom = "#151417",
	ink = "#ffffff",
	body = "#c9cdd4",
	accent = "#b0431e",
	accentHover = "#cf5a2e",
	linkHover = "#f08a5f",
	radius = "20px",
	class: className,
	id,
} = Astro.props;

/* 4 tracks for the brand, 2 for each group, 4 for contact — the desktop row. */
const columns = 4 + 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);
const telHref = contact?.phoneHref ?? (contact?.phone ? `tel:${contact.phone.replace(/[^+\d]/g, "")}` : undefined);

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

<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">
		<div class="top">
			<div class="grid">
				<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>

							<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>
										<a class="link" href={telHref}>
											{contact.phone}
										</a>
									</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>

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