Validation

Marking fields invalid, the submitting state, and server-side errors.

The invalid state

A control with aria-invalid="true" gets the danger border. The same border applies on its own through :user-invalid: the state matches once the user has interacted with a control and left it invalid, and it clears when the value passes. An empty required field does not render invalid on page load, because nothing has been interacted with yet.

Note that this automatic state doesn't require any scripting, but it also doesn't provide a message. So, browsers without :user-invalid support keep the aria-invalid border, but they ignore the automatic one.

Marking on blur

When a field needs its own message before you submit, you can set the state yourself using on blur after the field has been used. For example, set aria-invalid="true", show the message, and point aria-describedby at the message. On a later blur that passes, undo all three. That demo below provides a sample JavaScript snippet that does this.

<form class="stack" style="max-inline-size: 24rem" data-validate>
            <div>
                <div class="stack is-tight">
                    <label for="val-email">Email</label>
                    <input id="val-email" type="email" class="input" required />
                    <p class="text-danger text--1" hidden>
                        Enter an address like name@example.com.
                    </p>
                </div>
            </div>
            <button class="button" type="submit">Subscribe</button>
        </form>
        <script>
            document.querySelector('form[data-validate]').addEventListener(
                'blur',
                (e) => {
                    const control = e.target;
                    if (!control.matches('.input')) return;
                    const message = control.closest('.stack').querySelector('.text-danger');
                    const invalid = control.value !== '' && !control.checkValidity();
                    /* aria-invalid carries the literal value "true" -- the
                       empty value toggleAttribute writes never matches the
                       [aria-invalid="true"] arm of the invalid restyle. */
                    if (invalid) control.setAttribute('aria-invalid', 'true');
                    else control.removeAttribute('aria-invalid');
                    message.toggleAttribute('hidden', !invalid);
                },
                true
            );
        </script>

On submit

The native submit pass blocks required and format violations, focuses the first invalid control, and shows a message next to it. That works for most forms. But, a custom handler can help mark each failing field the same way as the blur pass: the attribute, the message, aria-describedby. The styling does not change.

While submitting

While the form is submitting, it should not be possible to submit again, and the wait should be visible. To show this state, you can make two changes: disable the fieldset wrapping the fields, and set aria-busy="true" on the submit button, which shows Brio's spinner. Disabling the fieldset disables every control inside it at once; no script walks the fields. Undo both when the response arrives. Or, if you're not using a fieldset, disable each control individually.

<form class="stack" style="max-inline-size: 24rem" data-submit-demo>
            <fieldset class="stack is-tight">
                <div class="stack is-tight">
                    <label for="sub-team">Team name</label>
                    <input id="sub-team" type="text" class="input" required />
                </div>
                <div class="stack is-tight">
                    <label for="sub-seats">Seats</label>
                    <input id="sub-seats" type="number" class="input" value="5" min="1" required />
                </div>
            </fieldset>
            <button class="button" type="submit">Create team</button>
        </form>
        <script>
            document.querySelector('form[data-submit-demo]').addEventListener('submit', (e) => {
                e.preventDefault();
                const form = e.currentTarget;
                const button = form.querySelector('button[type="submit"]');
                form.querySelector('fieldset').disabled = true;
                button.setAttribute('aria-busy', 'true');
                setTimeout(() => {
                    form.querySelector('fieldset').disabled = false;
                    button.removeAttribute('aria-busy');
                }, 1500);
            });
        </script>

Server errors

When a server rejects or invalidates a submission, you can return a page or snippet with the form intact and an alert above it. Use .alert.is-danger with role="alert", to help announce it to screen readers.

<div class="stack" style="max-inline-size: 24rem">
            <div class="alert media is-danger" role="alert">
                <svg
                    class="media-figure icon"
                    viewBox="0 0 24 24"
                    fill="none"
                    stroke="currentColor"
                    stroke-width="2"
                    stroke-linecap="round"
                    stroke-linejoin="round"
                    aria-hidden="true"
                >
                    <circle cx="12" cy="12" r="10" />
                    <path d="M12 8v4M12 16h.01" />
                </svg>
                <div class="media-body">
                    <h4>2 problems need fixing</h4>
                    <ul>
                        <li><a href="#srv-team">The team name is already taken.</a></li>
                        <li><a href="#srv-seats">Seats must be a whole number.</a></li>
                    </ul>
                </div>
            </div>
        </div>

For the fields, you can add an error message to each field, which helps explain what went wrong. If you're using a fieldset, you can add an error message to it, too. Each field the summary names is a field unit in its error state: aria-invalid="true", the message in text-danger, aria-describedby pointing at it. So, the links in the alert can be set to match the field's id, and Brio's reset adds scroll margin to :target so following it clears the viewport edge.

Client-side validation still runs on a form like this. The client catches format problems before submission; the server's response re-renders the form, and the re-rendered markup is what shows.

The team name is already taken. Try one with a suffix.

Seats must be a whole number.

<form class="stack" style="max-inline-size: 24rem">
            <div>
                <div class="stack is-tight">
                    <label for="srv-team">Team name</label>
                    <input
                        id="srv-team"
                        type="text"
                        class="input"
                        required
                        aria-invalid="true"
                        aria-describedby="srv-team-error"
                    />
                    <p id="srv-team-error" class="text-danger text--1">
                        The team name is already taken. Try one with a suffix.
                    </p>
                </div>
            </div>
            <div>
                <div class="stack is-tight">
                    <label for="srv-seats">Seats</label>
                    <input
                        id="srv-seats"
                        type="number"
                        class="input"
                        value="2.5"
                        required
                        aria-invalid="true"
                        aria-describedby="srv-seats-error"
                    />
                    <p id="srv-seats-error" class="text-danger text--1">
                        Seats must be a whole number.
                    </p>
                </div>
            </div>
            <div class="cluster is-end">
                <button class="button" type="submit">Create team</button>
            </div>
        </form>