Skip to content

Components / Sections

Signup Preview

A newsletter signup beside a look at what arrives: the heading, a line, one email field with its button and the count of people already reading, and next to them the next issue as it lands in an inbox (who it is from, the subject, the day it sends, its first lines fading out) on a small stack of the issues before it. The form is a real form first: method="post" to your action, labelled and validated, so it sends with JavaScript off. One small script binds it with the core form lib for the fetch, the spoken status line and the button's four faces. The field is core VanishInput: what was typed vanishes as the form sends, and with a thanks page (success) the reader goes there once it has. demo renders a form that sends nothing and says so; sent swaps the form for its confirmation, the page a native post lands on.

Live demo

Live, from the library itself, at full size: scroll, hover, navigate away and back. The demo is never gated.

Variants · each row writes the attribute the prop writes; the section's own CSS answers

tone
density

Measured

JavaScript of its own

43 B raw

63 B gzip.

Uses VanishInput and the shared runtime (1.9 kB raw, once per site) and form. With those included: 5.3 kB raw.

CSS 26.3 kB raw including the base tokens. Measured from a production build, and again in CI for every change that can move it.

Browser support

Baseline · widely available

Chrome 120 · Firefox 110 · Safari 16.5; needs container-units, user-invalid, mask-image. Elsewhere: without container units the two columns never form and the headline keeps the page's text size; without :user-invalid the field keeps its border when the address is wrong; without masks the sample's lines show in full instead of fading out. In every case the form sends the same..

Install Pro

The demo above is never gated; the source is. With a key in components.json (see account), the registry serves it like any other item:

terminal
npx shadcn@latest add @moonarc-pro/signup-preview
src/pages/index.astro
---
import SignupPreview from '@/components/moonarc/pro/SignupPreview.astro';
---
<!-- demo sends nothing and says so. With your list service, replace it with action="/api/subscribe": the form then posts
     without JavaScript, and the section's script sends the same fields with fetch. -->
<SignupPreview
  id="signup"
  demo
  eyebrow="Newsletter"
  copy="One essay every Friday, free, with no tracking pixels."
  next={{
    from: 'Your Name',
    subject: 'The slow way to a first thousand readers',
    when: 'Friday, 8:00',
    lines: ['Most advice about growing a list is about speed.', 'This week: the opposite, and what it cost me.'],
    tease: 'Also inside: the three numbers I track',
  }}
  count={{ n: 12400, text: '{n} people read it every Friday' }}
  privacy="/privacy/"
>
  <span slot="headline">Get the next issue <em>on Friday</em></span>
</SignupPreview>

Without a key the registry answers 401 with an upgrade message. Everything this section composes is free: stateful-button, vanish-input, check-draw.

Props

PropTypeDefaultDescription
eyebrowstringnoneA short line above the heading. Slot "eyebrow" wins.
headlinestringnoneThe heading; required unless the headline slot is filled, and the build fails without either. Slot "headline" wins and may hold an <em>, set in the emphasis face and colour.
copystringnoneA line under the heading.
next{ from: string; subject: string; when: string; lines: string[]; tease?: string }noneRequired. The next issue as the email that arrives: who it is from (its first letter is the avatar), the subject, when it sends, its first lines (they fade out toward the bottom) and an optional line under them.
count{ n: number; text: string }noneHow many already read it, and the sentence it sits in: {n} is where the number goes, set in bold and formatted in English with thousands separators.
labelstring'Your email'The email field's label, shown above it.
placeholderstring'you@example.com'The email field's placeholder, when placeholders is not set.
placeholdersstring[]noneSuggestions that turn in the empty field (core VanishInput), the first one the placeholder attribute.
localestring'en'Intl locale of the count's number.
namestring'email'The email field's name, as your list service expects it (type="email", autocomplete="email", required).
submitLabelsPartial<Record<'idle' | 'pending' | 'done' | 'error', string>>{ idle: 'Subscribe', pending: 'Subscribing', done: 'Subscribed', error: 'Try again' }The button's four faces (StatefulButton): the words for idle, pending, done and error.
consentstringnoneA required CheckDraw inside the form, named consent (value yes), with this text as its label; off by default.
privacyLabelstring'Privacy'The text of the privacy link.
sentbooleanfalseShow the confirmation instead of the form: the page a native post lands on. Needs sentHeadline.
sentHeadlinestringnoneThe confirmation's first line; required with sent.
sentCopystringnoneA line under the confirmation's first line.
actionstringnoneWhere the form posts without JavaScript. Required unless demo or sent; the build fails without it.
endpointstringnoneWhere the lib posts with JavaScript (data-endpoint); unset, the action.
method'post''post'Only post: an email address never goes in the URL. Any other value fails the build with that rule in the message.
encode'multipart' | 'urlencoded' | 'json''multipart'The body of the fetch (data-encode); every request sends Accept: application/json.
successstringnoneA page to go to after success (data-success). On the same origin the lib first leaves a record for that page, where ThankYou reads it. Unset, the form stays and the status line says done.
carrystring | string[]noneResponse keys handed to that page (data-carry): a number or a short token passes, so an email never can.
refParamstringnoneA URL parameter (ref) sent along with the email (data-ref-param): into a field of that name if the fields slot has one, else appended.
honeypotstringnoneName of a honeypot field: rendered display:none, tabindex -1, autocomplete off. A filled one marks a bot for your service.
formAttrsRecord<string, string | number | boolean | undefined>noneExtra attributes on the <form>: a service's flag (data-netlify), name, id, class. The attributes the section writes (action, method, data-ma-form*, data-endpoint, data-encode, data-success, data-carry, data-ref-param, data-msg-*) fail the build here. Use their props instead.
messages{ pending?, done?, exists?, error?, 429?, offline?, demo? }English textsThe status line's texts (data-msg-*): 'Signing you up…', 'You are on the list. The next issue is on its way.', 'You are already on the list.', 'That didn’t go through. Try again.', a 429 text and an offline text. demo is the visible note of a demo form, 'Demo: nothing is sent.' A service's own error text is never shown, because it cannot be translated.
demobooleanfalseA form that sends nothing: method="dialog" (it goes nowhere outside a <dialog>, even without JavaScript), data-ma-form-demo with the sample response, and a visible note from messages.demo. action and endpoint are not rendered.
demoResponseRecord<string, unknown>{ ok: true }What a demo form answers with, as JSON in data-ma-form-demo. With success and carry it reaches the thanks page like a real answer.
privacystringnoneThe URL of the privacy policy, linked under the form.
block1 | 2 | 3 | 4 | 51The colour block the preview sits on; the email card on it is the page's own pair.
level1 | 22Heading level: 1 on a page whose own heading this is, 2 under another h1.
tone'surface' | 'muted' | 'ink' | 'brand'unset (paints nothing)The section's ground; the preview keeps its block on every tone and the button follows the tone's text and ground.
density'compact' | 'cozy' | 'roomy''cozy'Block padding ×0.8 / ×1 / ×1.25 through --ma-density.

Theming and overrides

The section reads the theme contract (shadcn's variable names with the base tokens as fallback), so a pasted theme or one --ma-brand recolours it. Its class names are the override surface and stay stable; a named slot replaces the matching prop with your own markup.

Classes

.ma-signup.ma-signup__avatar.ma-signup__copy.ma-signup__count.ma-signup__eyebrow.ma-signup__field.ma-signup__fine.ma-signup__form.ma-signup__from.ma-signup__headline.ma-signup__inner.ma-signup__lines.ma-signup__mail.ma-signup__main.ma-signup__preview.ma-signup__privacy.ma-signup__row.ma-signup__sent.ma-signup__sent-copy.ma-signup__sent-title.ma-signup__stack.ma-signup__status.ma-signup__subject.ma-signup__submit.ma-signup__tease.ma-signup__tick.ma-signup__when.ma-vanish.ma-vanish__input.ma-vanish__label

Slots

slot="eyebrow"slot="headline"slot="fields"

Reduced motion

The mail straightens at once when a pointer rests on the preview, without its slow turn. The button follows StatefulButton (the faces switch at once and the check is simply drawn), and the form, the status line and the confirmation work the same.

With ClientRouter

The script calls bindForms(), which binds every form[data-ma-form] through the runtime's onMount. A form that arrives by a ClientRouter navigation is bound fresh, and a second call (another section, a layout) is a no-op. Leaving for success is a full navigation.

Why it is built this way

Replaces: Newsletter signup sections (Tailwind Plus) · Subscribe embeds (Substack, Mailchimp forms). See the migration table.

idle → pending → done · one attribute

One submit button with four faces (idle, pending with a spinner, done with a check that draws itself, error) and a width that follows the label.

every browserclick

hints take turns · Enter sends · the words blur away

Placeholders that take turns, and text that blurs away on submit.

newly · Chrome 125 · Firefox 128 · Safari 16.4clickalways

A footer that signs off like a letter: on a strong colour block, a line of thanks, the name set large with its second part in the emphasis face and a stroke drawn under that part, a way back to the signup, the links in columns and a few small lines at the foot.

Chrome 115 · not Firefox · Safari 26scrollhover

An email signup section built on a real form: native validation, an invalid state that only shows after the user has typed, a submit button whose label swaps for a spinner when the form carries data-pending, a status line for your handler, and a dot field behind.

every browserscroll

You're in

Thanks, you're on the list.

We'll write once, the day it opens. Until then, every friend who joins through your link moves you up.

Your place in line
1,284

Back to Pro

A thanks page for after a signup: a headline, the reader's place in line rolling up, a referral link with a copy button, and one burst of confetti.

newly · Chrome 125 · Firefox 121 · Safari 15.4loadclick

Waitlist · private beta

Doors open soon.

One email when they do. Type it in and watch it go. The form underneath is real.

Opens

Demo: nothing is sent.

A pre-launch hero with a waitlist form: a flip countdown over a retro grid floor, an email field whose text blurs away on submit, a status line that says what happened, and the people already waiting.

newly · Chrome 125 · Firefox 128 · Safari 16.4loadclick

Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown