A login is the authentication stack: a header with logo and title, one or more form panes, and trailing text actions. The class is thin by design — it composes Card, the form primitives, Password Toggle and Button rather than restyling them. It ships no JavaScript of its own: switching between panes (sign in, reset, one-time code) is consumer JS toggling , and password reveal is password-toggle.js.
Centering on the page belongs to the consuming layout, not the component. caps its own width and stacks its children; where it sits is the page's decision.
Anatomy
| Class | Role |
|---|---|
| Root column — width cap, gap-based stack | |
| Logo, title and subtitle, centered | |
Logo slot; inherits currentColor |
|
| The heading | |
| Supporting line; collapses when empty | |
| One form pane — a page may hold several, one visible at a time | |
| Row between fields and submit: remember-me against forgot-password | |
| Form-level live region for error or success messages | |
| Trailing text actions below a pane |
Basic usage
Sign in with email and password. The pane's own gap owns the spacing between groups; inside a group, the label-to-input rhythm is tighter. Wrap the stack in a when the page wants a panel.
Welcome back
Sign in to your account
<div class="card login">
<div class="login-header">
<div class="login-logo" role="img" aria-label="By Default logo">
<!-- inline logo SVG -->
</div>
<h1 class="login-title">Welcome back</h1>
<p class="login-subtitle">Sign in to your account</p>
</div>
<form class="login-form" autocomplete="on" novalidate>
<div class="form-group">
<label for="login-email">Email</label>
<input type="email" id="login-email" name="email"
autocomplete="username" autocapitalize="none" spellcheck="false" required aria-required="true">
</div>
<div class="form-group">
<label for="login-password">Password</label>
<div class="password-field">
<input type="password" id="login-password" name="password"
autocomplete="current-password" autocapitalize="none" spellcheck="false" required aria-required="true">
<button type="button" class="password-toggle" aria-label="Show password" aria-pressed="false">
<div class="svg-icn" data-icon="eye-off"><!-- inline eye-off SVG --></div>
<div class="svg-icn" data-icon="eye"><!-- inline eye SVG --></div>
</button>
</div>
</div>
<div class="login-meta">
<div class="form-check">
<input type="checkbox" id="login-remember" name="remember">
<label for="login-remember">Remember me</label>
</div>
<button type="button" class="button" data-variant="text">Forgot password?</button>
</div>
<p class="login-status" id="login-status" data-type="danger" role="alert" aria-live="assertive"></p>
<button type="submit" class="button" data-full-width>Sign in</button>
</form>
</div>
On the page the demo heading is an h2; in an application the login title is usually the page's h1. The subtitle collapses when empty, so flow JS can fill it only when there is something to say — an invite instruction, a recovery notice.
Create account
Sign-up swaps the field set: name, email, a new password with a hint. The footer points existing users back to sign in.
Create your account
<div class="form-group">
<label for="signup-password">Password</label>
<div class="password-field">
<input type="password" id="signup-password" name="password"
autocomplete="new-password" autocapitalize="none" spellcheck="false" required aria-required="true"
minlength="8" aria-describedby="signup-hint">
<button type="button" class="password-toggle" aria-label="Show password" aria-pressed="false">
<div class="svg-icn" data-icon="eye-off"><!-- inline eye-off SVG --></div>
<div class="svg-icn" data-icon="eye"><!-- inline eye SVG --></div>
</button>
</div>
<p class="form-hint" id="signup-hint">At least 8 characters.</p>
</div>
Every password field gets the reveal toggle — creating a password is where seeing what was typed matters most.
Forgot and reset password
Two panes. The request pane asks for an email and sends a link; the set pane takes the new password twice. On one page they sit as sibling panes and consumer JS switches them with .
Reset your password
We will email you a reset link
The set pane pairs two new-password fields, and carries a hidden autocomplete="username" input that consumer JS fills with the account email from the reset token — that is what lets password managers save the new credential against the right account. With the reveal toggle present the confirm field is optional — a user who can see what they typed doesn't need to type it twice — so a product may drop it and keep only the first field. The example below also shows the error state: and aria-invalid="true" on the input together, with a message wired in through aria-describedby.
Set a new password
<div class="form-group">
<label for="setpw-confirm">Confirm password</label>
<div class="password-field">
<input type="password" id="setpw-confirm" name="confirm-password"
autocomplete="new-password" autocapitalize="none" spellcheck="false" required aria-required="true" minlength="8"
class="is-error" aria-invalid="true" aria-describedby="setpw-confirm-error">
<button type="button" class="password-toggle" aria-label="Show password" aria-pressed="false">
<div class="svg-icn" data-icon="eye-off"><!-- inline eye-off SVG --></div>
<div class="svg-icn" data-icon="eye"><!-- inline eye SVG --></div>
</button>
</div>
<p class="form-error" id="setpw-confirm-error">Passwords do not match.</p>
</div>
Magic link
Passwordless entry: one email field, one action. Pair it with the one-time code pane below when the flow confirms with a code rather than a link.
Sign in without a password
We will email you a sign-in link
<form class="login-form" autocomplete="on" novalidate>
<div class="form-group">
<label for="magic-email">Email</label>
<input type="email" id="magic-email" name="email"
autocomplete="username" autocapitalize="none" spellcheck="false" required aria-required="true">
</div>
<p class="login-status" id="magic-status" data-type="danger" role="alert" aria-live="assertive"></p>
<button type="submit" class="button" data-full-width>Email me a link</button>
</form>
One-time code
The code field is a plain text input tuned for the moment: inputmode="numeric" raises the number pad on touch keyboards, autocomplete="one-time-code" lets the browser offer the code straight from the message that delivered it, and spellcheck="false" stops the squiggle under a string that was never a word.
Enter your code
Sent to you by email
<div class="form-group">
<label for="otp-code">One-time code</label>
<input type="text" id="otp-code" name="code"
inputmode="numeric" autocomplete="one-time-code" spellcheck="false"
pattern="[0-9]*" maxlength="6" required aria-required="true">
</div>
Social sign-in
Provider buttons are plain buttons: data-variant="outline" with data-full-width, a brand mark in the standard wrapper, stacked below a labelled Divider. No role class, no token overrides.
Welcome back
<div class="divider divider--labelled">or</div>
<button type="button" class="button" data-variant="outline" data-full-width>
<div class="svg-icn" data-icon="google"><!-- inline Google mark --></div>
Continue with Google
</button>
<button type="button" class="button" data-variant="outline" data-full-width>
<div class="svg-icn" data-icon="apple"><!-- inline Apple mark --></div>
Continue with Apple
</button>
The Google and Apple marks are site-local icons — they live at assets/icons/local/, deliberately outside the shared icon registry, because third-party brand marks are a per-product licensing decision, not a system asset. Consumers copy them from this page's code or author their own. Both ship as monochrome currentColor paths so they follow the theme; a product bound by Google's full sign-in branding rules may substitute the official multicolour G inline — fixed fills work, they will not theme.
JavaScript
The component ships no script of its own. Wiring a login page in a new product takes the stylesheet install (see the appendix below), one shipped module, and three small pieces of your own JavaScript — in the order they come up.
1. Include the password reveal module once per page. The toggle markup carries both state icons; the contract is in Password Toggle.
<script src="/assets/js/password-toggle.js" defer></script>
2. Intercept every submit. Each submitting pane is a real <form>, and the handler prevents the default — a form that falls through to a native submit navigates with the typed values, credentials included, in the URL. Keep the button enabled and mark it while the request runs; write the outcome into the pane's . The status element is already in the DOM and stays in the accessibility tree while empty, so filling it with text is all a reliable announcement needs.
const form = document.querySelector('#sign-in-form');
const status = form.querySelector('.login-status');
const submit = form.querySelector('[type="submit"]');
async function handleSignIn(event) {
event.preventDefault();
status.textContent = '';
submit.classList.add('is-loading');
try {
await signIn(new FormData(form)); // your auth call
} catch (error) {
status.textContent = 'That email and password did not match. Try again.';
} finally {
submit.classList.remove('is-loading');
}
}
form.addEventListener('submit', handleSignIn);
3. Switch panes with and move focus. Render each flow as a sibling , keep one visible at a time, and land focus where the flow continues.
function showPane(next) {
document.querySelectorAll('.login-form').forEach(function (pane) {
pane.classList.toggle('is-hidden', pane !== next);
});
const field = next.querySelector('input:not([hidden])');
if (field) field.focus();
}
4. Fill what the flow knows. On the set-new-password pane, write the account email from the reset token into the hidden autocomplete="username" input — that is what lets password managers pair the new credential with the account. Write flow instructions into ; it collapses when empty, so it costs nothing on panes with nothing to say.
The social sign-in buttons and their brand marks are in Social sign-in — the marks are site-local by design and deliberately not shipped with the system, so copy them from that section's code into your own assets.
Accessibility
- Every input carries the
autocompletevalue for its moment — this is what makes password managers and OS keychains work:
| Field | autocomplete |
|---|---|
| Email as sign-in identifier (sign in, sign up, reset, magic link) | username |
| Email as a contact detail only | email |
| Full name | name |
| Current password (sign in) | current-password |
| New password (sign up, reset) | new-password |
| One-time code | one-time-code |
On sign-up the email is the future identifier, so it takes username — that is what lets password managers pair it with the adjacent new-password field and save the credential.
- Set and
aria-invalid="true"together, always — the class paints the border and the danger focus ring, the attribute tells assistive technology. One without the other is a half-reported error. - Per-field messages use with an
id, wired to the input througharia-describedby. - The form-level is a live region:
role="alert"witharia-live="assertive"for errors, orrole="status"witharia-live="polite"for confirmations. A pane that needs both keeps two elements. Keep the element in the DOM and rendered from first render and fill it with text — a region inserted or revealed fromdisplay: noneat announce time is not reliably announced, which is why the empty state collapses by height rather than bydisplay. Don't also point fields at it witharia-describedby— the live region announces on its own, and a form-level message is not a field description. - The one-time code field pairs
inputmode="numeric"with a visible label; never replace the label with a placeholder. - Labels are always visible
<label for>elements. The password toggle keeps itsaria-label/aria-pressedcontract from Password Toggle. - Identifier fields (email as username, one-time code) carry
autocapitalize="none"andspellcheck="false"— mobile keyboards otherwise capitalise the first letter and spellcheck marks the address, both of which cause failed sign-ins that look like wrong passwords. - Password fields carry the same pair. The reveal toggle flips them to plain text inputs, and at that moment autocapitalize can mangle further typing while browser spellcheck can send the revealed value to a spellcheck service.
- A pane that sets a password without showing the email — the set-new-password flow — includes a hidden
autocomplete="username"input, filled by consumer JS, so password managers know which account the new credential belongs to. - When consumer JS switches panes, move focus to the revealed pane's first field or heading — keyboard and screen-reader users otherwise stay on a control that just disappeared.
Usage rules
Do:
- Compose with when the page wants the form on a panel
- Let the page layout own the centering — only caps its own width
- Keep one pane visible at a time; switch siblings with
- Put trailing text actions ("Back to sign in", "Resend code") in a as with
data-variant="text" - Give every password field the reveal toggle, including sign-up — creating a password is where seeing it matters most
- While a submit is in flight, keep the button enabled and mark it — a disabled button drops keyboard focus and goes silent for screen readers
- Answer a reset request the same way whether or not the account exists — "If an account exists for that address, we've sent a link" — so the form never confirms which emails are registered
- Write error text that states the fix, not just the failure — "Passwords need at least 8 characters", matching the field's
minlength, beats "Invalid password"
Don't:
- Don't nest a login inside a login, or stack two visible panes
- Don't add margins inside the stack — the gaps own the spacing
- Don't invent provider button styles — a social button is with
data-variant="outline",data-full-widthand a mark - Don't put brand marks in the shared icon registry — they are site-local by design
- Don't block paste in password fields — pasting from a password manager is the desired path, not an abuse case
- Don't cap password
maxlengthshort or restrict characters — length is the strength that costs users nothing; keepminlengthin step with the backend policy so the browser and the server reject the same inputs
CSS reference
This section documents how the component is built. For usage, see the sections above.
Tokens
| Token | Default | Purpose |
|---|---|---|
380px |
Width cap of the root column — the one-column form measure | |
88px |
Height of the header logo slot |
Selectors
| Selector | Purpose |
|---|---|
| Root column: full width up to the cap, flex column, gap, centered items | |
| Centered logo/title/subtitle stack with gap | |
Logo slot at token height; inner svg scales to it and inherits currentColor |
|
| Heading at , weight from the heading element, document-flow margins removed | |
Supporting line at in ; :empty collapses it |
|
| Full-width pane, flex column with gap | |
.login-form .form-group |
Scoped tightening: label-to-input gap, margin-bottom zeroed — the pane gap owns inter-group spacing |
.login-form label |
|
.login-form .divider |
Re-points to — the pane gap already separates it |
| Wrapping space-between row for remember-me and the forgot link | |
Form-level live region at , centered; :empty collapses it to zero height while it stays in the accessibility tree; data-type="danger"/"success" pick the status colour |
|
| Centered column for trailing text actions |
Key rules
- Spacing is gap-only throughout — no margins inside the stack, per the layout rules
- tightens by scope rather than introducing a new field class, so the form primitives stay untouched everywhere else
data-typeon uses the shared feedback axis values (danger,success) resolving to--status-*tokens- The component carries no dark-mode CSS — every colour is a semantic token
Use in another product
The design system installs once per product:
npm install github:bydefaultstudio/design-system-dist#semver:^4.7.0
This component's styles ship in design-system.css. Its behaviour ships as dist/js/password-toggle.js — copy it into the product's served assets and include it once per page:
<script src="assets/js/password-toggle.js" defer></script>