# Signup Preview (Moonarc)

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.

- Pro block. Install: `npx shadcn@latest add @moonarc-pro/signup-preview` (license key required)
- Tier B · category section · trigger click, hover
- Readout: `<SignupPreview demo next={{ from, subject, when, lines }} count={{ n, text }} />`
- Browser support: widely (Chrome 120 · Firefox 110 · Safari 16.5); 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.
- Measured cost: 43 B raw JS · 63 B gzip · + VanishInput + runtime + form (with dependencies 5.3 kB raw; CSS 26.3 kB raw)
- Page: https://moonarc.dev/components/signup-preview/

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `eyebrow` | `string` | none | A short line above the heading. Slot "eyebrow" wins. |
| `headline` | `string` | none | The 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. |
| `copy` | `string` | none | A line under the heading. |
| `next` | `{ from: string; subject: string; when: string; lines: string[]; tease?: string }` | none | Required. 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 }` | none | How 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. |
| `label` | `string` | `'Your email'` | The email field's label, shown above it. |
| `placeholder` | `string` | `'you@example.com'` | The email field's placeholder, when placeholders is not set. |
| `placeholders` | `string[]` | none | Suggestions that turn in the empty field (core VanishInput), the first one the placeholder attribute. |
| `locale` | `string` | `'en'` | Intl locale of the count's number. |
| `name` | `string` | `'email'` | The email field's name, as your list service expects it (type="email", autocomplete="email", required). |
| `submitLabels` | `Partial<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. |
| `consent` | `string` | none | A required CheckDraw inside the form, named consent (value yes), with this text as its label; off by default. |
| `privacyLabel` | `string` | `'Privacy'` | The text of the privacy link. |
| `sent` | `boolean` | `false` | Show the confirmation instead of the form: the page a native post lands on. Needs sentHeadline. |
| `sentHeadline` | `string` | none | The confirmation's first line; required with sent. |
| `sentCopy` | `string` | none | A line under the confirmation's first line. |
| `action` | `string` | none | Where the form posts without JavaScript. Required unless demo or sent; the build fails without it. |
| `endpoint` | `string` | none | Where 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. |
| `success` | `string` | none | A 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. |
| `carry` | `string | string[]` | none | Response keys handed to that page (data-carry): a number or a short token passes, so an email never can. |
| `refParam` | `string` | none | A 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. |
| `honeypot` | `string` | none | Name of a honeypot field: rendered display:none, tabindex -1, autocomplete off. A filled one marks a bot for your service. |
| `formAttrs` | `Record<string, string | number | boolean | undefined>` | none | Extra 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 texts` | The 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. |
| `demo` | `boolean` | `false` | A 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. |
| `demoResponse` | `Record<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. |
| `privacy` | `string` | none | The URL of the privacy policy, linked under the form. |
| `block` | `1 | 2 | 3 | 4 | 5` | `1` | The colour block the preview sits on; the email card on it is the page's own pair. |
| `level` | `1 | 2` | `2` | Heading 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. |

## Usage

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

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

## Craft

- The form is a real form first: method="post" to your action, a visible label and native validation, so it sends with JavaScript off. The script only takes over the submit for the fetch, the pending lock and the spoken status. The email never goes in the URL: any method but post fails the build.
- The status line exists at load, is never hidden and reserves one line, because a live region that appears together with its text is not announced and a line that appears pushes the page. The button goes aria-disabled while pending, since disabled would drop the focus.
- The preview is the next issue as the email that arrives, on a colour block with two sheets behind it for the issues before. Its first lines fade out under a mask, so the sample ends where a real preview would, and the mail straightens when a pointer rests on it.
- A demo form never pretends to collect addresses: method="dialog" goes nowhere outside a dialog even with JavaScript off, the lib answers with demoResponse, and a visible note says nothing is sent. Hidden fields a list service needs go in the fields slot, inside the form.

## Replaces

- Newsletter signup sections (Tailwind Plus)
- Subscribe embeds (Substack, Mailchimp forms)

## Source

Pro block. The source is served by the license-gated registry: `npx shadcn@latest add @moonarc-pro/signup-preview` with a key in components.json (https://moonarc.dev/account/). Everything it composes is free: stateful-button, vanish-input, check-draw.
