Skip to content

Components / Sections

Sign In

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.

Live demo

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

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

Baseline · newly available

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

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/sign-in
src/pages/index.astro
---
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.

Props

PropTypeDefaultDescription
headlinestringnoneHeadline; required unless the headline slot is filled.
eyebrowstringnoneShort line above the headline. Slot "eyebrow" wins.
copystringnoneSupporting copy. Slot "copy" wins.
level1 | 21Heading 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.
emailLabelstring'Email address'The email field's visible label (type="email", autocomplete="email", required).
emailNamestring'email'Name of the email field.
emailPlaceholderstring'you@example.com'Placeholder of the email field.
codeLabelstring'Code from the email'The code field's visible label (OtpInput, autocomplete="one-time-code", required).
codeNamestring'code'Name of the code field: step two's request carries this field alone.
codeLengthnumber6Characters 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.
resendHrefstringnoneA 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.
resendLabelstring'Send a new code'Text of that link.
changeEmailLabelstring'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 textsEach 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.
demoLabelstring'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? }noneStep 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? }noneStep two's form, the same shape. action is required unless demo.
successstringnoneWhere 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.
backdropbooleanfalseA 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.

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

Slots

slot="eyebrow"slot="headline"slot="copy"slot="providers"slot="aside"

Reduced motion

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.

With ClientRouter

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

Why it is built this way

Replaces: Sign-in pages (Tailwind Plus) · Login (shadcnblocks) · Auth UI OTP flows (Clerk, Supabase UI). See the migration table.

Grid Pattern

Backgrounds

A hairline grid behind content, faded toward the edges, optionally drifting one cell at a time.

every browseralways

one input · paste and autofill work

A one-time code in cells, from one native input.

every browserclick

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.

every browserclick

Contact · sample page

Write to the team.

Sample channels on the left. The form on the right is real and sends nothing here: fill it in and watch the button.

Email (sample)
hello@studio.example
Phone (sample)
+1 555 0100
Office (sample)
1 Sample Street, Springfield

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.

Chrome 135 · not Firefox · Safari 27click

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