
Almost every app starts with a login form, and most login forms get the same details wrong: errors that appear too early, password managers that cannot fill the fields, and a submit button you can press twice. In this tutorial I build one that gets them right, using oks-ui's Form, FormFieldSet and Button.
What we're building
- An email field that must be filled in and look like an email address.
- A password field with a minimum length and a show/hide toggle.
- Error messages that appear after the user leaves a field, not while they are still typing.
- A submit button that shows a spinner and cannot be pressed twice.
- Autofill that works, so password managers can do their job.

The finished form in four states.
1. Install and import
npm install oks-uiImport each component from its own entry point, together with its stylesheet:
import "oks-ui/tokens.css"; // the design tokens every component stylesheet reads
import { Form, FormFieldSet } from "oks-ui/form-field-set";
import "oks-ui/form-field-set.css";
import { Button } from "oks-ui/button";
import "oks-ui/button.css";The first line loads the design tokens that every component stylesheet reads. Without it the components still render, but with fallback colours: in my test a danger button came out the same blue as a primary one.
2. The form
Form holds the values, runs validation and calls onSubmit only when every field is valid. Each FormFieldSet registers itself with the nearest Form by its name.
export const LoginForm = () => {
async function handleLogin(data: Record<string, unknown>) {
await signIn(String(data.email), String(data.password));
}
return (
<Form onSubmit={handleLogin} disableAutofill={false} autoComplete="on">
<FormFieldSet
type="email"
name="email"
label="Email"
placeholder="you@company.com"
autoComplete="email"
validation={{ rules: { required: true, email: true } }}
/>
<FormFieldSet
type="password"
name="password"
label="Password"
autoComplete="current-password"
validation={{
rules: { required: true, minLength: 8 },
message: { minLength: "Use at least 8 characters" },
}}
/>
<Button type="submit" color="primary" fullWidth>
Sign in
</Button>
</Form>
);
};This type-checks as written. I tested it with a fake signIn. Submitting an empty form did not call onSubmit and showed "This field is required" under both fields. A bad email and a short password were not sent either, and showed "Please enter a valid email address" and the custom "Use at least 8 characters". A valid pair called onSubmit once with the email and password as strings. That was the same in all three browsers.
3. Let password managers fill it in
By default, oks-ui's Form disables browser autofill. That is the right default for most forms: nobody wants a saved address appearing in a "company name" field. A login form is the exception.
I looked at what the default does. The <form> gets autocomplete="off", and Form adds two hidden decoy inputs named __oks_autofill_username and __oks_autofill_password, so the browser spends its autofill on those instead of your fields.
Two props turn this off: disableAutofill={false} and autoComplete="on" on the Form. With them, the decoys were gone and the form had autocomplete="on". Then give each field the standard autocomplete token: email for the email and current-password for the password. Those were on the inputs. A sign-up form would use new-password instead, which tells password managers to suggest a strong new password.
One honest limit: I checked the attributes, not a real password manager. Whether a particular manager fills the form is up to that manager.
4. Show errors at the right moment
Errors that appear while someone is still typing their email feel like being told off. Form shows an error after the user leaves a field, not while they are typing. I tested it: with an invalid email typed, no error was shown, and it appeared the moment I tabbed away.
The first version then said the error "updates as they fix it". With the default settings it does not. After the error appeared I went back and fixed the address, and the error stayed on screen until I left the field again. If you want the error to clear as soon as the value becomes valid, set both of these on the Form:
<Form onSubmit={handleLogin} validationMode="change" showErrorsOn="blur" disableAutofill={false} autoComplete="on">validationMode="change" checks the value on every keystroke, and showErrorsOn="blur" still holds the first error back until the field is left. With those two set, the error disappeared while I typed the fix, in all three browsers. For comparison, showErrorsOn="change" shows the error while you are still typing, which is the behaviour this tutorial avoids.
Every rule has a default message. Override any of them with message, as the password field does above.
5. A button that cannot be pressed twice
Form awaits your onSubmit. While the promise is pending, show a loading state so the user knows something is happening and cannot send the form again. With the button's isLoading prop:
const [pending, setPending] = useState(false);
async function handleLogin(data: Record<string, unknown>) {
setPending(true);
try {
await signIn(String(data.email), String(data.password));
} finally {
setPending(false);
}
}
// …
<Button type="submit" color="primary" fullWidth isLoading={pending}>
Sign in
</Button>This does what it says, and it covers the keyboard too, where it matters most. I pressed Enter three times quickly in the password field and counted the requests the page sent. With isLoading, one request went out. Without it, three. The button gets aria-busy while loading. That held in all three browsers.
6. Show a failed login
A wrong password is not a validation error, because the server decides it. Show it above the form with an Alert:
import { Alert } from "oks-ui/alert";
import "oks-ui/alert.css";
{error && (
<Alert color="danger" variant="soft" title="Couldn't sign you in" description={error} />
)}The alert has role="alert", so a screen reader announces it when it appears. After a failed attempt the email and password stayed in their fields, and the alert was removed as soon as I submitted again. The alert sits directly on top of the first label with no gap, so add some margin above the form.
Keep the message general, such as "Email or password is incorrect". Saying which of the two was wrong tells an attacker which emails have accounts.
Accessibility, checked
Each FormFieldSet connects its label to its input. After a failed submit, both inputs had aria-invalid="true" and aria-describedby pointing at the error message, which has role="alert". The password toggle is a real <button type="button"> named "Show password", and pressing Enter on it switched the field from password to text. The tab order was email, password, the show/hide button, "Forgot password?", then "Sign in".
axe-core found no problems in light mode, even with the error messages showing. Two things need your attention:
- Focus stays on the submit button after a failed submit. It does not move to the first invalid field. The errors are announced, but a keyboard user has to tab back up to the field. You can add the move yourself in
onError, whichFormcalls with the errors. I did not test that. - In dark mode the error text is too faint. It measured #e20202 on #0a0a0a, a contrast ratio of 4.0 to 1 against the 4.5 to 1 that WCAG AA asks for. That is a gap in oks-ui 1.4.1, not in your code. I have noted it for the library; nothing is released or fixed yet.
Next steps
- Add "Forgot password?" as a
Buttonwithvariant="link"andtype="button"under the password field. In my test, clicking it did not submit the form or show any errors. - For sign-up, add a confirm-password field with the
matchFieldrule:validation={{ rules: { required: true, matchField: "password" } }}. A mismatch showed "Fields do not match" and a match showed nothing. - See every field type on the form components page.
Results

Every result, in all three browsers.
On size: the whole page, React included, came to 111 KB of JavaScript and 23 KB of CSS gzipped.
Check your own form for double submits
This script fills your login form, presses Enter three times quickly and counts the requests sent to your login endpoint. Install playwright first.
// npm i -D playwright
// node double-submit-check.mjs http://localhost:3000/login "input[name=email]" "input[name=password]" /api/login
import { chromium, firefox, webkit } from "playwright";
const [url, emailSel, passwordSel, apiPath] = process.argv.slice(2);
for (const [name, type] of [["Chromium", chromium], ["Firefox", firefox], ["WebKit", webkit]]) {
const browser = await type.launch();
const context = await browser.newContext();
const page = await context.newPage();
let posts = 0;
page.on("request", (request) => {
if (request.method() === "POST" && request.url().includes(apiPath)) posts++;
});
await page.goto(url);
await page.fill(emailSel, "someone@example.com");
await page.fill(passwordSel, "a-long-enough-password");
// Press Enter three times in quick succession, as an impatient person would.
await page.click(passwordSel);
for (let i = 0; i < 3; i++) await page.keyboard.press("Enter");
await page.waitForTimeout(1500);
console.log(`${name}: ${posts} request(s) sent${posts > 1 ? " <- double submit" : ""}`);
await browser.close();
}On my form it printed "1 request(s) sent" for each browser with isLoading and "3 request(s) sent <- double submit" without it.
Limits
- The server was a fake: a function that waits 700 ms and rejects one particular password. Real network errors, rate limiting and lockouts were not tested.
- I tested the attributes that password managers read, not a real password manager.
- Only a desktop viewport was used, and I did not test with a screen reader.
- The
onErrorfocus move is a suggestion I have not tried.

