Grid Pattern
BackgroundsA hairline grid behind content, faded toward the edges, optionally drifting one cell at a time.
A sign-in card in two steps, email then a one-time code, as the front end of your own auth service. Each step is a real form that posts, so it works with JavaScript off. With JavaScript the core form lib sends each step with fetch, CSS swaps step one for step two when the email is accepted, and the section's script moves the focus to the code field and cleans a pasted code of spaces and dashes. It ships no backend: your service keeps the session and answers both steps.
Sign-in · sample
Type any email for a code, then any six digits. This demo sends nothing.
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
JavaScript of its own
761 B raw
452 B gzip.
Uses the shared runtime (1.9 kB raw, once per site) and form. With those included: 5.4 kB raw.
With code: the same script as the default, 761 B raw. The code path is part of it and adds nothing; measured on its own fixture.
CSS 21.2 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 119 · Firefox 121 · Safari 16.5; needs has, user-invalid. Elsewhere: without :has() both steps show at once and each still sends; the code cells keep their resting border..
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/sign-in---
import SignIn from '@/components/moonarc/pro/SignIn.astro';
// your auth service's two endpoints; with the template kit, formProps({ adapter: 'custom', endpoint, success: false,
// refParam: false, carry: false, adapters: ['custom', 'demo'] }).props gives the same objects
const email = { action: '/api/auth/email', encode: 'json' as const };
const code = { action: '/api/auth/code', encode: 'json' as const };
---
<!-- The contract your service answers, for both steps:
step one POST email=… JSON (fetch): 2xx { ok: true } sends the code; 429 too many tries; any other status fails.
Native post: 303 to the page that renders <SignIn step="code" … />.
step two POST code=… the code alone; the session (a cookie your service set in step one) says whose it is.
JSON (fetch): 2xx { ok: true } signs in and the page goes to success; 4xx fails.
Native post: 303 to your app, or back to the code page on a wrong code.
Each request sends Accept: application/json; a fetch reads the status and the ok flag only, never your text. -->
<SignIn
headline="Sign in"
copy="We email you a six-digit code. No password."
email={email}
code={code}
success="https://app.example.com/"
resendHref="/sign-in/"
backdrop
>
<a slot="providers" href="/api/auth/sso">Continue with single sign-on</a>
</SignIn>
<!-- /sign-in/code/, the page step one's native post lands on (noindex): step two alone -->
<SignIn headline="Check your email" step="code" code={code} success="https://app.example.com/" resendHref="/sign-in/" />Without a key the registry answers 401 with an upgrade message. Everything this section composes is free: otp-input, stateful-button, grid-pattern.
| Prop | Type | Default | Description |
|---|---|---|---|
headline | string | none | Headline; required unless the headline slot is filled. |
eyebrow | string | none | Short line above the headline. Slot "eyebrow" wins. |
copy | string | none | Supporting copy. Slot "copy" wins. |
level | 1 | 2 | 1 | Heading level of the headline: 1 on the sign-in page itself, 2 under another h1 (a demo). |
step | 'email' | 'code' | 'email' | email renders both steps, step two hidden until step one answers done. code renders step two alone: the page a native post of step one lands on. |
emailLabel | string | 'Email address' | The email field's visible label (type="email", autocomplete="email", required). |
emailName | string | 'email' | Name of the email field. |
emailPlaceholder | string | 'you@example.com' | Placeholder of the email field. |
codeLabel | string | 'Code from the email' | The code field's visible label (OtpInput, autocomplete="one-time-code", required). |
codeName | string | 'code' | Name of the code field: step two's request carries this field alone. |
codeLength | number | 6 | Characters in the code, 4 to 12. |
charset | 'numeric' | 'alphanumeric' | 'numeric' | Digits only (a numeric keyboard) or letters and digits. |
submitLabels | { idle?, pending?, done?, error? } | 'Send the code' · 'Sending' · 'Sent' · 'Try again' | Step one's button faces (StatefulButton); the face that shows is its name. |
verifyLabels | { idle?, pending?, done?, error? } | 'Sign in' · 'Checking' · 'Signed in' · 'Try again' | Step two's button faces. |
resendHref | string | none | A link under step two back to step one, where a new code is asked for: the sign-in page. It works without JavaScript; without it there is no link. |
resendLabel | string | 'Send a new code' | Text of that link. |
changeEmailLabel | string | 'Use another email' | A button under step two that shows step one again with the focus in the email field (two-step form only, JavaScript only: without it the step is a page of its own). |
messages | { email?: { pending?, done?, error?, 429?, offline? }, code?: { … } } | English texts | Each step's status texts. Step one's done text ('We sent a code to your email.') is also written into step two's status when the step changes, so a screen reader hears why the page changed. A service's own text is never shown. |
demoLabel | string | 'Demo: nothing is sent.' | The visible note under the card when a step is a demo form. |
email | { action, endpoint?, encode?, honeypot?, formAttrs?, demo?, demoResponse? } | none | Step one's form: formProps() with the custom adapter, or demo. action is required unless demo. A Netlify or Formspree form fails the build: a sign-in step posts to your own endpoint. |
code | { action, endpoint?, encode?, honeypot?, formAttrs?, demo?, demoResponse? } | none | Step two's form, the same shape. action is required unless demo. |
success | string | none | Where step two sends the reader after done: your app, a path here or an absolute URL on another origin. Nothing is written to storage for it. Unset, the card says done and stays. |
backdrop | boolean | false | A GridPattern behind the card (aria-hidden), in the tone's text colour. |
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. The card takes the same ground and the buttons invert on ink and brand. |
density | 'compact' | 'cozy' | 'roomy' | 'cozy' | Block padding and the card's padding ×0.8 / ×1 / ×1.25 through --ma-density. |
align | 'start' | 'center' | 'center' | The card centred, or at the start of the section. |
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-otp__input.ma-otp__label.ma-signin.ma-signin__alt.ma-signin__aside.ma-signin__card.ma-signin__copy.ma-signin__demo.ma-signin__eyebrow.ma-signin__form.ma-signin__grid.ma-signin__head.ma-signin__headline.ma-signin__input.ma-signin__label.ma-signin__link.ma-signin__otp.ma-signin__providers.ma-signin__status.ma-signin__step.ma-signin__step--code.ma-signin__step--email.ma-signin__submit
slot="eyebrow"slot="headline"slot="copy"slot="providers"slot="aside"
The step change is instant in every mode (display, nothing travels). Under reduced motion the buttons change faces in place and the code cells and their check draw without animation.
ClientRouterThe script binds through the runtime's onMount (bindForms() for the two forms, the paste, the focus move and the back button), so a card that arrives by a ClientRouter navigation is bound fresh and its listeners go with the page. Leaving for success is a full navigation.
Replaces: Sign-in pages (Tailwind Plus) · Login (shadcnblocks) · Auth UI OTP flows (Clerk, Supabase UI). See the migration table.
A hairline grid behind content, faded toward the edges, optionally drifting one cell at a time.
one input · paste and autofill work
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.
Contact · sample page
Sample channels on the left. The form on the right is real and sends nothing here: fill it in and watch the button.
Demo: nothing is sent.
A contact section in two columns: the ways to reach you (email, phone, office) on one side, a form that really sends on the other.
Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown