Skip to content

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.
Download ZIP

Neutral palette · 10.6 KB ZIP File receipt View as Markdown View code

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.
cmp_newsletter_signup_001 · version 2.1.2 · Neutral palette
Using the PageSugar MCP server, fetch component cmp_newsletter_signup_001 version 2.1.2 with variant "neutral", 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
Neutral
Version
2.1.2
Digest
Full digest
sha256-6c118a9d50d2c85cebe624c81cefcee028ce0c50bef55443f8c0a2da4d642fd9
NewsletterSignup.svelte Svelte · 17.0 KB Raw
<script module lang="ts">
	export type SubscribeResult = { ok: true } | { ok: false; message: string };
</script>

<script lang="ts">
	import { onMount } from 'svelte';

	interface Props {
		onSubscribe: (email: string) => Promise<SubscribeResult>;
		title: string;
		eyebrow?: string;
		description?: string;
		headingLevel?: 2 | 3 | 4 | 5;
		label?: string;
		placeholder?: string;
		submitLabel?: string;
		pendingLabel?: string;
		successMessage?: string;
		errorMessage?: string;
		note?: string;
		consentLabel?: string;
	}

	let {
		onSubscribe,
		title,
		eyebrow,
		description,
		headingLevel = 2,
		label = 'Email address',
		placeholder = 'you@example.com',
		submitLabel = 'Subscribe',
		pendingLabel = 'Subscribing…',
		successMessage = 'Thanks for subscribing.',
		errorMessage = 'Something went wrong. Please try again.',
		note,
		consentLabel
	}: Props = $props();

	const uid = $props.id();
	const heading = $derived(`h${headingLevel}`);

	let email = $state('');
	let consent = $state(false);
	let status = $state<'idle' | 'pending' | 'success' | 'error'>('idle');
	let failure = $state('');
	// The submit button stays disabled until hydration so the form can never fall back to a
	// native GET submission that would put the address in the URL.
	let hydrated = $state(false);

	onMount(() => {
		hydrated = true;
	});

	const describedBy = $derived(
		[status === 'error' ? `${uid}-message` : '', note ? `${uid}-note` : '']
			.filter(Boolean)
			.join(' ') || undefined
	);

	// Feedback describes the submission it followed. Once the address or the consent choice
	// changes, that submission is history, so the message goes with it.
	function clearFeedback() {
		if (status === 'success' || status === 'error') {
			status = 'idle';
			failure = '';
		}
	}

	function guardConsent(event: Event) {
		// The checkbox has no readonly attribute; while pending it is marked aria-disabled and the
		// click (which Space also dispatches) is cancelled, so the submitted choice cannot change
		// under the request without disabling the control and losing focus.
		if (status === 'pending') event.preventDefault();
	}

	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 = '';
				consent = false;
			} else {
				status = 'error';
				failure = result.message || errorMessage;
			}
		} catch {
			status = 'error';
			failure = errorMessage;
		}
	}
</script>

<!--
	The move: a dark band at section width that splits the offer from a single control row.
	The left column carries the eyebrow, title and promise; the right column is one bordered
	shape holding the field and its button. Colour stays out of it: the default accent is
	near-white, so the band reads monochrome until a consumer sets one.

	The two columns are centred against each other from lg up rather than topped. The form is
	always the shorter column — it does not grow with the title or the promise — so topping them
	leaves the band's whole bottom-right quadrant empty, worst in the title-only and long-copy
	fixtures. Centring splits that slack above and below the control, which is also what puts the
	field on the offer's optical centre line.

	On the spacing budget, deliberately: the design language allows at most five distinct spacing
	values, and read as raw numbers this component uses more. Read as roles it uses five — band
	padding and grid gap, each stepping once per breakpoint, plus three margins that mean three
	different things (8px from a label to its control, 12px inside the control group, 16px out to
	the small print). The two numbers left over are not layout at all: the shell's 6px inset is
	what keeps its corners concentric with the button's, and the checkbox's 2px is an optical
	nudge onto its label's first line. Forcing the raw count down to five would put the band's
	desktop block padding back to 64px, which is the spacing this component was just corrected
	away from.
-->
<section
	class="newsletter-signup px-4 py-16 sm:px-6 sm:py-24 lg:px-8 lg:py-32"
	aria-labelledby="{uid}-title"
>
	<div
		class="newsletter-signup__band mx-auto grid max-w-6xl gap-8 rounded-2xl bg-(--_surface) p-6 shadow-(--_band-edge) sm:p-8 lg:grid-cols-[minmax(0,1fr)_minmax(0,26rem)] lg:items-center lg:gap-16 lg:px-16 lg:py-12"
	>
		<div>
			{#if eyebrow}
				<p
					class="text-xs font-semibold tracking-[0.06em] text-balance break-words text-(--_muted) uppercase"
				>
					{eyebrow}
				</p>
			{/if}
			<!-- Arabic and other cursive scripts join their letters, so the heading's negative tracking
			     resets by direction rather than by naming a font: the component inherits the
			     consumer's typeface. -->
			<svelte:element
				this={heading}
				id="{uid}-title"
				class={[
					'text-3xl leading-[1.15] font-semibold tracking-[-0.02em] text-balance break-words text-(--_ink) sm:text-4xl rtl:leading-[1.35] rtl:tracking-normal',
					eyebrow && 'mt-2'
				]}
			>
				{title}
			</svelte:element>
			{#if description}
				<!-- The measure is bounded by direction, here and on the note: Arabic sets far more
				     characters into the same width, so a 46ch cap in Latin runs past 70 characters. -->
				<p
					class="newsletter-signup__lede mt-4 max-w-[46ch] text-lg leading-[1.5] tracking-[-0.01em] text-pretty break-words text-(--_muted) rtl:max-w-[32ch]"
				>
					{description}
				</p>
			{/if}
		</div>

		<form onsubmit={handleSubmit}>
			<!--
				13px rather than 14px: §3.8 puts the label above the input at 13 to 14px and weight 500,
				and the lower end keeps it under the button's 14px so the control row's own action stays
				the brightest thing in this column.
			-->
			<label for="{uid}-email" class="block text-[13px] leading-5 font-medium text-(--_ink)"
				>{label}</label
			>

			<!--
				One shape, not two boxes: the shell carries the border and the focus ring, the field
				is transparent inside it, and the button nests against its inner edge. The inner
				radius is the outer radius less the shell's padding, so the corners share a centre.

				The shape has to say the right thing at both widths, and a single treatment cannot.
				Side by side from sm up, the button is inset on all four edges and reads as seated in
				the shell. Stacked below sm that same inset put a gutter all the way around the
				button, so it read as a button sitting *inside* an oversized text input rather than
				as the other half of one control. Below sm the shell therefore drops its padding and
				gap and becomes two attached segments: the field takes its own inset, the button runs
				flush to the shell's edges, and its bottom corners take the shell's full radius
				because there is no padding left to subtract. Neither arrangement clips the button's
				own focus ring, which is why the shell is never given overflow.

				Where the pointer is coarse, every target clears 44px: the field, the button and the
				consent row, whose label wraps its own checkbox so the whole row is the target.
			-->
			<div
				class="newsletter-signup__shell mt-2 flex flex-wrap items-center gap-0 rounded-xl p-0 sm:gap-2 sm:p-1.5"
			>
				<input
					id="{uid}-email"
					name="email"
					type="email"
					autocomplete="email"
					required
					bind:value={email}
					oninput={clearFeedback}
					{placeholder}
					readonly={status === 'pending'}
					aria-describedby={describedBy}
					class="newsletter-signup__field h-11 min-w-0 flex-1 basis-full rounded-t-xl bg-transparent px-4 text-base text-(--_ink) placeholder:text-(--_muted) focus:outline-none sm:h-10 sm:basis-0 sm:rounded-t-none sm:px-3 pointer-coarse:min-h-11"
				/>
				<!-- Both labels occupy the same grid cell so the button keeps one width and the field
				     does not shrink when the pending label appears. The hidden one is out of the
				     accessibility tree. -->
				<button
					type="submit"
					disabled={!hydrated}
					aria-disabled={status === 'pending' ? 'true' : undefined}
					class="newsletter-signup__button grid h-11 w-full items-center rounded-t-none rounded-b-xl bg-(--_accent) px-4 text-center text-sm font-medium 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 sm:h-10 sm:w-auto sm:rounded-md 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 consentLabel}
				<label
					for="{uid}-consent"
					class="newsletter-signup__consent mt-3 flex items-start gap-2 text-sm leading-5 text-pretty text-(--_muted) pointer-coarse:min-h-11"
				>
					<input
						id="{uid}-consent"
						name="consent"
						type="checkbox"
						required
						bind:checked={consent}
						onclick={guardConsent}
						onchange={clearFeedback}
						aria-disabled={status === 'pending' ? 'true' : undefined}
						class="newsletter-signup__check mt-0.5 size-4 shrink-0 accent-(--_accent) focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-(--_accent) aria-disabled:cursor-progress"
					/>
					<span class="break-words">{consentLabel}</span>
				</label>
			{/if}

			{#if note}
				<p
					id="{uid}-note"
					class="newsletter-signup__note mt-4 max-w-[52ch] text-xs leading-4 text-pretty break-words text-(--_muted) rtl:max-w-[40ch]"
				>
					{note}
				</p>
			{/if}
			<!--
				The live region is persistent and last in the column, so a message has nothing beneath
				it to push. It reserves two lines at every width, which is what the component's own
				defaults and the documented provider messages actually wrap to in this column: a
				double opt-in confirmation or a service-unavailable error runs to two lines at the
				416px the form gets on a wide band, so reserving one there would have moved the layout
				on exactly the submissions most likely to happen. Two lines is a contract, not a
				guess — a longer message from onSubscribe grows the region rather than being clipped,
				and that limit is documented rather than pretended away. Success reads as a
				confirmation with a mark beside it, and the form stays where it is.
			-->
			<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 items-start gap-2 font-medium text-(--_success)">
						<svg
							class="mt-0.5 size-4 shrink-0"
							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 class="break-words">{successMessage}</span>
					</p>
				{:else if status === 'error'}
					<p id="{uid}-message" class="flex items-start gap-2 font-medium text-(--_error)">
						<svg
							class="mt-0.5 size-4 shrink-0"
							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 class="break-words">{failure}</span>
					</p>
				{/if}
			</div>
		</form>
	</div>
</section>

<style>
	.newsletter-signup {
		--_accent: var(--newsletter-signup-accent, #fafafa);
		--_on-accent: var(--newsletter-signup-on-accent, #09090b);
		--_surface: var(--newsletter-signup-surface, #09090b);
		--_field: var(--newsletter-signup-field, #18181b);
		--_ink: var(--newsletter-signup-ink, #fafafa);
		--_muted: var(--newsletter-signup-muted, #a1a1aa);
		--_control-border: var(--newsletter-signup-control-border, #6b6b70);
		--_success: var(--newsletter-signup-success, #4ade80);
		--_error: var(--newsletter-signup-error, #f87171);
		--_scheme: var(--newsletter-signup-color-scheme, dark);

		/* Derived so one accent drives the button and its hover. Not part of the public set. */
		--_accent-hover: color-mix(in srgb, var(--_accent) 88%, var(--_surface));
		/* The state-change curve (§3.6), shared by the shell's ring and the button's fill. */
		--_ease: cubic-bezier(0.2, 0, 0, 1);

		/* §3.5's treatment for a surface on dark: a one-pixel lit top edge and no shadow, because
		   the band sits on the page rather than floating above it. Without it a #09090b band on
		   white is a hole rather than an object. */
		--_band-edge: inset 0 1px 0 rgb(255 255 255 / 0.08);

		/* The checkbox is a native control, and a native control is painted by the browser, not by
		   our background-color — on a light scheme Chrome drew the unchecked box in its own white,
		   which read as already ticked and as the second brightest thing on the band. Declaring the
		   scheme is what makes the browser paint its own controls, its autofill fill and the caret
		   to suit this surface. It is public, because a consumer who retones the band to a pale
		   surface has to be able to send the native controls back to light with it. */
		color-scheme: var(--_scheme);
	}

	/* Blink and WebKit paint their own opaque fill when the browser completes a saved address.
	   color-scheme already darkens it; these pin it to the component's own field and ink tokens so
	   an autofilled address looks identical to a typed one. The field is transparent inside the
	   shell, so the fill is painted back out with the shell's colour rather than one of its own.
	   The absurd transition delay is the documented way to stop the engine animating its fill back
	   in over the top. */
	.newsletter-signup__field:-webkit-autofill,
	.newsletter-signup__field:-webkit-autofill:hover,
	.newsletter-signup__field:-webkit-autofill:focus {
		-webkit-text-fill-color: var(--_ink);
		caret-color: var(--_ink);
		/* The shell's boundary is an inset shadow on the shell, so it paints beneath its
		   children. Below 640px the field runs flush to the shell's top and sides, which means
		   an autofill fill would cover that boundary on three of them — and the field sits only
		   1.12:1 against the band, so the boundary is the only thing identifying the control
		   (WCAG 1.4.11). Redraw it on the field itself, above the fill. The field's own top
		   corners are rounded to the shell's radius for the same reason: without them the fill
		   would square off corners the shell has rounded. */
		-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;
	}

	/* From 640px the field is inset inside the shell's padding and never touches its boundary,
	   so the fill alone is correct and a ring here would draw a line through open space. */
	@media (min-width: 40rem) {
		.newsletter-signup__field:-webkit-autofill,
		.newsletter-signup__field:-webkit-autofill:hover,
		.newsletter-signup__field:-webkit-autofill:focus {
			-webkit-box-shadow: inset 0 0 0 1000px var(--_field);
		}
	}

	/* A raised fill one step above the band, plus a border strong enough to identify the field
	   rather than merely decorate it. The shell owns the boundary and the focus ring; the input
	   inside it is transparent, so the two controls read as one object. */
	.newsletter-signup__shell {
		background-color: var(--_field);
		/* An inset ring rather than a border, so the padding box keeps the shell's radius and the
		   button's radius is exactly the shell's less its 6px inset. */
		box-shadow: inset 0 0 0 1px var(--_control-border);
		transition: box-shadow 140ms var(--_ease);
	}

	.newsletter-signup__shell:focus-within {
		box-shadow:
			inset 0 0 0 1px var(--_accent),
			0 0 0 2px var(--_accent);
	}

	/* Where :has() is available, the shell reacts to the field alone, so focusing the button
	   shows one ring rather than two. Without it the shell keeps the wider :focus-within
	   behaviour above, which is still a visible indicator. */
	@supports selector(:has(*)) {
		.newsletter-signup__shell:focus-within {
			box-shadow: inset 0 0 0 1px var(--_control-border);
		}

		.newsletter-signup__shell:has(.newsletter-signup__field:focus-visible) {
			box-shadow:
				inset 0 0 0 1px var(--_accent),
				0 0 0 2px var(--_accent);
		}
	}

	/* The ring is what holds the checkbox's boundary at 3:1 against the band (WCAG 1.4.11); the
	   fill and the tick are the browser's, drawn to the scheme declared above. Nothing here sets
	   background-color or border-radius, because a native control ignores both. */
	.newsletter-signup__check {
		box-shadow: inset 0 0 0 1px var(--_control-border);
	}

	@media (prefers-reduced-motion: reduce) {
		.newsletter-signup__shell {
			transition: none;
		}
	}
</style>

Service-required: nothing is subscribed until you implement onSubscribe. The component validates the address format with the browser, calls onSubscribe(email) once per submission, 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 of its own and records no consent; the optional checkbox only blocks submission until it is ticked.

Suggested location
src/lib/components/newsletter-signup-01
Required props
onSubscribetitle

Limitations

  • No backend: storage, double opt-in, consent records and unsubscribe handling are your provider's and server's responsibility.
  • Submission requires JavaScript: the submit button stays disabled until the component hydrates, so there is no native form fallback. Add a server form action if you need one.
  • Only browser-level email format validation is performed; validate again on the server.
  • Feedback is reserved for two lines, measured against the shipped defaults and the documented provider messages. Arbitrary length, unrestricted growth and zero layout shift cannot all hold at once: a longer message grows the region, and on a wide band the offer column absorbs that only while it is the taller column. Keep the strings returned to onSubscribe short.
  • The consent checkbox (consentLabel) only requires a tick before submission. Its wording, timestamp and source are not passed to onSubscribe; record them on your server.
  • The band is a dark surface by default. It is one appearance, not a light and dark pair: set the surface, field, ink, muted and color-scheme variables to place it on a light page.
  • No typeface is set: the component inherits the fonts of the page you place it on.

Example

Svelte
<!-- Implement /api/newsletter first. Replace the copy with your own and set successMessage to the real outcome, including double opt-in if you use it. -->
<script lang="ts">
	import NewsletterSignup, { type SubscribeResult } from '$lib/components/newsletter-signup-01/NewsletterSignup.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>

<NewsletterSignup
	eyebrow="Ship notes"
	title="Every release, explained by the people who shipped it"
	description="One email each Tuesday: what changed, the migration steps, and the bugs we have not fixed yet."
	note="We use your address for this newsletter and nothing else. Unsubscribe from any email."
	onSubscribe={subscribe}
/>

The source imports its own files by relative path only. Examples import from $lib/...; SvelteKit 3 projects have no $lib alias by default, so import from your #lib subpath (with the .svelte extension) or a relative path instead.

Connecting your email service#

Copy NewsletterSignup.svelte into src/lib/components/newsletter-signup-01/. There are no extra runtime packages, but you must implement onSubscribe and connect it to a provider through your server.

The quick-start example calls /api/newsletter, which you must create. Keep provider credentials on the server. Replace the example mailing frequency and privacy note with your actual practices before publishing. Set successMessage to match the real outcome: an accepted request may still need email confirmation.

Callback contract#

TypeScript
onSubscribe: (email: string) => Promise<{ ok: true } | { ok: false; message: string }>;
  • Called once per submission with the trimmed address; the input is read-only and repeat submits are ignored while pending.
  • { ok: true } shows successMessage and clears the input (and the consent checkbox, if any).
  • { ok: false, message } shows message (or errorMessage if empty) and keeps the input so the user can retry.
  • A rejected promise shows errorMessage.
  • Editing the address, or changing the consent checkbox, clears the previous success or error message. Feedback always describes the values it was produced for.

Set consentLabel to render a required checkbox with that wording under the input. The browser blocks submission until it is ticked, so onSubscribe is only ever called with consent given. The component does not pass the wording, time or source to onSubscribe; record those on your server, using the same text you passed as consentLabel.

Colours#

The button, focus rings and checkbox use two CSS variables, set on any ancestor:

CSS
--newsletter-signup-accent: #1d4ed8;
--newsletter-signup-on-accent: #ffffff;

Keep the pair at 4.5:1 or better. The card, borders and message colours are Tailwind utilities in the source.

What you must implement elsewhere#

Responsibility Where it belongs
Storing subscribers Email provider
Server-side validation, rate limit Your endpoint
Double opt-in confirmation Provider (tell users via successMessage)
Consent records Your endpoint / provider
Unsubscribe and deletion requests Provider

Preview#

The catalogue preview supplies a fake onSubscribe that waits briefly and returns a fixed result per fixture: success, a double opt-in success message, a returned error, an empty error message, a rejected promise, a first failure followed by success, and a request that never settles. It is labelled "Demonstration — no data is sent". It is not evidence of a working integration.

Props and content inputs#

On this page
NameTypeRequiredDefaultDescription
onSubscribe(email: string) => Promise<{ ok: true } | { ok: false; message: string }>YesNoneCalled 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.
titlestringYesNoneSection heading text.
eyebrowstringNoNoneOptional short label above the heading, set small, uppercase and tracked. Use it to name the newsletter, not to advertise it.
descriptionstringNoNoneOptional paragraph under the heading, e.g. what subscribers receive and how often.
headingLevel2 | 3 | 4 | 5No2Level of the section heading.
labelstringNo'Email address'Visible label for the email input.
placeholderstringNo'you@example.com'Input placeholder; supplementary only, never a replacement for the label.
submitLabelstringNo'Subscribe'Submit button text.
pendingLabelstringNo'Subscribing…'Submit button text while onSubscribe is pending.
successMessagestringNo'Thanks for subscribing.'Message shown after onSubscribe resolves { ok: true }. Mention confirmation emails here if you use double opt-in.
errorMessagestringNo'Something went wrong. Please try again.'Fallback error text used when onSubscribe rejects or returns an empty message.
notestringNoNoneOptional small print under the form, such as a privacy statement; linked to the input with aria-describedby.
consentLabelstringNoNoneOptional label for a required consent checkbox rendered under the input. When set, the browser blocks submission until the box is ticked; the checkbox resets after success.

Customization#

On this page

Connect onSubscribe to your provider, set the copy through props, and restyle the band through ten CSS variables. The accent drives the button and its hover; everything else is the band, the field, the two state colours and the colour scheme the browser paints its own controls from.

  • 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, set successMessage to say so.
  • Consent: set consentLabel to add a required checkbox with that wording, and record the wording, time and source on the server when onSubscribe is called.
  • Copy: eyebrow names the newsletter, title states what arrives, description says how often and what is in it. Keep the description under about 46 characters a line by leaving it short.
  • Accent: set --newsletter-signup-accent and --newsletter-signup-on-accent. The default is near-white on the dark band, so the band renders monochrome until you choose a colour. The hover shade is mixed from the accent, so it stays in step.
  • Light band: set --newsletter-signup-surface to a pale colour, --newsletter-signup-field a step above it, --newsletter-signup-ink to near-black and --newsletter-signup-muted to a mid grey that clears 4.5:1. Set the accent to your dark brand colour and on-accent to white.
  • Colour scheme: --newsletter-signup-color-scheme is dark because the band is. The browser paints the consent checkbox, the caret and the autofill fill from it, and none of them take a background-color from CSS. Set it to light whenever you set a pale surface.
  • Field boundary: --newsletter-signup-control-border draws the shell and the checkbox. Keep it at 3:1 against both the band and the field, because it is what identifies the control; it is drawn as a 1px inset ring so the shell's corners stay concentric with the button's.
  • State colours: --newsletter-signup-success and --newsletter-signup-error carry the two messages. Each is paired with a mark, so colour is never the only signal. Keep both at 4.5:1 against the band.
  • Layout: the band splits into two columns from lg:; below that the offer sits above the form. The shell wraps its button under the field below sm:.

Public CSS variables

VariableToken
--newsletter-signup-accentaccent
--newsletter-signup-on-accentonAccent
--newsletter-signup-surfacesurface
--newsletter-signup-fieldfield
--newsletter-signup-inkink
--newsletter-signup-mutedmuted
--newsletter-signup-control-bordercontrolBorder
--newsletter-signup-successsuccess
--newsletter-signup-errorerror
--newsletter-signup-color-schemecolorScheme

Dependencies and services#

On this page

No 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 agreed to
  • Unsubscribe links and processing, plus data deletion requests

Configuration

  • Implement onSubscribe to call your own endpoint (for example a SvelteKit +server.ts route or form action) 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 user-facing, recoverable message.

Accessibility#

On this page
  • The email input has a visible, programmatically associated label, type=email and autocomplete=email.
  • Pending (visually hidden), success and error text is rendered in a persistent polite live region; the submit button's label also changes to pendingLabel while waiting.
  • While pending the button and the consent checkbox use aria-disabled rather than disabled so keyboard focus is not lost, the input is read-only and the checkbox ignores activation so the submitted values cannot change, and repeat submissions are ignored.
  • On a failed submission the input is described by the error message (alongside the optional note) and the entered address is kept so the user can 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 the state is never signalled by colour alone.
  • Editing the address or changing the consent checkbox clears the previous success or error message, so feedback never describes a submission that no longer matches the form.
  • The component declares color-scheme, so the browser paints the consent checkbox, the caret and the autofill fill to suit the band. A native checkbox takes neither background-color nor border-radius from CSS, so under a light scheme it is drawn in the browser's own white and reads as already ticked. Autofill is also pinned to the field and ink tokens for Blink and WebKit.
  • The field and its button sit in one shell. The shell takes the focus ring when the field is focused and the button carries its own outline, so focusing either shows exactly one indicator. Where the pointer is coarse, the field, the button and the whole consent row each clear a 44px target.
  • Default contrast on the #09090b band: ink #fafafa about 19.1:1, muted #a1a1aa about 7.8:1, success #4ade80 about 11.4:1, error #f87171 about 7.2:1. The near-white accent carries #09090b text at about 18.4:1; the blue palette's #60a5fa carries it at about 7.8:1.
  • The field boundary #6b6b70 clears 3:1 against both the band (about 3.8:1) and the #18181b field (about 3.3:1), which is what identifies the control rather than merely decorating it.
  • The feedback region reserves two lines of height at every width, which is what the shipped defaults and the documented provider messages wrap to in this column, so those messages appear without moving the note or the band's edge. A longer message from onSubscribe grows the region rather than being clipped.
  • Headings reset their negative letter-spacing and relax their line height under dir="rtl", because cursive scripts join their letters. No typeface is imposed; the component inherits yours.
  • IDs come from $props.id(), so several signup bands on one page remain uniquely labelled.

Known limitations

  • Browser-native validation bubbles are used for empty or malformed addresses and an unticked consent box; their wording and styling vary by browser.
  • Contrast is computed for the shipped token defaults and the two palettes only; any changed colour must be re-checked at 4.5:1 for text and 3:1 for the field border.
  • The button's hover shade is mixed with color-mix(), which needs Chrome 111, Safari 16.2 or Firefox 113 and newer. Older browsers keep the resting accent on hover, which stays legible.
  • The pressed state uses a transform, which is removed under prefers-reduced-motion.
  • The shell scopes its focus ring to the field with :has(). In browsers without it (Firefox before 121) the ring also appears while the button is focused, alongside the button's own outline.

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
  • 2.1.2 (Published) Current release · 16 September 2026

Only the current release is available. Keep downloaded source and its receipt if you need to use it again later.