Newsletter footer
A site footer led by a newsletter signup, one field-and-button row under a short title, with link columns beside or below it and a copyright bar. Connect your own email service.
cmp_footer_newsletter_01 Before you use this component
- Requires an external service: Email subscription provider. The backend is not included; see responsibilities.
- Requires client-side JavaScript to work.
Preview
Demonstration — no data is sent
Fit to the available width. The frame follows the height of its content; previews taller than the maximum auto-height scroll inside it.
Give this component to your coding agent Copy a prompt that fetches this exact version and palette through the PageSugar MCP server.
Using the PageSugar MCP server, fetch component cmp_footer_newsletter_01 version 1.0.0 with variant "blue", first inspect its requirements and license status and confirm this project uses Svelte 5 and Tailwind CSS 4. Retrieve every manifest file, including binary assets and any manifest-only response files, preserving relative paths. Then integrate the source and follow its usage notes. Run project checks, review the browser result and report anything unverified. Do not substitute another version or invent missing files.Not connected yet? Set up the MCP server
Code
- Palette
- Blue accent
- Version
- 1.0.0
- Digest
Full digest
sha256-83b3c4cc84baf51c8a462a563211d13817fea011325c941c52888333b82d81ee
<script lang="ts" module>
export type SubscribeResult = { ok: true } | { ok: false; message: string };
export interface FooterLink {
label: string;
href: string;
/** Opens in a new tab and says so, visibly and to assistive technology. */
external?: boolean;
}
export interface FooterColumn {
heading: string;
links: FooterLink[];
}
</script>
<script lang="ts">
import { onMount } from 'svelte';
interface Props {
onSubscribe: (email: string) => Promise<SubscribeResult>;
signupTitle: string;
copyright: string;
signupDescription?: string;
privacyNote?: string;
columns?: FooterColumn[];
legalLinks?: FooterLink[];
signupPlacement?: 'side-column' | 'top-band';
headingLevel?: 2 | 3 | 4;
label?: string;
placeholder?: string;
submitLabel?: string;
pendingLabel?: string;
successMessage?: string;
errorMessage?: string;
navLabel?: string;
legalLabel?: string;
newTabLabel?: string;
}
let {
onSubscribe,
signupTitle,
copyright,
signupDescription,
privacyNote,
columns = [],
legalLinks = [],
signupPlacement = 'side-column',
headingLevel = 2,
label = 'Email address',
placeholder = 'you@example.com',
submitLabel = 'Subscribe',
pendingLabel = 'Subscribing…',
successMessage = 'Thanks for subscribing.',
errorMessage = 'Something went wrong. Please try again.',
navLabel = 'Footer',
legalLabel = 'Legal',
newTabLabel = '(opens in new tab)'
}: Props = $props();
const uid = $props.id();
const headingTag = $derived(`h${headingLevel}`);
const groups = $derived(columns.filter((column) => column.links.length > 0));
/* With no columns to sit beside, the signup takes the band layout rather than a stranded third. */
const beside = $derived(signupPlacement === 'side-column' && groups.length > 0);
/*
* Beside the signup the groups share three fifths of the width, so four is the most one row
* holds; across the full width, five. Complete static classes for each track count.
*/
const lgCols: Record<number, string> = {
1: 'lg:grid-cols-1',
2: 'lg:grid-cols-2',
3: 'lg:grid-cols-3',
4: 'lg:grid-cols-4',
5: 'lg:grid-cols-5'
};
const tracks = $derived(Math.min(groups.length, beside ? 4 : 5));
const columnsClass = $derived(lgCols[tracks]);
/* Below lg the groups pair up; an odd last group takes the whole row and splits its list in two. */
const isWide = (index: number) => groups.length % 2 === 1 && index === groups.length - 1;
/*
* From lg, a group left alone on the last row would strand one narrow track beside empty ones,
* so it spans the row and its links flow across the same tracks as the groups above.
*/
const isLone = (index: number) =>
groups.length > tracks && groups.length % tracks === 1 && index === groups.length - 1;
let email = $state('');
let status = $state<'idle' | 'pending' | 'success' | 'error'>('idle');
let failure = $state('');
// The submit button stays disabled until hydration, so the form never falls back to a native
// GET submission that would put the address in the URL. The links need no script at all.
let hydrated = $state(false);
onMount(() => {
hydrated = true;
});
const describedBy = $derived(
[status === 'error' ? `${uid}-message` : '', privacyNote ? `${uid}-note` : '']
.filter(Boolean)
.join(' ') || undefined
);
// Feedback describes the submission it followed; once the address changes, it goes.
function clearFeedback() {
if (status === 'success' || status === 'error') {
status = 'idle';
failure = '';
}
}
async function handleSubmit(event: SubmitEvent) {
event.preventDefault();
if (status === 'pending') return;
status = 'pending';
failure = '';
try {
const result = await onSubscribe(email.trim());
if (result.ok) {
status = 'success';
email = '';
} else {
status = 'error';
failure = result.message || errorMessage;
}
} catch {
status = 'error';
failure = errorMessage;
}
}
/*
* The arrow travels with the label's last word, so a wrapped label never leaves it alone on a
* line. A last word too long to hold together is left free to break.
*/
const splitLabel = (text: string) => {
const trimmed = text.trim();
const at = trimmed.lastIndexOf(' ') + 1;
return { head: trimmed.slice(0, at), tail: trimmed.slice(at) };
};
/* One link style for every list: muted at rest, ink on hover, a 44 px row on a coarse pointer. */
const linkClass =
'inline-flex max-w-full min-h-11 min-w-11 items-center rounded-sm text-(--_muted) transition-colors duration-150 ease-(--_ease) hover:text-(--_ink) focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-(--_accent) pointer-fine:min-h-0 pointer-fine:min-w-0';
</script>
{#snippet anchor(link: FooterLink)}
<a
href={link.href}
class={linkClass}
target={link.external ? '_blank' : undefined}
rel={link.external ? 'noopener noreferrer' : undefined}
>
<span class="min-w-0 [overflow-wrap:anywhere]">
{#if link.external}
{@const { head, tail } = splitLabel(link.label)}
{head}<span class={tail.length <= 24 ? 'whitespace-nowrap' : undefined}
>{tail}<svg
class="ms-0.5 inline size-3.5 align-[-0.125em] rtl:-scale-x-100"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"><path d="M4.5 11.5l7-7M6 4.5h5.5V10" /></svg
></span
><span class="sr-only"> {newTabLabel}</span>
{:else}
{link.label}
{/if}
</span>
</a>
{/snippet}
<!--
The move: the signup leads the footer as one field-and-button row under a 24 px title, and the
signup and every link column hang from the same ink rule. Beside the columns (side-column) the
invitation and the index start on one line; above them (top-band, or whenever there are no
columns) the signup splits its offer from its form across that rule. Below lg everything
stacks in reading order, signup first, each part still hanging from its rule.
-->
<footer
class="footer-newsletter border-t border-(--_hairline) bg-(--_surface) px-4 pt-16 pb-8 text-start sm:px-6 sm:pt-24 lg:px-8 lg:pt-32"
>
<div class="mx-auto max-w-6xl">
<div class={['grid gap-12', beside && 'lg:grid-cols-[minmax(0,2fr)_minmax(0,3fr)]']}>
<section
aria-labelledby="{uid}-title"
class={[
'min-w-0 border-t border-(--_rule) pt-4',
!beside && 'lg:grid lg:grid-cols-2 lg:items-start lg:gap-x-12'
]}
>
<div class="min-w-0">
<svelte:element
this={headingTag}
id="{uid}-title"
class="footer-newsletter__tracked footer-newsletter__phrase text-2xl leading-[1.2] font-semibold tracking-[-0.02em] text-balance [overflow-wrap:anywhere] text-(--_ink)"
>
{signupTitle}
</svelte:element>
{#if signupDescription}
<p
class="footer-newsletter__measure footer-newsletter__phrase mt-4 max-w-[30em] text-base leading-6 text-pretty [overflow-wrap:anywhere] text-(--_muted)"
>
{signupDescription}
</p>
{/if}
</div>
<form class={['min-w-0', beside ? 'mt-8' : 'mt-8 lg:mt-0']} onsubmit={handleSubmit}>
<label for="{uid}-email" class="block text-[13px] leading-5 font-medium text-(--_ink)"
>{label}</label
>
<!--
One shape, not two boxes: the shell draws the field's boundary and takes the focus
ring, the input is transparent inside it, and the button runs flush to the shell's end
edge with the shell's own end radius, so the two read as halves of one control.
Where the pointer is coarse, the field and the button clear 44 px.
-->
<div class="footer-newsletter__shell mt-2 flex max-w-md items-stretch rounded-lg">
<input
id="{uid}-email"
name="email"
type="email"
autocomplete="email"
spellcheck="false"
required
bind:value={email}
oninput={clearFeedback}
{placeholder}
readonly={status === 'pending'}
aria-describedby={describedBy}
class="footer-newsletter__field min-h-10 min-w-0 flex-1 rounded-s-lg bg-transparent px-3 text-base text-(--_ink) placeholder:text-(--_muted) focus:outline-none sm:text-sm pointer-coarse:min-h-11"
/>
<!-- Both labels share one grid cell, so the button keeps one width while pending. -->
<button
type="submit"
disabled={!hydrated}
aria-disabled={status === 'pending' ? 'true' : undefined}
class="footer-newsletter__button grid min-h-10 max-w-[45%] shrink-0 items-center rounded-e-lg bg-(--_accent) px-4 py-2 text-center text-sm leading-5 font-medium text-balance text-(--_on-accent) transition-[background-color] duration-140 ease-(--_ease) hover:bg-(--_accent-hover) focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-(--_accent) active:scale-[.98] disabled:cursor-not-allowed disabled:opacity-60 aria-disabled:cursor-progress aria-disabled:bg-(--_accent) motion-reduce:transition-none motion-reduce:active:scale-100 pointer-coarse:min-h-11"
>
<span
class={['col-start-1 row-start-1', status === 'pending' && 'invisible']}
aria-hidden={status === 'pending' ? 'true' : undefined}>{submitLabel}</span
>
<span
class={['col-start-1 row-start-1', status !== 'pending' && 'invisible']}
aria-hidden={status !== 'pending' ? 'true' : undefined}>{pendingLabel}</span
>
</button>
</div>
{#if privacyNote}
<p
id="{uid}-note"
class="footer-newsletter__measure footer-newsletter__phrase mt-3 max-w-[30em] text-[13px] leading-5 text-pretty [overflow-wrap:anywhere] text-(--_muted)"
>
{privacyNote}
</p>
{/if}
<!--
A persistent polite region, last in the column, reserving two lines so a message
pushes nothing. Success and error each carry a mark as well as a colour.
-->
<div class="mt-3 min-h-10 text-sm leading-5" aria-live="polite" aria-atomic="true">
{#if status === 'pending'}
<p class="sr-only">{pendingLabel}</p>
{:else if status === 'success'}
<p id="{uid}-message" class="flex gap-2 font-medium text-(--_success)">
<span class="flex h-5 shrink-0 items-center">
<svg
class="size-4"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M13.5 4.5 6.5 12 2.5 8.2" />
</svg>
</span>
<span class="min-w-0 [overflow-wrap:anywhere]">{successMessage}</span>
</p>
{:else if status === 'error'}
<p id="{uid}-message" class="flex gap-2 font-medium text-(--_error)">
<span class="flex h-5 shrink-0 items-center">
<svg
class="size-4"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M8 1.5 15 14H1z" />
<path d="M8 6.5v3.25M8 12.1v.01" />
</svg>
</span>
<span class="min-w-0 [overflow-wrap:anywhere]">{failure}</span>
</p>
{/if}
</div>
</form>
</section>
{#if groups.length}
<nav aria-label={navLabel} class="min-w-0">
<div class={['grid grid-cols-2 items-start gap-x-8 gap-y-12', columnsClass]}>
{#each groups as column, index (index)}
<div
class={[
'min-w-0 border-t border-(--_rule) pt-4',
isWide(index) && 'col-span-2',
isLone(index) ? 'lg:col-span-full' : isWide(index) && 'lg:col-span-1'
]}
>
<svelte:element
this={headingTag}
id="{uid}-heading-{index}"
class="text-sm leading-5 font-semibold text-balance [overflow-wrap:anywhere] text-(--_ink)"
>
{column.heading}
</svelte:element>
<ul
role="list"
aria-labelledby="{uid}-heading-{index}"
class={[
'mt-4 grid text-sm leading-5 pointer-coarse:mt-0 pointer-fine:gap-y-3',
(isWide(index) || isLone(index)) && 'gap-x-8',
isWide(index) && 'grid-cols-2',
isLone(index) ? columnsClass : isWide(index) && 'lg:grid-cols-1'
]}
>
{#each column.links as link, linkIndex (linkIndex)}
<li class="min-w-0">{@render anchor(link)}</li>
{/each}
</ul>
</div>
{/each}
</div>
</nav>
{/if}
</div>
<div
class="mt-12 flex flex-col gap-3 border-t border-(--_hairline) pt-8 text-[13px] leading-5 text-(--_muted) sm:flex-row sm:items-start sm:justify-between sm:gap-8"
>
<p class="max-w-[30em] min-w-0 text-pretty [overflow-wrap:anywhere] tabular-nums">
{copyright}
</p>
{#if legalLinks.length}
<ul
role="list"
aria-label={legalLabel}
class="flex flex-wrap gap-x-8 sm:justify-end pointer-fine:gap-y-3"
>
{#each legalLinks as link, index (index)}
<li class="min-w-0">{@render anchor(link)}</li>
{/each}
</ul>
{/if}
</div>
</div>
</footer>
<style>
/* Public tokens: set --footer-newsletter-* on this footer or any ancestor to retone it. */
.footer-newsletter {
--_accent: var(--footer-newsletter-accent, #1d4ed8);
--_on-accent: var(--footer-newsletter-on-accent, #ffffff);
--_ink: var(--footer-newsletter-ink, #18181b);
--_muted: var(--footer-newsletter-muted, #52525b);
--_rule: var(--footer-newsletter-rule, #18181b);
--_hairline: var(--footer-newsletter-hairline, rgb(0 0 0 / 0.08));
--_surface: var(--footer-newsletter-surface, #ffffff);
--_field: var(--footer-newsletter-field, #ffffff);
/* The lightest zinc step between 400 and 500 that holds the field's boundary at 3:1 on white. */
--_control-border: var(--footer-newsletter-control-border, #8b8b94);
--_success: var(--footer-newsletter-success, #15803d);
--_error: var(--footer-newsletter-error, #b91c1c);
--_scheme: var(--footer-newsletter-color-scheme, light);
/* Derived so one accent drives the button and its hover. Not part of the public set. */
--_accent-hover: color-mix(in srgb, var(--_accent) 86%, var(--_surface));
/* The state-change curve (§3.6), shared by the links, the shell's ring and the button's fill. */
--_ease: cubic-bezier(0.2, 0, 0, 1);
/* The browser paints the caret and the autofill fill from the declared scheme, so a dark
palette has to be able to move it with the surface. */
color-scheme: var(--_scheme);
}
/*
* Selected text takes the accent pair, so a copied address reads in the brand's colours.
* CSS, because it styles every element the footer renders rather than one.
*/
.footer-newsletter ::selection {
background-color: var(--_accent);
color: var(--_on-accent);
}
/* An inset ring rather than a border, so the button can run flush to the shell's end edge with
exactly the shell's radius and cover the ring where they meet. */
.footer-newsletter__shell {
background-color: var(--_field);
box-shadow: inset 0 0 0 1px var(--_control-border);
transition: box-shadow 140ms var(--_ease);
}
/* The ring follows the shell's radius two pixels out. Without :has() it shows whenever either
half has focus; with it, the ring belongs to the field alone and the button draws its own. */
.footer-newsletter__shell:focus-within {
box-shadow:
inset 0 0 0 1px var(--_accent),
0 0 0 2px var(--_surface),
0 0 0 4px var(--_accent);
}
@supports selector(:has(*)) {
.footer-newsletter__shell:focus-within {
box-shadow: inset 0 0 0 1px var(--_control-border);
}
.footer-newsletter__shell:has(.footer-newsletter__field:focus-visible) {
box-shadow:
inset 0 0 0 1px var(--_accent),
0 0 0 2px var(--_surface),
0 0 0 4px var(--_accent);
}
}
/* Blink and WebKit paint their own fill over an autofilled address; pin it to the field and
ink tokens and redraw the boundary above it. The delay stops the engine animating it back. */
.footer-newsletter__field:-webkit-autofill,
.footer-newsletter__field:-webkit-autofill:hover,
.footer-newsletter__field:-webkit-autofill:focus {
-webkit-text-fill-color: var(--_ink);
caret-color: var(--_ink);
-webkit-box-shadow:
inset 0 0 0 1px var(--_control-border),
inset 0 0 0 1000px var(--_field);
transition: background-color 100000s ease-in-out 0s;
}
/* Forced colours drop box-shadows, so the shell's boundary and ring come back as a border and
an outline in system colours. */
@media (forced-colors: active) {
.footer-newsletter__shell {
border: 1px solid CanvasText;
}
.footer-newsletter__shell:has(.footer-newsletter__field:focus-visible) {
outline: 2px solid Highlight;
outline-offset: 2px;
}
.footer-newsletter__button {
border: 1px solid ButtonText;
}
}
/*
* Other scripts. :lang() has no stock variant, so Japanese phrase breaking is CSS, and the
* right-to-left resets of the same three elements sit with it. Arabic and Hebrew are never
* letter-spaced; tracked text resets under right-to-left.
*/
.footer-newsletter__tracked:dir(rtl) {
letter-spacing: 0;
line-height: 1.35;
}
/* Arabic sets more characters into the same width, so the measure tightens with it. */
.footer-newsletter__measure:dir(rtl) {
max-width: 24em;
}
/* Japanese breaks cleanly at phrase boundaries rather than mid-word. */
.footer-newsletter__phrase:lang(ja) {
word-break: auto-phrase;
}
@media (prefers-reduced-motion: reduce) {
.footer-newsletter__shell {
transition: none;
}
}
</style>
Usage#
On this pageService-required: nothing is subscribed until you implement onSubscribe. The component checks the address format with the browser, calls onSubscribe(email) once per submission with the trimmed address, holds the button's width while it waits, then shows successMessage or the returned error beside a mark. Editing the address clears that feedback. It makes no requests, stores nothing, collects no consent and computes no copyright year.
- Suggested location
src/lib/components/footer-newsletter-01- Required props
onSubscribesignupTitlecopyright
Limitations
- No backend: storage, double opt-in, consent records and unsubscribe handling belong to your provider and server.
- Subscribing needs JavaScript: the submit button stays disabled until the component hydrates, so the form never falls back to a GET with the address in the URL. The links and bottom bar work without JavaScript. Add a server form action if you need a no-script signup.
- Only browser-level email format validation is performed; validate again on the server.
- Feedback reserves two lines. A longer message from
onSubscribegrows the region rather than being clipped, so keep returned messages short. - No consent checkbox. Add one in the source if your jurisdiction requires explicit consent, and record it on the server.
- Beside the signup, one row holds up to four link groups (five across the width); more wrap onto further rows, and a single group left on the last row spans it.
- No typeface is set: the component inherits the fonts of the page you place it on.
- The button takes at most 45% of the control row; long submit or pending labels wrap onto a second line inside it rather than squeezing the field.
Example
<!-- Implement /api/newsletter first. Set successMessage to the real outcome, including double opt-in if you use it. -->
<script lang="ts">
import FooterNewsletter, { type SubscribeResult } from '$lib/components/footer-newsletter-01/FooterNewsletter.svelte';
async function subscribe(email: string): Promise<SubscribeResult> {
const response = await fetch('/api/newsletter', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ email })
});
if (response.ok) return { ok: true };
return { ok: false, message: 'We could not subscribe that address. Please try again.' };
}
</script>
<FooterNewsletter
signupTitle="Planning notes, once a month"
signupDescription="What shipped, one template worth copying and a short read on running roadmaps. The first Tuesday of each month."
privacyNote="We use your address for this letter and nothing else. Every issue has a one-click unsubscribe."
successMessage="Almost done. We have sent a link to confirm your address."
onSubscribe={subscribe}
columns={[
{ heading: 'Product', links: [{ label: 'Boards', href: '/product/boards' }, { label: 'Pricing', href: '/pricing' }] },
{ heading: 'Company', links: [{ label: 'About', href: '/about' }, { label: 'Careers', href: '/careers' }] }
]}
copyright="© 2026 Your Company Ltd. All rights reserved."
legalLinks={[{ label: 'Privacy', href: '/privacy' }, { label: 'Terms', href: '/terms' }]}
/>Newsletter footer#
A site footer led by a newsletter signup. The signup is one field-and-button row under a short
title, with the link groups beside it (signupPlacement="side-column", the default) or below it
("top-band"), and a bottom bar for the copyright line and policy links. The signup and every
link group hang from the same ink rule.
Connecting a provider#
onSubscribe(email) receives the trimmed address and must resolve { ok: true } or
{ ok: false, message }. Call your own server route from it; keep provider keys on the server.
A rejected promise, or an empty message, shows errorMessage.
async function subscribe(email: string): Promise<SubscribeResult> {
const response = await fetch('/api/newsletter', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ email })
});
if (response.ok) return { ok: true };
return { ok: false, message: 'We could not subscribe that address. Please try again.' };
}If your provider sends a confirmation email, say so in successMessage.
Without JavaScript#
Every link and the bottom bar work before hydration. The submit button stays disabled until the component hydrates, so the form never submits natively and never puts an address in a URL. If you need a no-script signup, give the form a server action in the source.
Dark palette#
Set these on the footer or any ancestor:
.site-footer {
--footer-newsletter-surface: #09090b;
--footer-newsletter-field: #18181b;
--footer-newsletter-ink: #fafafa;
--footer-newsletter-muted: #a1a1aa;
--footer-newsletter-rule: #fafafa;
--footer-newsletter-hairline: rgb(255 255 255 / 0.1);
--footer-newsletter-accent: #fafafa;
--footer-newsletter-on-accent: #09090b;
--footer-newsletter-control-border: #6b6b70;
--footer-newsletter-success: #4ade80;
--footer-newsletter-error: #f87171;
--footer-newsletter-color-scheme: dark;
}Consent#
There is no consent checkbox. If you need explicit consent, add a required checkbox inside the
form in the source and record its wording, time and source on your server when onSubscribe is
called.
Props and content inputs#
On this page| Name | Type | Required | Default | Description |
|---|---|---|---|---|
onSubscribe | (email: string) => Promise<{ ok: true } | { ok: false; message: string }> | Yes | None | Called with the trimmed address on submit. Resolve { ok: true } for success or { ok: false, message } to show a recoverable error. A rejected promise or an empty message shows errorMessage. |
signupTitle | string | Yes | None | Heading for the signup region. Say what arrives, not that there is a newsletter. |
copyright | string | Yes | None | Copyright line in the bottom bar, including the year; the component does not compute it. |
signupDescription | string | No | None | What subscribers get and how often. |
privacyNote | string | No | None | Small print under the field, such as how the address is used; linked to the input with aria-describedby. |
columns | { heading: string; links: { label: string; href: string; external?: boolean }[] }[] | No | [] | Footer link groups. Groups with no links are skipped; with none, the signup spans the width. |
legalLinks | { label: string; href: string; external?: boolean }[] | No | [] | Policy links in the bottom bar. |
signupPlacement | 'side-column' | 'top-band' | No | 'side-column' | From lg, the signup sits in the start column beside the link groups, or spans the width above them with its offer and form side by side. Below lg it always leads. |
headingLevel | 2 | 3 | 4 | No | 2 | Level of the signup heading and the link group headings. |
label | string | No | 'Email address' | Visible label for the email input. |
placeholder | string | No | 'you@example.com' | Input placeholder; supplementary only. |
submitLabel | string | No | 'Subscribe' | Submit button text. |
pendingLabel | string | No | 'Subscribing…' | Button text and announcement while onSubscribe is pending. |
successMessage | string | No | 'Thanks for subscribing.' | Shown after { ok: true }. Mention the confirmation email if you use double opt-in. |
errorMessage | string | No | 'Something went wrong. Please try again.' | Fallback error text when onSubscribe rejects or returns an empty message. |
navLabel | string | No | 'Footer' | Accessible name of the link navigation landmark. |
legalLabel | string | No | 'Legal' | Accessible name of the legal links list. |
newTabLabel | string | No | '(opens in new tab)' | Screen-reader text appended to links marked external. |
Customization#
On this pageConnect onSubscribe to your provider, set the copy and links through props, and retone the footer through twelve --footer-newsletter-* variables. The accent drives the button, its hover, the focus ring and text selection.
- Integration: implement
onSubscribeto POST to your own server route, keep provider credentials on the server, and map provider errors to short, recoverable messages. - Double opt-in: if your provider sends a confirmation email, say so in
successMessage. - Copy:
signupTitlesays what arrives,signupDescriptionsays what is in it and how often,privacyNotesays what the address is used for. - Placement:
signupPlacement='side-column' puts the signup beside the links from lg; 'top-band' spans it across the width above them. With no columns, the signup always spans. - Accent:
--footer-newsletter-accentand--footer-newsletter-on-accentcolour the button. Keep on-accent at 4.5:1 against the accent and the accent at 3:1 against the surface for the focus ring. The hover shade is mixed from the accent. - Rules:
--footer-newsletter-ruledraws the line the signup and every link group hang from;--footer-newsletter-hairlinedraws the footer's top edge and the small-print divider. - Field:
--footer-newsletter-fieldfills the input and--footer-newsletter-control-borderdraws its boundary; keep the border at 3:1 against both the surface and the field. - Dark palette: surface
#09090b, field#18181b, ink#fafafa, muted#a1a1aa, rule#fafafa, hairline rgb(255 255 255 / 0.1), accent#fafafa, on-accent#09090b, control-border#6b6b70, success#4ade80, error#f87171, color-scheme dark. - Colour scheme: set
--footer-newsletter-color-schemeto dark on a dark surface so the caret and autofill fill follow it. - Columns: the
lgColsmap and the tracks value in the script decide the lg column count (four beside the signup, five across the width); below lg two groups share a row. A group left alone on the last lg row spans it and flows its links across the tracks.
Public CSS variables
| Variable | Token |
|---|---|
--footer-newsletter-accent | accent |
--footer-newsletter-on-accent | onAccent |
--footer-newsletter-ink | ink |
--footer-newsletter-muted | muted |
--footer-newsletter-rule | rule |
--footer-newsletter-hairline | hairline |
--footer-newsletter-surface | surface |
--footer-newsletter-field | field |
--footer-newsletter-control-border | controlBorder |
--footer-newsletter-success | success |
--footer-newsletter-error | error |
--footer-newsletter-color-scheme | colorScheme |
Dependencies and services#
On this pageNo additional runtime packages beyond Svelte and Tailwind CSS.
Requires Email subscription provider
A mailing list or email marketing service, reached through your own server endpoint, that stores subscribers and sends messages. This component only collects the address and calls onSubscribe.
Not implemented by this component
- Storing subscriber addresses and list membership
- Server-side email validation, rate limiting and abuse or bot protection
- Double opt-in confirmation emails where required
- Recording consent: timestamp, source and the wording the subscriber saw
- Unsubscribe links and processing, plus data deletion requests
Configuration
- Implement
onSubscribeto call your own endpoint (for example a SvelteKit+server.tsroute) that talks to the provider. - Keep provider API keys on the server; never pass them to this component or the browser.
- Return
{ ok: true }on success or{ ok: false, message }with a short, recoverable, user-facing message.
Accessibility#
On this page- The root is a <footer> (contentinfo). The signup is a <section> labelled by its heading, and the link groups sit in one <nav> named by
navLabel; each list is labelled by its group heading. - The email input has a visible label,
type=email,autocomplete=emailand spellcheck off. - Pending (visually hidden), success and error text is rendered in a persistent polite live region; the button label also changes to
pendingLabel. - While pending the button uses
aria-disabledrather than disabled so focus stays on it, the input is read-only and repeat submissions are ignored. - After a failed submission the input is described by the error (alongside the privacy note) and the address is kept for a retry;
aria-invalidis not set, because a service failure does not mean the address is invalid. - Success and error each carry an
aria-hiddenmark beside the text, so state is never signalled by colour alone. - The submit button is disabled until hydration so the form cannot submit natively; links and the bottom bar need no JavaScript.
- The shell takes a two-pixel accent ring when the field has keyboard or pointer focus; the button carries its own outline. Links show a two-pixel accent outline on focus-visible.
- Where the pointer is coarse, the field, the button and every link clear a 44 px target.
- Default contrast on white: ink
#18181babout 17.7:1, muted#52525babout 7.7:1, success#15803dabout 5.0:1, error#b91c1cabout 6.5:1, field boundary#8b8b94about 3.4:1. On the dark palette: muted#a1a1aaabout 7.8:1 and the field boundary#6b6b70about 3.8:1 against the surface. - Tracked headings reset letter-spacing under dir=rtl and the measures tighten for Arabic. External links announce that they open a new tab through
newTabLabel. - IDs come from
$props.id(), so two footers on one page stay uniquely labelled. Consumers own the heading level (headingLevel) and the landmark name when the page already has another footer navigation. - In forced-colours mode the shell's boundary and focus ring, drawn with box-shadow elsewhere, come back as a CanvasText border and a Highlight outline, and the button gains a ButtonText border.
Known limitations
- Browser-native validation bubbles are used for empty or malformed addresses; their wording and styling vary by browser.
- Contrast is computed for the shipped defaults and the two palettes; any changed colour needs re-checking.
- The hover shade uses color-mix(), which needs Chrome 111, Safari 16.2 or Firefox 113; older browsers keep the resting accent on hover.
- Without :has() (Firefox before 121) the shell's ring also shows while the button has focus, alongside the button's own outline.
- The Dark palette variant retones colours only; variants cannot set
--footer-newsletter-color-scheme. Set it to dark yourself on a dark surface. The caret follows the ink colour and the autofill fill is pinned to the field tokens either way.
Release details#
On this page- Integration
- Service required
- Requires client-side JavaScript to be interactive
- Server-side rendering supported
- Connects to your own backend for Email subscription provider
- License
MIT. Default license approval is pending; see the license status before adopting the source.
- Version history
- 1.0.0 (Published) Current release · 30 September 2026
Only the current release is available. Keep downloaded source and its receipt if you need to use it again later.