src/components/library/PanelFooter.astro Copy component
one file No 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>