Newsletter signup
An email newsletter signup section with a labelled input and pending, success and error states, connected through a callback.
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. Previews taller than the maximum auto-height scroll inside the frame.
Use this component with your coding agent
Using the PageSugar MCP server, fetch component cmp_newsletter_signup_001 version 1.0.0 with variant "neutral", then add its files to this project and follow its usage notes.
Code
Neutral palette, version 1.0.0. Artifact sha256-30fa056735b98e97bdb210b4ecf9244899ec2b14d01c7108f2e81c5757f06cfe
NewsletterSignup.svelte · Svelte component · 3.9 KB
<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;
description?: string;
headingLevel?: 2 | 3 | 4 | 5;
label?: string;
placeholder?: string;
submitLabel?: string;
pendingLabel?: string;
successMessage?: string;
errorMessage?: string;
note?: string;
}
let {
onSubscribe,
title,
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
}: Props = $props();
const uid = $props.id();
const heading = $derived(`h${headingLevel}`);
let email = $state('');
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
);
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;
}
}
</script>
<section class="px-4 py-16 sm:px-6 lg:px-8" aria-labelledby="{uid}-title">
<div class="mx-auto max-w-xl rounded-2xl bg-zinc-50 p-6 ring-1 ring-zinc-200 sm:p-10">
<svelte:element
this={heading}
id="{uid}-title"
class="text-2xl font-semibold tracking-tight text-balance break-words text-zinc-900 sm:text-3xl"
>
{title}
</svelte:element>
{#if description}
<p class="mt-3 text-base text-pretty break-words text-zinc-600">{description}</p>
{/if}
<form class="mt-6" onsubmit={handleSubmit}>
<label for="{uid}-email" class="block text-sm font-medium text-zinc-900">{label}</label>
<div class="mt-2 flex flex-col gap-3 sm:flex-row">
<input
id="{uid}-email"
name="email"
type="email"
autocomplete="email"
required
bind:value={email}
{placeholder}
readonly={status === 'pending'}
aria-describedby={describedBy}
class="block w-full min-w-0 flex-1 rounded-lg border border-zinc-500 bg-white px-3 py-2.5 text-base text-zinc-900 placeholder:text-zinc-500 focus:border-zinc-900 focus:outline-2 focus:outline-offset-0 focus:outline-zinc-900"
/>
<button
type="submit"
disabled={!hydrated}
aria-disabled={status === 'pending' ? 'true' : undefined}
class="shrink-0 rounded-lg bg-zinc-900 px-4 py-2.5 text-sm font-semibold text-white hover:bg-zinc-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-zinc-900 disabled:cursor-not-allowed disabled:bg-zinc-700 aria-disabled:cursor-progress aria-disabled:bg-zinc-700"
>
{status === 'pending' ? pendingLabel : submitLabel}
</button>
</div>
<div aria-live="polite" aria-atomic="true">
{#if status === 'pending'}
<p class="sr-only">{pendingLabel}</p>
{:else if status === 'success'}
<p id="{uid}-message" class="mt-3 text-sm font-medium break-words text-green-800">
{successMessage}
</p>
{:else if status === 'error'}
<p id="{uid}-message" class="mt-3 text-sm font-medium break-words text-red-700">
{failure}
</p>
{/if}
</div>
{#if note}
<p id="{uid}-note" class="mt-3 text-xs break-words text-zinc-600">{note}</p>
{/if}
</form>
</div>
</section>
Usage
Service-required: nothing is subscribed until you implement onSubscribe. The component validates the address format with the browser, calls onSubscribe(email) once per submission, shows a pending label, then shows successMessage or the returned error message. It makes no requests of its own.
Suggested location: src/lib/components/newsletter-signup-01
Required props: onSubscribe, title
Example
<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
title="Get product updates"
description="One short email a month. Unsubscribe at any time."
note="We only use your address to send this newsletter."
onSubscribe={subscribe}
/>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.
- No consent checkbox is included; add one in the source if your jurisdiction or policy requires explicit opt-in.
- Light appearance only.
Newsletter signup #
An email signup section with a labelled input and pending, success and error states.
Service required. This component does not subscribe anyone by itself and makes no network
requests. You must implement onSubscribe and connect it to an email subscription provider through
your own server.
Install #
Copy NewsletterSignup.svelte into src/lib/components/newsletter-signup-01/. No packages are
required.
Example #
<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
title="Get product updates"
description="One short email a month. Unsubscribe at any time."
note="We only use your address to send this newsletter."
onSubscribe={subscribe}
/>/api/newsletter is your own endpoint (for example src/routes/api/newsletter/+server.ts). It holds
the provider credentials and calls the provider's API.
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.{ ok: false, message }showsmessage(orerrorMessageif empty) and keeps the input so the user can retry.- A rejected promise shows
errorMessage.
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 |
Props #
| Prop | Type | Default |
|---|---|---|
onSubscribe |
see above | required |
title |
string |
required |
description |
string |
— |
headingLevel |
2 | 3 | 4 | 5 |
2 |
label |
string |
'Email address' |
placeholder |
string |
'you@example.com' |
submitLabel |
string |
'Subscribe' |
pendingLabel |
string |
'Subscribing…' |
successMessage |
string |
'Thanks for subscribing.' |
errorMessage |
string |
'Something went wrong. Please try again.' |
note |
string |
— |
Limitations #
- Requires JavaScript: the submit button is disabled until hydration. Add a SvelteKit form action if you need a no-JS fallback.
- Browser-only format validation; validate again on the server.
- No consent checkbox is included.
- Light appearance only.
Preview #
The catalogue preview supplies a fake onSubscribe that waits briefly and returns a fixed result.
It is labelled "Demonstration — no data is sent". It is not evidence of a working integration.
Customization
Connect onSubscribe to your provider, adjust copy through props, and edit Tailwind classes in the source for colours and layout. No colour tokens are declared.
- 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 tell people to check their inbox.
- Consent: add a required checkbox or policy link inside the form if your policy needs explicit consent, and record it on the server.
- Copy: set title, description, label, submitLabel and note; keep the label visible.
- Colours: the card uses zinc utilities, the button bg-zinc-900 and messages text-green-800 / text-red-700; keep replacements at 4.5:1 contrast.
- Layout: the input and button stack on small screens and sit in a row from sm:; change flex-col sm:flex-row to alter this.
Props and content inputs
| 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 shows errorMessage. |
title | string | Yes | None | Section heading text. |
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. |
Dependencies and services
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.
Integration boundaries
- Integration level: service-required.
- Requires client-side JavaScript to be interactive.
- Server-side rendering: supported.
- Connects to your own backend for: Email subscription provider.
Accessibility
- 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 text also changes to pendingLabel while waiting.
- While pending the button uses aria-disabled rather than disabled so keyboard focus is not lost, the input is read-only so the submitted address 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.
- IDs come from $props.id(), so several signup forms on one page remain uniquely labelled.
Known limitations
- Browser-native validation bubbles are used for empty or malformed addresses; their wording and styling vary by browser.
License
Source license: MIT.
Version history
Only one release has been published so far; earlier versions will be listed here.
- 1.0.0 Published current · selected · 16 September 2026