Waitlist Hero
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. It is a real form first (method="post" to your action, labelled, validated), so it sends with JavaScript off. One small script binds it with the core form lib for the fetch, the pending lock, the spoken status and the thanks-page record. The email never goes in the URL: any method but post fails the build.
Live demo
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.
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
Measured
JavaScript of its own
43 B raw
63 B gzip.
Uses CountdownFlip and the shared runtime (1.9 kB raw, once per site) and VanishInput and form. With those included: 6.4 kB raw.
CSS 23.6 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 125 · Firefox 128 · Safari 16.4; needs mask-image, clip-path, property, has, css-math. Elsewhere: the grid runs to the horizon without fading; without @property or round() the field shows its first placeholder only, and without :has() the native placeholder shows through the overlay. 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:
npx shadcn@latest add @moonarc-pro/hero-waitlist---
import HeroWaitlist from '@/components/moonarc/pro/HeroWaitlist.astro';
// a real launch comes from your config or CMS: an ISO date with a time and an offset ('…T09:00:00+01:00'). This one is
// next 1 January, so the example never runs out
const launch = new Date(Date.UTC(new Date().getUTCFullYear() + 1, 0, 1)).toISOString();
---
<!-- Without JavaScript the form posts to action and your endpoint answers with a page (or a 303 to /thanks/). With it,
the section's script (bindForms() from the core form lib) sends the same fields with fetch. Because it
handled the submit, the typed text vanishes while the request is out. -->
<HeroWaitlist
eyebrow="Private beta"
headline="Join the waitlist for the launch"
copy="We send one email, on the day it opens, and nothing else."
launch={launch}
timeZone="Europe/Berlin"
done="We're open. Come in."
units={['days', 'hours', 'minutes']}
label="Email address"
placeholders={['you@studio.example', 'your work email']}
action="/api/waitlist"
encode="json"
success="/thanks/"
carry={['position', 'referral']}
refParam="ref"
honeypot="website"
privacy="/privacy/"
count={2400}
people={[{ name: 'Ana Lima' }, { name: 'Bo Chen' }, { name: 'Cleo Park' }, { name: 'Dev Rao' }]}
/>
<!-- A service that reads its own attributes and hidden inputs gets them passed through: action="/thanks/" endpoint="/"
encode="urlencoded" formAttrs={{ name: 'waitlist', 'data-netlify': 'true' }}, and in the fields slot
<input slot="fields" type="hidden" name="form-name" value="waitlist" />.
While no service is set up, demo instead of action: nothing is sent, and a note under the form says so. -->Without a key the registry answers 401 with an upgrade message. Everything this section composes is free: retro-grid, countdown-flip, vanish-input, shimmer-button, check-draw, avatar-stack.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
level | 1 | 2 | 1 | Heading level of the headline: 1 on the launch page itself, 2 under another h1 (a demo, or a block further down). |
headline | string | none | Headline; required unless the headline slot is filled. |
eyebrow | string | none | Short line above the headline. |
copy | string | none | Supporting copy. |
launch | string | none | The launch moment: an ISO date with a time and an offset ('2026-11-02T09:00:00+01:00' or '…Z'). Without an offset the build fails, because the build machine's time zone would decide the moment. |
done | string | none | Required. The text the countdown shows at zero, and its accessible name from then on (passed to CountdownFlip). A build after the launch prints it in the server HTML. |
units | ('days' | 'hours' | 'minutes' | 'seconds')[] | all four | Countdown cards, in order. |
labels | Partial<Record<unit, string | [one, other]>> | none | Labels under the cards; a pair gives the spoken name its singular ("1 day"). |
dateLabel | string | 'Opens {date}' | The line under the countdown; {date} becomes a <time datetime> with the launch date. It stays correct without JavaScript, since the server-rendered numbers are the time left at build. |
timeZone | string | the offset in launch | Zone the date line is written in (Intl.DateTimeFormat, dateStyle long): an IANA zone ('Europe/Berlin') or an offset ('+01:00'). Unset, it is the offset launch is written with: '…T20:00:00-08:00' prints 2 November, the day it is where the launch is announced, not 3 November as in UTC. 'Z' is UTC. The countdown itself counts to the instant, whatever the zone. |
locale | string | 'en' | Intl locale of the date, the count and the avatar chip's number (its spoken sentence is moreLabel). |
label | string | 'Email address' | The email field's accessible name, as its <label>. |
showLabel | boolean | false | Show the label above the field; by default it is screen-reader text only. |
placeholders | string[] | none | Suggestions shown in turn in the empty field; the first is the placeholder attribute. |
name | string | 'email' | Name of the email field (type="email", autocomplete="email", required). |
submitLabel | string | 'Join the waitlist' | The submit button's label (a ShimmerButton, type="submit", on --ma-sec-action). |
action | string | none | Where the form posts without JavaScript. Required unless demo; the build fails without it. |
endpoint | string | none | Where the lib posts with JavaScript (data-endpoint); unset, the action. |
method | 'post' | 'post' | Only post: the email never goes in the URL. Any other value fails the build with that rule in the message. |
encode | 'multipart' | 'urlencoded' | 'json' | 'multipart' | 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 writes sessionStorage['ma:form'] = { path, at, …carried } for the thanks page, where the ThankYou section reads it. Unset, the form stays and the status 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, and never aria-hidden on something focusable. A filled one marks a bot for your service. |
formAttrs | Record<string, string | number | boolean> | none | Extra attributes on the <form>: a service's flag (data-netlify), name, class, id. 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-*): 'Adding you…', 'You’re on the list.', 'You’re already on the list.', 'That didn’t go through. Try again.', a 429 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: it cannot be translated. |
demo | boolean | false | A form that sends nothing: method="dialog" (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> | { position: 2401, referral: 'demo' } | 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. |
consent | string | none | A required CheckDraw inside the form, named consent (value yes), with this text as its label; off by default. |
privacy | string | none | URL of the privacy policy, linked under the form. |
privacyLabel | string | 'Privacy policy' | Text of that link. |
people | { name, src? }[] | [] | Avatars in the proof block (AvatarStack without count: its chip only when people outnumber its five; initials without a photo), ringed in the plate's colour. |
moreLabel | string | 'and {n} others' | What a screen reader hears for the "+N" avatar chip (AvatarStack's moreLabel); {n} is the number formatted in locale. Translate it with the rest: without {n} the build fails. |
count | number | none | How many are waiting, formatted with Intl.NumberFormat(locale). Without it (and without the proof slot) the proof block is not rendered. |
countLabel | string | '{n} people waiting' | The proof line; {n} is the formatted count, set in bold. |
tone | 'surface' | 'muted' | 'ink' | 'brand' | unset (paints nothing) | The ground: the page surface, a muted band, an always-dark ink panel, or a deep brand panel. Each paints --background, --ma-tone-muted (over --muted, with a --border hairline), --ma-tone-ink or --ma-tone-brand (over --primary) with the matching text. With theme.css, a painted tone also re-declares --card, --border, --input and --ring inside the section, so cards and fields follow the ground. The form's plate takes the same ground, the submit inverts on ink and brand, and the grid is strongest on ink and brand. |
density | 'compact' | 'cozy' | 'roomy' | 'cozy' | Block padding and the main gaps ×0.8 / ×1 / ×1.25 through --ma-density. |
align | 'start' | 'center' | 'center' | Centred, or everything aligned to the start. |
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-cd__card.ma-hwl.ma-hwl__actions.ma-hwl__aside.ma-hwl__clock.ma-hwl__consent.ma-hwl__copy.ma-hwl__count.ma-hwl__date.ma-hwl__demo.ma-hwl__eyebrow.ma-hwl__field.ma-hwl__field--sr.ma-hwl__fine.ma-hwl__form.ma-hwl__head.ma-hwl__headline.ma-hwl__inner.ma-hwl__inner--media.ma-hwl__main.ma-hwl__media.ma-hwl__panel.ma-hwl__privacy.ma-hwl__proof.ma-hwl__row.ma-hwl__social.ma-hwl__status.ma-hwl__submit.ma-vanish.ma-vanish__input.ma-vanish__label
Slots
slot="eyebrow"slot="headline"slot="copy"slot="fields"slot="actions"slot="proof"slot="aside"slot="media"
Reduced motion
The floor stands still, the button has no sheen, the field shows its first placeholder without the cycle, the cards change in place, and on submit the text fades out quickly instead of blurring away. The form, the status line and the countdown 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. CountdownFlip and VanishInput rebind the same way. Leaving for success is a full navigation, never a view transition.
Why it is built this way
- The first Pro section with a script of its own: two lines, a named import of bindForms from the core form lib (so the registry adds /r/form.json as a dependency) and the call. Everything that moves is a free primitive.
- The email never goes in the URL, the storage or a log: method is post or the build fails, the lib reads the FormData before the vanish empties the field, and only numbers and short tokens are carried to the thanks page.
- The vanish is the proof the lib took the submit: its listener sits on the form (target phase), prevents the navigation synchronously, and VanishInput's listener on document sees defaultPrevented and blurs the text away while the request is out. A failure waits for the vanish, puts the text back and focuses the field.
- The status line exists at load, is never hidden, and reserves one line (min-block-size: 1lh): a live region that appears with its text is not announced, and a line that appears pushes the proof down. The button goes aria-disabled while pending, because disabled would drop the focus to the page.
- A demo form never pretends to collect addresses: method="dialog" goes nowhere outside a <dialog> even with JavaScript off, the lib answers with demoResponse once the vanish has finished, and a visible note says nothing is sent.
- The countdown's numbers are frozen at build; the <time datetime> under it is the date without JavaScript. After the launch a rebuild prints done in the server HTML; in the browser at zero the countdown's name becomes done, and the headline is not swapped. Swapping it takes a rebuild.
- The grid floor starts at 55 % of the height and every text below it sits on the plate, so no text is read over a grid line. The plate is the card colour on an unset section and the tone's own ground under a tone (the StatsRow rule). It re-points the primitives' base tokens (--ma-ink, --ma-panel) at each tone's concrete text and plate colours. It never uses currentColor, which a custom property resolves where it is used. The avatar chip and tooltip keep their text readable, and these reach 3 : 1 on every tone, as browser.mjs measures: the field's border, including its :user-invalid red (the theme's destructive mixed half and half with the tone's text through VanishInput's --ma-vanish-invalid), the checkbox's border, and every focus ring (the submit's through ShimmerButton's --ma-shimmer-ring, the privacy link's its own).
Replaces: Waitlist heroes (shadcnblocks) · Coming-soon countdown pages (Framer / Webflow templates) · PlaceholdersAndVanishInput over a RetroGrid (Aceternity / Magic UI compositions). See the migration table.
Related
a tick that draws · one indeterminate
Countdown Flip
DataA flip clock counting down to a date: days, hours, minutes, seconds, each a card whose top half folds down over the hinge when the number changes.
A highlight sweeps across the body of a button on a loop; hover lifts it on the preset's hover travel and adds a ring, and focus draws the same.
hints take turns · Enter sends · the words blur away
Placeholders that take turns, and text that blurs away on submit.
Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown