Press / to search

    No results

    Search failed

    npm GitHub

    <field-elemental>

    Wrap a label and a control, and the browser's refusal to submit becomes a sentence under the field β€” tied to it, announced, and yours to style. Light DOM, no shadow root, nothing moved.

    <form>
      <field-elemental>
        <label for="email">Email address</label>
        <input type="email" id="email" name="email" required>
      </field-elemental>
    
      <field-elemental>
        <label for="handle">Handle</label>
        <input id="handle" name="handle" required pattern="[a-z0-9_]{3,20}"
               aria-describedby="handle-hint">
        <p id="handle-hint">Three to twenty lowercase letters, numbers or underscores.</p>
      </field-elemental>
    
      <button type="submit">Sign up</button>
    </form>
    /* the page's own look, all of it β€” the element styles no control, on the reasoning that a
       field is what a design system already owns. what it does give you is the hook two rules
       down */
    form { display: grid; gap: 1.25rem; max-width: 24rem; }
    field-elemental { display: grid; gap: 0.35rem; }
    field-elemental label { font-size: 0.875rem; font-weight: 500; }
    
    field-elemental input {
      box-sizing: border-box;
      padding: 0.6rem 0.75rem;
      font: inherit;
      color: CanvasText;
      background: Canvas;
      border: 1px solid color-mix(in srgb, CanvasText 30%, transparent);
      border-radius: 0.375rem;
    }
    field-elemental input:focus-visible { outline: 2px solid CanvasText; outline-offset: 1px; }
    
    /* the hook: `aria-invalid` is on the control the whole time the message is up and off it
       the rest of the time, so the rim is one selector and asks nothing of JavaScript. it is
       never the only cue β€” the message under it says what is wrong in words */
    field-elemental input[aria-invalid="true"] { border-color: var(--field-elemental-error-color); }
    
    field-elemental [id$="-hint"] {
      margin: 0;
      font-size: 0.8125rem;
      color: color-mix(in srgb, CanvasText 60%, transparent);
    }
    
    
    form > button {
      justify-self: start;
      padding: 0.5rem 1rem;
      font: inherit;
      color: Canvas;
      background: CanvasText;
      border: 0;
      border-radius: 0.375rem;
    }

    Submit it empty. Then put one character in the email field and tab away. The hint under the handle field is still described when the error arrives β€” the element appends to aria-describedby, it does not take it over.

    Every line of that CSS is the page's. The element contributes one thing to it, [aria-invalid="true"] on the control, and that is the whole of its styling contract.

    Why this needs script at all

    The validation is already there. required, type="email", pattern, minlength are attributes, the browser enforces them, and nothing here re-implements any of it. What the platform leaves undone is the half after the refusal.

    What the native bubble does Why it is a problem
    Cannot be styled it is the one part of your form that is not your form
    Disappears when the field takes focus the reader reads it, clicks the field to fix it, and the instruction is gone
    Shown for the first invalid control and no other a form with four problems reports one, three times over
    Not reliably announced a screen reader user is told the submit did nothing, and not what to do about it
    Gone on the next paint nothing to come back to, and nothing a second reader can point at over a shoulder

    So every form on the web either lives with it or hand-writes a replacement β€” and the replacement is where the accessibility goes. A red paragraph that no aria attribute ties to the field is a message a screen reader user never meets, which trades WCAG 3.3.1 Error Identification for a colour.

    Prior art

    What it gives you Shape
    afova the fullest of these: per-constraint messages from data-* attributes, message placeholders, async validation, a violation summary, focus to the first invalid afova(), called once, over every form on the page
    GOV.UK error message a house style, an error summary at the top of the page, and the research behind both markup and classes you write yourself; no script does the wiring
    Shoelace nothing, on purpose β€” "The 'correct' solution to this is probably a separate form validation library" closed; the repo was archived in May 2026
    this the bubble cancelled, the browser's own message in a paragraph, aria-describedby, aria-invalid an element around markup you already wrote

    afova is the more capable library and the better answer if you want a message vocabulary β€” it is worth reading before you pick this. The difference is where the work lands: it configures forms from JavaScript, and this is a tag you put around a field, which is what the rest of this book is and what makes it work from a <script> tag with nothing called.

    When it speaks

    The rule the native bubble uses is one moment and no others. The rule most hand-written validators use is every keystroke, which tells a reader their email address is invalid after they have typed n. Neither is right, so the rule is this:

    Moment With no message showing With one showing
    A submit was refused say why, always say why
    Focus leaves the field say why, but only if something was typed in take it down once the value is right
    Typing, or a change nothing β€” typing never starts a complaint keep it current, and end it when answered
    The form was reset nothing to do take it down β€” or put back the one the page was rendered with

    "Something was typed in" is about the value, not the visit: a field typed into and emptied again is back to untouched, and tabbing through a form never lights it up.

    novalidate on the form turns off the submit half. No invalid event is fired, so nothing stops the submit and nothing is said about it β€” only the blur rule is left. That is the attribute doing exactly what it says, and it is worth knowing before you reach for it here.

    What it writes

    Given the markup above, an invalid field ends up as:

    <field-elemental>
      <label for="email">Email address</label>
      <input type="email" id="email" name="email" required
             aria-invalid="true" aria-describedby="email-error">
      <p class="field-elemental-error" id="email-error">Please fill out this field.</p>
    </field-elemental>

    Valid again, the <p> is hidden and empty, aria-invalid is removed rather than set to false, and aria-describedby goes back to whatever it held before β€” a hint you wrote is never lost, and never left describing a field with nothing wrong with it.

    The control gets an id if it did not have one. The message takes the control's id plus -error.

    aria-describedby, not aria-errormessage

    aria-errormessage is the attribute written for exactly this, and it is still not the one that works. Adrian Roselli's testing found the message behind it "generally not exposed when navigating through fields", against aria-describedby being "consistently exposed"; support was still not there in mid-2024. The day it lands this is one attribute name to change.

    There is no live region on the message either, which is the counter-intuitive half. aria-describedby is already announced when focus leaves the field and the message appears, so aria-live on top of it is the same sentence twice in NVDA and JAWS, and stops VoiceOver reading the description at all.

    Errors from the server

    Write the paragraph yourself with the message in it and the element adopts it β€” same class, same wiring, no second code path:

    <field-elemental>
      <label for="email">Email address</label>
      <input type="email" id="email" name="email" value="taken@example.com" required>
      <p class="field-elemental-error">That address is already registered.</p>
    </field-elemental>

    With no script that is a paragraph under a field, which is a working page. With the script it becomes an announced, described error at upgrade.

    Editing the field takes it down, because editing it is the reader answering it. Whether the new value is any better is the server's to say on the next submit β€” the element does not call setCustomValidity() with a server message, because that would block the form on a sentence nothing on the page can clear.

    Resetting the form puts it back. A reset restores the values the page was rendered with, and a message the page was rendered with was about exactly those β€” taking it down would leave the form holding the value the server already refused with nothing on screen saying so.

    Custom messages

    There is no message vocabulary here β€” no data-required-message, no invalid-message. The platform already has one call for it:

    input.setCustomValidity('We need this to send your receipt.');
    input.setCustomValidity(''); // and this is how it goes away again

    The element shows whatever validationMessage holds, so a custom validity appears the same way the browser's own wording does β€” already in the reader's language when it is the browser's, and yours when it is not. The trap comes with the platform call and is worth knowing: a control with a custom validity set stays invalid until that second call is made.

    title is not a substitute, and this is the one thing the element makes worse. A title beside a pattern is folded into the bubble by every browser, but only some fold it into validationMessage β€” measured, WebKit does and Chromium does not:

    Browser validationMessage for a failed pattern with a title
    Chromium Please match the requested format. β€” the title is in the bubble only
    WebKit Match the requested format: Three to twenty lowercase…

    Cancel the bubble and Chromium's half of that hint has nowhere left to go. So say what the field wants in a hint next to it, the way the demo above does β€” which is WCAG 3.3.2 Labels or Instructions and worth doing whether or not anything is ever wrong β€” and use setCustomValidity() when the message itself has to be specific.

    Two fields that have to agree

    A confirm field is the constraint HTML has no attribute for, and setCustomValidity() is where it goes. There is no match attribute here, because this is already the whole of it:

    const check = () => confirm.setCustomValidity(
      confirm.value === password.value ? '' : 'Passwords do not match.'
    );
    confirm.addEventListener('input', check);
    password.addEventListener('input', check); // the half that is usually forgotten

    That second listener is the bug in most hand-written versions: editing the password after the confirmation was already filled in leaves a mismatch nothing re-checks. The element shows, announces and clears whatever those four lines decide, so the accessible half is not written twice.

    What it cannot do is speak for a field the reader is not in. Changing the password says nothing under the confirm field until they come back to it or submit β€” an element watching one control cannot know another one moved, and shouting about the second field while they are still typing in the first would be the wrong answer anyway.

    The element reads the validity at the end of the event rather than during it, which is what makes the above work at all: a page's input listener is registered after the element upgraded, so it runs after the element's. Reading any sooner would report the answer from before your rule ran, and the message would clear a keystroke late.

    What it does not do

    Why
    Validate anything required, pattern, type and setCustomValidity() are the whole constraint layer, and they are the browser's
    Style the control this book is not a design system. [aria-invalid="true"] is on the control the whole time the message is up β€” the rim, the ring, the tint are your CSS
    Leave focus where the browser put it it cannot. Cancelling invalid to drop the bubble drops the browser's focus with it β€” a refused submit leaves focus on the button in Chromium and on <body> in WebKit. So the element does focus the first invalid control, and only that one. Firefox was not checked
    Summarise errors at the top of the form that is a page-level component, not a field, and GOV.UK's error summary is the one to copy
    Handle a radio or checkbox group one message belongs to one answer. A group is a <fieldset>, and pointing this at the first radio would describe the group by whichever one the reader landed on
    Reserve space for the message the message is hidden until there is one, so the field below it moves down a line when it appears. Holding the line open costs a blank gap under every field that is never wrong, which is the more visible of the two β€” and it means overriding [hidden] back to display: block, so the space is held by an element saying it is not there. Add it in your own CSS if your layout needs it

    Events

    Event When detail
    field-validity the message appears, changes, or goes valid, and message β€” the text now on screen, empty when there is none

    Installation

    <script src="https://unpkg.com/book-of-elementals/dist/elementals/field.min.js"></script>
    <link rel="stylesheet" href="https://unpkg.com/book-of-elementals/dist/elementals/field.css">
    <link rel="stylesheet" href="https://unpkg.com/book-of-elementals/dist/elementals/field-theme.css">
    import 'book-of-elementals/field';
    @use "book-of-elementals/field/style.scss";
    @use "book-of-elementals/field/theme.scss"; // optional look