SitenameFriday, 09:00
No. 187: The button at the end
Hello, and happy Friday.
This week is about the last button on a form: what it says, where it sits, and what happens after it is pressed.
The rest lands on Friday
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.
Subscribe
Free, every Friday at nine. One click to leave from any issue.
Demo: nothing is sent.
Read by 12,480 people every Friday
SitenameFriday, 09:00
No. 187: The button at the end
Hello, and happy Friday.
This week is about the last button on a form: what it says, where it sits, and what happens after it is pressed.
The rest lands on Friday
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
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
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..
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:
npx shadcn@latest add @moonarc-pro/signup-preview---
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.
| 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. |
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.
.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
slot="eyebrow"slot="headline"slot="fields"
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.
ClientRouterThe 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.
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.
hints take turns · Enter sends · the words blur away
Placeholders that take turns, and text that blurs away on submit.
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.
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.
You're in
We'll write once, the day it opens. Until then, every friend who joins through your link moves you up.
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.
Waitlist · private beta
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.
Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown