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

- ID: `cmp_footer_newsletter_01`
- Slug: `footer-newsletter-01`
- Version: `1.0.0` (current)
- Status: published
- Published: 2026-09-30T23:47:02Z
- Updated: 2026-09-30
- Available versions: `1.0.0`
- Kind: section
- Primary category: `footers`
- Detail page: https://pagesugar.com/components/footer-newsletter-01?variant=blue
- Preview: https://pagesugar.com/preview/footer-newsletter-01

## Variants

| Variant | Label | Default | Artifact digest |
| --- | --- | --- | --- |
| `neutral` | Neutral | yes | `sha256-233a08b4a32ad28cf2759b7a954545ba8e2a8e794bb5bf930f7b334061f90733` |
| `dark` | Dark | no | `sha256-dd3c6a933ec58dcad7bed4a2aa30bc9809754b01bca0e343a6104e0c1fac600e` |
| `blue` | Blue accent | no | `sha256-83b3c4cc84baf51c8a462a563211d13817fea011325c941c52888333b82d81ee` |

## Runtime and compatibility

- Runtime: svelte
- Svelte: 5
- SvelteKit required: no (portable Svelte component)
- Tailwind CSS: 4
- SSR: supported
- Requires client-side JavaScript: yes
- Integration level: service-required
- Appearance modes: light
- Suggested directory: `src/lib/components/footer-newsletter-01`

## Dependencies

No third-party runtime packages.

## Services

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

Configuration:

- Implement onSubscribe to call your own endpoint (for example a SvelteKit +server.ts route) 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.

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

## Usage

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

Required props: `onSubscribe`, `signupTitle`, `copyright`

```svelte
<!-- 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' }]}
/>
```

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 onSubscribe grows 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.

## Usage guide

### 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`.

```ts
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:

```css
.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

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `onSubscribe` | (email: string) =\> Promise\<{ ok: true } \| { ok: false; message: string }\> | yes |  | 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 |  | Heading for the signup region. Say what arrives, not that there is a newsletter. |
| `copyright` | `string` | yes |  | Copyright line in the bottom bar, including the year; the component does not compute it. |
| `signupDescription` | `string` | no |  | What subscribers get and how often. |
| `privacyNote` | `string` | no |  | 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

Connect 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 onSubscribe to 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: signupTitle says what arrives, signupDescription says what is in it and how often, privacyNote says 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-accent and --footer-newsletter-on-accent colour 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-rule draws the line the signup and every link group hang from; --footer-newsletter-hairline draws the footer's top edge and the small-print divider.
- Field: --footer-newsletter-field fills the input and --footer-newsletter-control-border draws 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-scheme to dark on a dark surface so the caret and autofill fill follow it.
- Columns: the lgCols map 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.

| Token | Public CSS variable |
| --- | --- |
| `accent` | `--footer-newsletter-accent` |
| `onAccent` | `--footer-newsletter-on-accent` |
| `ink` | `--footer-newsletter-ink` |
| `muted` | `--footer-newsletter-muted` |
| `rule` | `--footer-newsletter-rule` |
| `hairline` | `--footer-newsletter-hairline` |
| `surface` | `--footer-newsletter-surface` |
| `field` | `--footer-newsletter-field` |
| `controlBorder` | `--footer-newsletter-control-border` |
| `success` | `--footer-newsletter-success` |
| `error` | `--footer-newsletter-error` |
| `colorScheme` | `--footer-newsletter-color-scheme` |

## Accessibility

- 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=email and 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-disabled rather 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-invalid is not set, because a service failure does not mean the address is invalid.
- Success and error each carry an aria-hidden mark 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 #18181b about 17.7:1, muted #52525b about 7.7:1, success #15803d about 5.0:1, error #b91c1c about 6.5:1, field boundary #8b8b94 about 3.4:1. On the dark palette: muted #a1a1aa about 7.8:1 and the field boundary #6b6b70 about 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.

## License

- Declared source: MIT
- Default license approval is pending. See https://pagesugar.com/docs/license.

## Source

- Palette: Blue accent (`blue`)
- Entry: `FooterNewsletter.svelte`
- Suggested directory: `src/lib/components/footer-newsletter-01`
- Files: 1
- Artifact digest: `sha256-83b3c4cc84baf51c8a462a563211d13817fea011325c941c52888333b82d81ee`

Paths below are relative to the suggested directory. Copy the files as they are;
they import nothing from this site.

#### `FooterNewsletter.svelte`

Role: entry · 17689 bytes · SHA-256 `eb60352fbedf311c744dd11cb2307d12db09790d578474fd27f2c173f5d9c3fe`

```svelte
<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">&nbsp;{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>
```

## Artifacts

### Blue accent (`blue`)

- Artifact digest: `sha256-83b3c4cc84baf51c8a462a563211d13817fea011325c941c52888333b82d81ee`
- Entry: `FooterNewsletter.svelte`
- Receipt: https://pagesugar.com/artifacts/cmp_footer_newsletter_01/1.0.0/blue/sha256-83b3c4cc84baf51c8a462a563211d13817fea011325c941c52888333b82d81ee/manifest.json
- Bundle: https://pagesugar.com/artifacts/cmp_footer_newsletter_01/1.0.0/blue/sha256-83b3c4cc84baf51c8a462a563211d13817fea011325c941c52888333b82d81ee/bundle.zip (10865 bytes, sha256 `d89073e2861e7047a26c153193eaf00714582f279f5a962c63a6b6016bf7ea51`)

Files:

- `FooterNewsletter.svelte` (entry, 17689 bytes): https://pagesugar.com/artifacts/cmp_footer_newsletter_01/1.0.0/blue/sha256-83b3c4cc84baf51c8a462a563211d13817fea011325c941c52888333b82d81ee/source/FooterNewsletter.svelte
