Skip to content

Components / Sections

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

tone
density
align

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

Baseline · newly available

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:

terminal
npx shadcn@latest add @moonarc-pro/hero-waitlist
src/pages/index.astro
---
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

PropTypeDefaultDescription
level1 | 21Heading level of the headline: 1 on the launch page itself, 2 under another h1 (a demo, or a block further down).
headlinestringnoneHeadline; required unless the headline slot is filled.
eyebrowstringnoneShort line above the headline.
copystringnoneSupporting copy.
launchstringnoneThe 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.
donestringnoneRequired. 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 fourCountdown cards, in order.
labelsPartial<Record<unit, string | [one, other]>>noneLabels under the cards; a pair gives the spoken name its singular ("1 day").
dateLabelstring'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.
timeZonestringthe offset in launchZone 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.
localestring'en'Intl locale of the date, the count and the avatar chip's number (its spoken sentence is moreLabel).
labelstring'Email address'The email field's accessible name, as its <label>.
showLabelbooleanfalseShow the label above the field; by default it is screen-reader text only.
placeholdersstring[]noneSuggestions shown in turn in the empty field; the first is the placeholder attribute.
namestring'email'Name of the email field (type="email", autocomplete="email", required).
submitLabelstring'Join the waitlist'The submit button's label (a ShimmerButton, type="submit", on --ma-sec-action).
actionstringnoneWhere the form posts without JavaScript. Required unless demo; the build fails without it.
endpointstringnoneWhere 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.
successstringnoneA 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.
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, and never aria-hidden on something focusable. A filled one marks a bot for your service.
formAttrsRecord<string, string | number | boolean>noneExtra 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 textsThe 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.
demobooleanfalseA 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.
demoResponseRecord<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.
consentstringnoneA required CheckDraw inside the form, named consent (value yes), with this text as its label; off by default.
privacystringnoneURL of the privacy policy, linked under the form.
privacyLabelstring'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.
moreLabelstring'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.
countnumbernoneHow many are waiting, formatted with Intl.NumberFormat(locale). Without it (and without the proof slot) the proof block is not rendered.
countLabelstring'{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

Replaces: Waitlist heroes (shadcnblocks) · Coming-soon countdown pages (Framer / Webflow templates) · PlaceholdersAndVanishInput over a RetroGrid (Aceternity / Magic UI compositions). See the migration table.

A row of overlapping avatars with a count chip such as "+2 398".

every browserhover

a tick that draws · one indeterminate

A checkbox whose tick is drawn.

every browserclick

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

every browserload

Retro Grid

Backgrounds

A perspective grid floor sliding toward the viewer.

every browseralways

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.

every browseralwayshover

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

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