Newsletter signup
A dark newsletter band that splits the offer from a single control row holding the field and its button. Connect your own email service to accept subscriptions.
cmp_newsletter_signup_001 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_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
<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>
Usage#
On this pageService-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
onSubscribeshort. - The consent checkbox (
consentLabel) only requires a tick before submission. Its wording, timestamp and source are not passed toonSubscribe; 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
<!-- 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#
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 }showssuccessMessageand clears the input (and the consent checkbox, if any).{ ok: false, message }showsmessage(orerrorMessageif 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.
Consent#
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:
--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| 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. |
title | string | Yes | None | Section heading text. |
eyebrow | string | No | None | Optional short label above the heading, set small, uppercase and tracked. Use it to name the newsletter, not to advertise it. |
description | string | No | None | Optional paragraph under the heading, e.g. what subscribers receive and how often. |
headingLevel | 2 | 3 | 4 | 5 | No | 2 | Level of the section heading. |
label | string | No | 'Email address' | Visible label for the email input. |
placeholder | string | No | 'you@example.com' | Input placeholder; supplementary only, never a replacement for the label. |
submitLabel | string | No | 'Subscribe' | Submit button text. |
pendingLabel | string | No | 'Subscribing…' | Submit button text while onSubscribe is pending. |
successMessage | string | No | 'Thanks for subscribing.' | Message shown after onSubscribe resolves { ok: true }. Mention confirmation emails here if you use double opt-in. |
errorMessage | string | No | 'Something went wrong. Please try again.' | Fallback error text used when onSubscribe rejects or returns an empty message. |
note | string | No | None | Optional small print under the form, such as a privacy statement; linked to the input with aria-describedby. |
consentLabel | string | No | None | Optional 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 pageConnect 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
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, set
successMessageto say so. - Consent: set
consentLabelto add a required checkbox with that wording, and record the wording, time and source on the server whenonSubscribeis 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-accentand--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-surfaceto a pale colour,--newsletter-signup-fielda step above it,--newsletter-signup-inkto near-black and--newsletter-signup-mutedto 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-schemeis 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-borderdraws 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-successand--newsletter-signup-errorcarry 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
| Variable | Token |
|---|---|
--newsletter-signup-accent | accent |
--newsletter-signup-on-accent | onAccent |
--newsletter-signup-surface | surface |
--newsletter-signup-field | field |
--newsletter-signup-ink | ink |
--newsletter-signup-muted | muted |
--newsletter-signup-control-border | controlBorder |
--newsletter-signup-success | success |
--newsletter-signup-error | error |
--newsletter-signup-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 agreed to
- Unsubscribe links and processing, plus data deletion requests
Configuration
- Implement
onSubscribeto call your own endpoint (for example a SvelteKit+server.tsroute 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=emailandautocomplete=email. - Pending (visually hidden), success and error text is rendered in a persistent polite live region; the submit button's label also changes to
pendingLabelwhile waiting. - While pending the button and the consent checkbox use
aria-disabledrather 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-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 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
#09090bband: ink#fafafaabout 19.1:1, muted#a1a1aaabout 7.8:1, success#4ade80about 11.4:1, error#f87171about 7.2:1. The near-white accent carries#09090btext at about 18.4:1; the blue palette's#60a5facarries it at about 7.8:1. - The field boundary
#6b6b70clears 3:1 against both the band (about 3.8:1) and the#18181bfield (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
onSubscribegrows 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.