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: "Newsletter panel footer"
Library page: https://astro.baysixmedia.com/components/newsletter-panel-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/NewsletterPanelFooter.astro =====
---
/**
* NewsletterPanelFooter
*
* A navy footer in two parts. On top, "Subscribe Our Newsletter" beside an
* email field with an orange Subscribe button (a real form). Below, the
* brand block — logo, a short blurb, four social circles — and three
* columns: Quick Links and Our Services (links with » bullets) and Contact
* Us (mail and phone). Headings carry a short white-and-orange underline.
*
* Two measured layouts:
* - `variant="panel"` (default, the reference's inner pages): the brand and
* columns sit in a rounded panel with a slightly lighter fill and a
* gradient hairline, and a centred copyright line closes the footer.
* - `variant="band"` (the reference's home page): a hairline divides the
* newsletter row from the columns, and the copyright sits on an orange band.
*
* Nothing about the business is assumed: no name, blurb, link, contact,
* social profile, copyright or sign-up form is shown unless it is passed, a
* block with nothing in it is not drawn, and with no content at all nothing
* renders.
*
* The top content rises 40px and fades in once when scrolled into view.
* Reads --tg-* design tokens from any ancestor, with fallbacks.
*/
export interface NewsletterPanelFooterLink {
label: string;
href: string;
}
export interface NewsletterPanelFooterColumn {
heading: string;
links: NewsletterPanelFooterLink[];
}
export interface NewsletterPanelFooterContact {
icon: "email" | "phone" | "address";
label: string;
href: string;
}
export type NewsletterPanelFooterNetwork = "facebook" | "x" | "instagram" | "linkedin" | "youtube" | "link";
export interface NewsletterPanelFooterSocial {
network: NewsletterPanelFooterNetwork;
href: string;
/** Accessible name, e.g. "Northpass on LinkedIn". */
label: string;
}
export interface NewsletterPanelFooterProps {
variant?: "panel" | "band";
/** The newsletter heading. Unset together with no `newsletter` drops the row. */
heading?: string;
/** The subscribe form. Off by default; `{}` shows it with no `action` (posts to the current URL). */
newsletter?: {
action?: string;
method?: "get" | "post";
/** Field name as submitted. */
name?: string;
label?: string;
placeholder?: string;
buttonLabel?: string;
} | null;
brandName?: string;
brandHref?: string;
/** Replaces the drawn mark and wordmark with an image (use a light logo). */
logo?: { src: string; alt: string; width?: number; height?: number };
blurb?: string;
social?: NewsletterPanelFooterSocial[];
columns?: NewsletterPanelFooterColumn[];
contactHeading?: string;
contacts?: NewsletterPanelFooterContact[];
/** Text of the copyright line. `{year}` is replaced with the build year. Defaults to one naming `brandName`; with neither, no line. */
copyright?: string;
/** Makes the copyright line a link. */
copyrightHref?: string;
year?: number;
/** Scroll-in rise for the top content. */
animate?: boolean;
navLabel?: string;
fontStylesheet?: string;
id?: string;
}
interface Props extends NewsletterPanelFooterProps {}
const {
variant = "panel",
heading = "",
newsletter = null,
brandName = "",
brandHref = "/",
logo,
blurb = "",
social = [],
columns = [],
contactHeading = "Contact Us",
contacts = [],
copyright,
copyrightHref,
year = new Date().getFullYear(),
animate = true,
navLabel = "Footer",
fontStylesheet = "https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&family=DM+Sans:wght@400;500;600&display=swap",
id,
} = Astro.props;
const seed = id ? id : `npf-${Math.random().toString(36).slice(2, 8)}`;
const emailId = `${seed}-email`;
const form = newsletter
? {
method: "post" as const,
name: "email",
label: "Email address",
placeholder: "Email Address",
buttonLabel: "Subscribe",
...newsletter,
}
: null;
const hasTop = Boolean(heading || form);
const hasMark = Boolean(logo || brandName);
const hasBrand = Boolean(hasMark || blurb || social.length > 0);
const hasCols = columns.length > 0 || contacts.length > 0;
const hasPanel = hasBrand || hasCols;
/* No name, no copyright line: never a line naming nobody. */
const copyText = (copyright ?? (brandName ? `Copyright © {year} ${brandName}. All Rights Reserved.` : "")).replace("{year}", String(year));
const hasContent = hasTop || hasPanel || Boolean(copyText);
const isExternal = (href: string) => /^https?:\/\//.test(href);
const CONTACT_ICONS: Record<NewsletterPanelFooterContact["icon"], string> = {
email:
'<path d="M1 4.2A1.7 1.7 0 0 1 2.7 2.5h10.6A1.7 1.7 0 0 1 15 4.2v.3L8 9 1 4.5z" fill="currentColor"/><path d="M1 6.3l6.5 4.2a1 1 0 0 0 1 0L15 6.3v5.5a1.7 1.7 0 0 1-1.7 1.7H2.7A1.7 1.7 0 0 1 1 11.8z" fill="currentColor"/>',
phone:
'<path d="M4.2 1.5 6.3 4a1 1 0 0 1-.1 1.3L5 6.5a8.4 8.4 0 0 0 4.5 4.5l1.2-1.2a1 1 0 0 1 1.3-.1l2.5 2.1a1 1 0 0 1 .1 1.4l-1 1.1c-.7.7-1.7.9-2.6.6A14 14 0 0 1 1.1 4.9c-.3-.9-.1-1.9.6-2.6l1-1a1 1 0 0 1 1.5.2z" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linejoin="round"/><path d="M9.6 1.6a5 5 0 0 1 4.8 4.8M9.4 4a2.6 2.6 0 0 1 2.6 2.6" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>',
address:
'<path d="M8 .8a5.6 5.6 0 0 0-5.6 5.6c0 4.1 5 8.5 5.2 8.7a.6.6 0 0 0 .8 0c.2-.2 5.2-4.6 5.2-8.7A5.6 5.6 0 0 0 8 .8zm0 7.9a2.3 2.3 0 1 1 0-4.6 2.3 2.3 0 0 1 0 4.6z" fill="currentColor"/>',
};
const GLYPHS: Record<NewsletterPanelFooterNetwork, string> = {
facebook:
'<path d="M13.6 21v-7.6h2.6l.4-3.1h-3V8.4c0-.9.3-1.5 1.5-1.5h1.6V4.1c-.3 0-1.2-.1-2.3-.1-2.3 0-3.9 1.4-3.9 4v2.3H7.9v3.1h2.6V21z" fill="currentColor"/>',
x: '<path d="M4 4h4.6l11.4 16h-4.6z" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/><path d="M19.4 4l-6.2 7M10.8 13l-6.2 7" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/>',
instagram:
'<rect x="4" y="4" width="16" height="16" rx="4.6" fill="none" stroke="currentColor" stroke-width="1.8"/><circle cx="12" cy="12" r="3.7" fill="none" stroke="currentColor" stroke-width="1.8"/><circle cx="16.6" cy="7.4" r="1.1" fill="currentColor"/>',
linkedin:
'<rect x="4.2" y="9.2" width="3.3" height="10.6" fill="currentColor"/><circle cx="5.85" cy="5.9" r="1.95" fill="currentColor"/><path d="M10 9.2h3.2v1.5c.5-.9 1.7-1.8 3.5-1.8 3.4 0 4 2.2 4 5.1v5.8h-3.3v-5.1c0-1.3 0-2.9-1.8-2.9s-2.1 1.4-2.1 2.8v5.2H10z" fill="currentColor"/>',
youtube:
'<rect x="3" y="6" width="18" height="12.5" rx="3.6" fill="none" stroke="currentColor" stroke-width="1.8"/><path d="M10.4 9.4v5.7l4.9-2.85z" fill="currentColor"/>',
link: '<path d="M10 14l4-4M8.5 11.5 6.6 13.4a3.2 3.2 0 0 0 4.5 4.5l1.9-1.9M15.5 12.5l1.9-1.9a3.2 3.2 0 0 0-4.5-4.5L11 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>',
};
---
{hasContent && fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
{hasContent && (
<footer
class:list={["npf", `npf--${variant}`]}
id={id}
data-npf-root
data-animate={animate ? "" : undefined}
>
{(hasTop || hasPanel) && (
<div class="npf-main">
{
hasTop && (
<div class="npf-top npf-rise" style="--npf-i: 0">
{heading && <h2 class="npf-heading">{heading}</h2>}
{form && (
<form class="npf-form" action={form.action} method={form.method}>
<label class="npf-sr" for={emailId}>{form.label}</label>
<div class="npf-field">
<input
class="npf-input"
id={emailId}
type="email"
name={form.name}
placeholder={form.placeholder}
autocomplete="email"
required
/>
<button class="npf-submit" type="submit">
<span>{form.buttonLabel}</span>
<svg class="npf-plane" viewBox="0 0 22 21" aria-hidden="true" focusable="false">
<path d="M20.6 1.2 1.6 8.6c-.8.3-.8 1.4 0 1.7l6 2.2 2.2 6.3c.3.8 1.4.9 1.8.1L21.9 2.5c.4-.8-.4-1.6-1.3-1.3zM9.1 12.2l7.7-6.9-6 8.1-.3 3.2z" fill="currentColor" />
</svg>
</button>
</div>
</form>
)}
</div>
)
}
{variant === "band" && hasTop && hasPanel && <div class="npf-rule" aria-hidden="true" />}
{hasPanel && (
<div class="npf-panel">
<div class="npf-links npf-rise" style="--npf-i: 1">
{hasBrand && (
<div class="npf-brandblock">
{hasMark && (
<a class="npf-brand" href={brandHref} aria-label={logo ? undefined : `${brandName} home`}>
{
logo ? (
<img
class="npf-logo-img"
src={logo.src}
alt={logo.alt}
width={logo.width ?? 157}
height={logo.height ?? 30}
loading="lazy"
/>
) : (
<>
<svg class="npf-mark" viewBox="0 0 46 30" aria-hidden="true" focusable="false">
<path d="M2 9h11M5 14h9M1 19h13" fill="none" stroke="var(--npf-orange)" stroke-width="2.6" stroke-linecap="round" />
<rect x="14" y="4" width="17" height="15" rx="2" fill="currentColor" />
<path d="M32 8h6.2l5.3 5.6V19H32z" fill="currentColor" />
<path d="M34.4 10.4h3.2l3 3.2h-6.2z" fill="var(--npf-orange)" />
<circle cx="20" cy="21.5" r="3.4" fill="currentColor" stroke="var(--npf-navy)" stroke-width="1.6" />
<circle cx="37.5" cy="21.5" r="3.4" fill="currentColor" stroke="var(--npf-navy)" stroke-width="1.6" />
<path d="M4 27.5h40" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" />
</svg>
<span class="npf-wordmark" aria-hidden="true">{brandName}</span>
</>
)
}
</a>
)}
{blurb && <p class="npf-blurb">{blurb}</p>}
{
social.length > 0 && (
<ul class="npf-social" role="list">
{social.map((s) => (
<li>
<a
class="npf-social-link"
href={s.href}
aria-label={s.label}
target={isExternal(s.href) ? "_blank" : undefined}
rel={isExternal(s.href) ? "noopener noreferrer" : undefined}
>
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false" set:html={GLYPHS[s.network] ?? GLYPHS.link} />
</a>
</li>
))}
</ul>
)
}
</div>
)}
{hasCols && (
<nav class="npf-cols" aria-label={navLabel}>
{
columns.map((col) => (
<div class="npf-col">
<h3 class="npf-col-heading">{col.heading}</h3>
<span class="npf-underline" aria-hidden="true"><span /><span /></span>
<ul class="npf-list" role="list">
{col.links.map((link) => (
<li>
<a class="npf-link" href={link.href}>
<svg class="npf-bullet" viewBox="0 0 18 18" aria-hidden="true" focusable="false">
<path d="M4.5 5l4 4-4 4M9.5 5l4 4-4 4" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" />
</svg>
<span>{link.label}</span>
</a>
</li>
))}
</ul>
</div>
))
}
{
contacts.length > 0 && (
<div class="npf-col npf-col--contact">
<h3 class="npf-col-heading">{contactHeading}</h3>
<span class="npf-underline" aria-hidden="true"><span /><span /></span>
<ul class="npf-list npf-list--contact" role="list">
{contacts.map((c) => (
<li>
<a class="npf-link" href={c.href}>
<svg class="npf-contact-icon" viewBox="0 0 16 16" aria-hidden="true" focusable="false" set:html={CONTACT_ICONS[c.icon]} />
<span>{c.label}</span>
</a>
</li>
))}
</ul>
</div>
)
}
</nav>
)}
</div>
</div>
)}
</div>
)}
{copyText && (
<div class="npf-bottom">
<p class="npf-copy">
{
copyrightHref ? (
<a class="npf-copy-link" href={copyrightHref}>
{copyText}
</a>
) : (
copyText
)
}
</p>
</div>
)}
</footer>
)}
<style>
.npf {
--npf-navy: var(--tg-navy, #020e28);
--npf-orange: var(--tg-orange, #fd5523);
--npf-hover: #0e2d3a;
--npf-soft: #b0b2b8;
--npf-display: var(--tg-font-display, Outfit, ui-sans-serif, system-ui, sans-serif);
--npf-body: var(--tg-font-body, "DM Sans", ui-sans-serif, system-ui, sans-serif);
--npf-dur: 0.4s;
--npf-ease: cubic-bezier(0.44, 0, 0.56, 1);
box-sizing: border-box;
width: 100%;
background: var(--npf-navy);
color: #fff;
font-family: var(--npf-body);
overflow: hidden;
}
.npf *,
.npf *::before,
.npf *::after {
box-sizing: border-box;
}
.npf ul,
.npf p,
.npf h2,
.npf h3 {
margin: 0;
}
.npf ul {
padding: 0;
list-style: none;
}
.npf-main {
display: flex;
flex-direction: column;
gap: 48px;
padding: 60px 20px 40px;
}
/* ---- newsletter row ---- */
.npf-top {
display: flex;
flex-direction: column;
gap: 30px;
width: 100%;
max-width: 1224px;
margin: 0 auto;
}
.npf-heading {
max-width: 420px;
font: 600 32px/1.2 var(--npf-display);
color: #fff;
text-transform: capitalize;
}
.npf-form {
width: 100%;
}
.npf-field {
position: relative;
display: flex;
flex-direction: column;
gap: 20px;
}
.npf-input {
width: 100%;
height: 48px;
padding: 0 25px;
border: 1px solid rgb(255 255 255 / 0.18);
border-radius: 50px;
background: rgb(255 255 255 / 0.07);
color: #fff;
font: 400 15px/1 var(--npf-body);
transition:
background-color var(--npf-dur) var(--npf-ease),
border-color var(--npf-dur) var(--npf-ease),
box-shadow var(--npf-dur) var(--npf-ease);
}
.npf-input::placeholder {
color: #d4d6db;
opacity: 1;
}
.npf-input:focus {
outline: none;
border-color: var(--npf-orange);
background: rgb(255 255 255 / 0.1);
box-shadow: 0 0 0 3px rgb(253 85 35 / 0.35);
}
.npf-submit {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 10px;
height: 48px;
padding: 0 19px 0 25px;
border: 0;
border-radius: 50px;
background: var(--npf-orange);
color: #fff;
font: 600 16px/1 var(--npf-display);
cursor: pointer;
transition: background-color var(--npf-dur) var(--npf-ease);
}
.npf-submit:hover {
background: var(--npf-hover);
}
.npf-plane {
width: 20px;
height: 20px;
flex: none;
}
.npf-rule {
width: 100%;
max-width: 1224px;
height: 1px;
margin: 0 auto;
background: rgb(255 255 255 / 0.2);
}
/* ---- brand block and columns ---- */
.npf-links {
display: flex;
flex-direction: column;
gap: 36px;
width: 100%;
max-width: 1224px;
margin: 0 auto;
}
.npf-brandblock {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 24px;
max-width: 320px;
}
.npf-brand {
display: inline-flex;
align-items: center;
gap: 9px;
min-height: 44px;
color: #fff;
text-decoration: none;
border-radius: 6px;
}
.npf-mark {
width: 46px;
height: 30px;
flex: none;
}
.npf-wordmark {
font: 600 24px/1 var(--npf-display);
letter-spacing: -0.01em;
}
.npf-logo-img {
display: block;
height: 30px;
width: auto;
}
.npf-blurb {
color: var(--npf-soft);
font: 400 16px/21px var(--npf-body);
}
.npf .npf-social {
display: flex;
align-items: center;
gap: 10px;
}
.npf-social-link {
position: relative;
display: grid;
place-items: center;
width: 36px;
height: 36px;
border-radius: 100px;
background: rgb(255 255 255 / 0.07);
color: #fff;
transition: background-color var(--npf-dur) var(--npf-ease);
}
/* 36px circle as measured, 44px target. */
.npf-social-link::after {
content: "";
position: absolute;
inset: -4px;
border-radius: inherit;
}
.npf-social-link svg {
width: 15px;
height: 15px;
}
.npf-social-link:hover {
background: var(--npf-orange);
}
.npf-cols {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 36px;
}
.npf-col-heading {
font: 700 24px/1 var(--npf-display);
color: #fff;
}
.npf-underline {
display: flex;
gap: 6px;
margin: 18px 0 20px;
}
.npf-underline span {
display: block;
width: 18px;
height: 2px;
background: #fff;
}
.npf-underline span + span {
width: 83px;
background: var(--npf-orange);
}
.npf .npf-list {
display: flex;
flex-direction: column;
gap: 0;
}
.npf-link {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 44px;
color: var(--npf-soft);
font: 400 16px/21px var(--npf-body);
text-decoration: none;
border-radius: 4px;
transition: color var(--npf-dur) var(--npf-ease);
}
.npf-link:hover {
color: var(--npf-orange);
}
.npf-bullet {
width: 18px;
height: 18px;
flex: none;
}
.npf-contact-icon {
width: 16px;
height: 16px;
flex: none;
}
.npf-list--contact .npf-link {
overflow-wrap: anywhere;
}
/* ---- the panel ---- */
.npf--panel .npf-panel {
position: relative;
width: 100%;
max-width: 1385px;
margin: 0 auto;
padding: 30px 16px;
border-radius: 20px;
/* A navy fill with a 10% white wash inside a 2px gradient hairline,
as measured: border-box gradient under a padding-box fill. */
border: 2px solid transparent;
background:
linear-gradient(107deg, rgb(255 255 255 / 0.1), rgb(255 255 255 / 0.1)) padding-box,
linear-gradient(var(--npf-navy), var(--npf-navy)) padding-box,
linear-gradient(107deg, rgb(255 255 255 / 0.2), rgb(255 255 255 / 0.5)) border-box;
}
/* ---- copyright ---- */
.npf-bottom {
padding: 0 20px 40px;
text-align: center;
}
.npf-copy {
color: #fff;
font: 400 16px/21px var(--npf-body);
}
.npf-copy-link {
color: inherit;
text-decoration: none;
border-radius: 4px;
transition: color var(--npf-dur) var(--npf-ease);
}
.npf-copy-link:hover {
color: var(--npf-orange);
}
.npf--band .npf-main {
padding-bottom: 60px;
}
.npf--band .npf-bottom {
padding: 30px 20px;
background: var(--npf-orange);
}
/* Orange on orange would vanish: on the band the link turns navy. */
.npf--band .npf-copy-link:hover {
color: var(--npf-navy);
}
/* ---- focus ---- */
.npf a:focus-visible,
.npf button:focus-visible {
outline: 2px solid var(--npf-orange);
outline-offset: 3px;
}
.npf--band .npf-copy-link:focus-visible,
.npf-submit:focus-visible {
outline-color: #fff;
}
.npf-sr {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
}
/* ---- appear on scroll ----
The hidden start pose applies only once the script has marked the root
ready, so without JS or under reduced motion everything is at rest. */
.npf-rise {
transition:
opacity 0.8s var(--npf-ease) calc(var(--npf-i, 0) * 0.1s),
transform 0.8s var(--npf-ease) calc(var(--npf-i, 0) * 0.1s);
}
.npf[data-npf-ready]:not([data-in]) .npf-rise {
opacity: 0;
transform: translateY(40px);
}
/* ---- tablet ---- */
@media (min-width: 810px) {
.npf-main {
gap: 64px;
padding: 80px 30px 40px;
}
.npf-top {
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 40px;
}
.npf-heading {
font-size: 44px;
line-height: 1.2;
}
.npf-form {
flex: 0 1 443px;
}
.npf-field {
display: block;
}
.npf-input {
height: 56px;
padding-right: 160px;
}
.npf-submit {
position: absolute;
top: 0;
right: 0;
height: 56px;
}
.npf-links {
gap: 56px;
}
.npf-cols {
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 30px;
}
.npf--panel .npf-panel {
padding: 60px 40px;
border-radius: 40px;
}
.npf .npf-list {
gap: 2px;
}
}
/* ---- desktop: brand block left, three columns right ---- */
@media (min-width: 1200px) {
.npf-main {
gap: 80px;
padding: 80px 20px 40px;
}
.npf--band .npf-main {
padding-bottom: 88px;
}
.npf-heading {
font-size: 48px;
line-height: 57.6px;
}
.npf-links {
flex-direction: row;
justify-content: space-between;
align-items: flex-start;
gap: 40px;
}
.npf-brandblock {
flex: 0 0 307px;
gap: 40px;
}
.npf-brandblock .npf-blurb {
margin-top: -10px;
}
.npf-cols {
flex: 0 1 815px;
gap: 0;
}
.npf--panel .npf-panel {
padding: 78px 20px;
border-radius: 50px;
}
.npf-top,
.npf-links {
max-width: 1224px;
}
}
/* Band variant: the columns keep the reference's 80px rhythm, no panel. */
.npf--band .npf-panel {
width: 100%;
}
@media (prefers-reduced-motion: reduce) {
.npf {
--npf-dur: 1ms;
}
.npf-rise {
transition: none;
}
}
@media (forced-colors: active) {
.npf-submit,
.npf-social-link {
border: 1px solid CanvasText;
}
}
</style>
<script>
/*
* Appear-on-scroll for the footer's top content. One IntersectionObserver
* per instance, scoped to its root, torn down on astro:before-swap. It sets
* `data-in` once and stops watching. Under reduced motion, without
* IntersectionObserver, or with animate={false}, nothing ever starts hidden.
*/
function initNewsletterPanelFooter(root: HTMLElement) {
if (root.dataset.npfReady === "") return;
if (!root.hasAttribute("data-animate")) return;
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduced || !("IntersectionObserver" in window)) {
root.dataset.in = "";
return;
}
const controller = new AbortController();
const { signal } = controller;
root.dataset.npfReady = "";
const observer = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
root.dataset.in = "";
observer.disconnect();
}
},
{ threshold: 0.12 },
);
observer.observe(root);
signal.addEventListener("abort", () => observer.disconnect());
document.addEventListener(
"astro:before-swap",
() => {
controller.abort();
delete root.dataset.npfReady;
},
{ once: true, signal },
);
}
const boot = () =>
document.querySelectorAll<HTMLElement>("[data-npf-root]").forEach(initNewsletterPanelFooter);
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", boot, { once: true });
} else {
boot();
}
document.addEventListener("astro:page-load", boot);
</script>
===== END src/components/NewsletterPanelFooter.astro =====
===== FILE (supporting): src/components/NewsletterPanelFooter.md =====
# Newsletter panel footer
A navy footer: "Subscribe Our Newsletter" beside a pill email field with an
orange Subscribe button, then the brand block (logo, blurb, four social
circles) and three columns (Quick Links, Our Services, Contact Us) inside a
rounded, slightly lighter panel, and a centred copyright line.
Catalog page: `/components/newsletter-panel-footer`
## Two variants, both measured
The reference uses two footers, not one:
- **`variant="panel"`** (default): the inner pages. The brand block and
columns sit in a panel 20px from each edge, radius 50px, with a 10% white
wash over navy inside a 2px gradient hairline
(`rgba(255,255,255,.2)` → `.5` at 107°). White copyright line below.
- **`variant="band"`**: the home page. A 1px `rgba(255,255,255,.2)` rule
divides the newsletter row from the columns, and the copyright sits on a
30px-padded orange band.
## Files required
**`NewsletterPanelFooter.astro`** — the whole component. No imports, no
packages, no images. `NewsletterPanelFooter.md` — this file, optional.
## Basic usage
```astro
---
import NewsletterPanelFooter from "../components/NewsletterPanelFooter.astro";
---
<NewsletterPanelFooter
heading="Subscribe Our Newsletter"
newsletter={{ action: "/api/subscribe" }}
brandName="Northpass"
columns={[{ heading: "Quick Links", links: [{ label: "About", href: "/about" }] }]}
contacts={[{ icon: "phone", label: "+1 (503) 555-0148", href: "tel:+15035550148" }]}
/>
```
Put it after `<main>` as a direct child of `<body>`.
## Business details
Nothing about the business is built in. With no props the footer renders
nothing; the newsletter row, brand block, each column, the panel (or the
band variant's rule) and the copyright line appear only when they have data.
The copyright defaults to `Copyright © {year} <brandName>. All Rights
Reserved.` when a `brandName` is given, and is omitted otherwise. The catalog
preview's Northpass content is demo data from the registry, not a default.
## The form has no back end
The form is **off by default**. `newsletter={{}}` shows it with `action`
unset, so the browser submits to the current URL — only useful for a demo.
Point it somewhere with `newsletter={{ action, method }}`. The field is
`type="email"`, `required`, `autocomplete="email"`, submitted as `email`
(`newsletter.name`), with a visually hidden `<label>` ("Email address").
There is no success or error state.
## Props
| Prop | Type | Default | What it does |
| :--- | :--- | :--- | :--- |
| `variant` | `"panel" \| "band"` | `"panel"` | See above. |
| `heading` | `string?` | — | The newsletter heading. |
| `newsletter` | `{ action?, method?, name?, label?, placeholder?, buttonLabel? } \| null` | `null` | The form; `{}` shows it with generic labels ("Email address", "Subscribe"). |
| `brandName`, `brandHref` | `string?` | —, `"/"` | No name and no `logo` drops the brand link. |
| `logo` | `{ src, alt, width?, height? }?` | — | Replaces the drawn mark (use a light logo). |
| `blurb` | `string?` | — | |
| `social` | `{ network, href, label }[]?` | `[]` | Generic glyphs: facebook, x, instagram, linkedin, youtube, link. |
| `columns` | `{ heading, links: { label, href }[] }[]?` | `[]` | Link columns with » bullets. |
| `contactHeading` | `string?` | `"Contact Us"` | |
| `contacts` | `{ icon: "email" \| "phone" \| "address", label, href }[]?` | `[]` | Empty drops the column. |
| `copyright` | `string?` | `Copyright © {year} <brandName>. All Rights Reserved.` | `{year}` is replaced. No `brandName`: no line. `""` hides it. |
| `copyrightHref` | `string?` | — | Makes the line a link. |
| `year` | `number?` | build year | |
| `animate` | `boolean?` | `true` | The scroll-in rise. |
| `navLabel` | `string?` | `"Footer"` | Name of the columns' `<nav>`. |
| `fontStylesheet` | `string?` | Outfit + DM Sans | `""` skips it. |
| `id` | `string?` | — | Root id; seeds the input id. |
## Behaviour
**Hovers** (0.4s `cubic-bezier(.44,0,.56,1)`, the measured copyright
transition):
| Element | Rest | Hover |
| :--- | :--- | :--- |
| Column links | `#b0b2b8` text and » bullet | both `#fd5523` |
| Contact links | `#b0b2b8` with icon | `#fd5523` |
| Social circles | 36px, `rgba(255,255,255,.07)` | `#fd5523` |
| Subscribe | `#fd5523` | `#0e2d3a` |
| Copyright link | white | `#fd5523` (navy on the orange band) |
The email field focuses with an orange ring.
**Appear on scroll.** The newsletter row and the link block start at
`opacity 0, translateY(40px)` and settle over 0.8s, 0.1s apart, once 12% of
the footer is in view. One IntersectionObserver per instance, torn down on
`astro:before-swap`. The hidden pose applies only after the script marks the
root `data-npf-ready`, so without JS, under reduced motion or with
`animate={false}` everything is simply at rest.
**Layout.** From 1200px: heading left, 443px form right; brand block 307px
left, three 272px columns right. 810–1199px: brand block above three
columns. Below 810px: one column, Subscribe stacked full width under the
field, panel radius 20px.
## Accessibility
- `<footer>` (contentinfo when a direct child of `<body>`); the columns are
one `<nav aria-label="Footer">` with `<h3>` headings; the newsletter
heading is an `<h2>`.
- Every link at least 44px tall; social circles have a 44px hit area and
`aria-label`s; visible focus rings.
## Images and credits
None. The mark, bullets, icons and glyphs are original inline SVG.
## Approximations
- **The task brief described one footer**; the reference has two (panel on
inner pages, band on home). Both are built; the panel is the default.
- **Link rows are 44px targets with a 2px gap** (46px pitch) against a
measured 45px (Quick Links) and 41px (Our Services) pitch; contact rows
46px against 51px.
- **Phone:** the reference form runs 5px short of the right edge; here it is
symmetric at 20px. Phone headings are 32px, read from the screenshot.
- **Tablet** was not captured; the stacked brand-over-columns layout is a
reading.
===== END src/components/NewsletterPanelFooter.md =====
--- VERIFIED IN THE COMPONENT LIBRARY ---
Enrolled for verification but never run.
No verification run has been recorded.
No checks have passed yet.
No usable source fingerprint, so these results cannot be tied to the files below.
--- NOT PROVEN ---
- ClientRouter: event-tested only. Handlers respond to dispatched lifecycle events; this has NOT been certified against a real <ClientRouter /> transition.
--- 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.