Validation
tier 2 — classes
A field is at rest, rejected or unusual. Rejected keys off aria-invalid, never a
class, so the styling and what a screen reader announces cannot disagree; unusual is
data-state="warn". Both go on the control, every field family draws both, and
aria-describedby on the control makes the message part of the field.
Error and warning
.form-error is a rejected value. .form-warning is one that is
accepted but unwise — its field carries data-state="warn" and no
aria-invalid, because the value is kept.
Not a complete address — nothing would be delivered.
Beyond the 365-day archive window, so the tail cannot be restored.
<div style="max-width:460px; display:flex; flex-direction:column; gap:16px">
<div class="form-field">
<label class="form-label form-label--required" for="f-mail">Notification address</label>
<input class="form-input" id="f-mail" type="email" required
aria-invalid="true" aria-describedby="f-mail-err" value="ops.example" />
<p class="form-error" id="f-mail-err">
<i class="ri-error-warning-line"></i>
<span>Not a complete address — nothing would be delivered.</span>
</p>
</div>
<div class="form-field">
<label class="form-label" for="f-retain">Retention</label>
<input class="form-input" id="f-retain" type="text" value="400 days"
data-state="warn" aria-describedby="f-retain-warn" />
<p class="form-warning" id="f-retain-warn">
<i class="ri-alert-line"></i>
<span>Beyond the 365-day archive window, so the tail cannot be restored.</span>
</p>
</div>
</div>
Unusual, not rejected
data-state="warn" is for a value that is saved but worth a second look —
outside the usual range, far from the average. Red would tell the reader it was refused.
No ARIA attribute says "unusual", so the .form-warning tied by
aria-describedby is what a screen reader hears; write it every time. On an
.input-group the attribute still goes on the control, and the group draws the
edge.
Usually 30 to 365. Saved, but the archive only reaches back 365 days.
This customer has never been shipped to from AP-South.
Four times the average of the last 90 days.
<div class="sedna-col" style="max-width:520px; gap:16px">
<div class="form-field">
<label class="form-label" for="un-retain">Retention</label>
<div class="input-group">
<input class="form-input" id="un-retain" type="number" value="400"
data-state="warn" aria-describedby="un-retain-warn" />
<span class="input-affix">days</span>
</div>
<p class="form-warning" id="un-retain-warn">
<i class="ri-alert-line"></i>
<span>Usually 30 to 365. Saved, but the archive only reaches back 365 days.</span>
</p>
</div>
<div class="form-field">
<label class="form-label" for="un-region">Shipping region</label>
<select class="form-select" id="un-region" data-state="warn" aria-describedby="un-region-warn">
<option>EU-West</option>
<option selected>AP-South</option>
</select>
<p class="form-warning" id="un-region-warn">
<i class="ri-alert-line"></i>
<span>This customer has never been shipped to from AP-South.</span>
</p>
</div>
<div class="form-field">
<label class="form-label" for="un-price">Unit price</label>
<div class="input-group input-group--sm">
<span class="input-affix">EUR</span>
<input class="form-input" id="un-price" type="text" inputmode="decimal" value="1,240.00"
data-state="warn" aria-describedby="un-price-warn" />
</div>
<p class="form-warning" id="un-price-warn">
<i class="ri-alert-line"></i>
<span>Four times the average of the last 90 days.</span>
</p>
</div>
</div>
Across every control
One attribute, the same result on an input, a select, an .input-group and a
text area — and on a combo box, a colour field and both editors. In a group, the
attribute stays on the control and the group draws the edge. The select is the case
worth knowing: it needs an empty first option for required to bite at all.
Not a complete address — nothing would be delivered.
Pick a team, or nobody is paged.
At least one pallet, or there is nothing to dispatch.
An override is recorded against your name, so it needs a reason.
<div class="sedna-col" style="max-width:520px; gap:16px">
<div class="form-field">
<label class="form-label form-label--required" for="av-mail">Notification address</label>
<input class="form-input" id="av-mail" type="email" required
aria-invalid="true" aria-describedby="av-mail-err" value="ops.example" />
<p class="form-error" id="av-mail-err">
<i class="ri-error-warning-line"></i>
<span>Not a complete address — nothing would be delivered.</span>
</p>
</div>
<div class="form-field">
<label class="form-label form-label--required" for="av-team">Escalation team</label>
<select class="form-input form-select" id="av-team" required
aria-invalid="true" aria-describedby="av-team-err">
<option value="" selected>Choose a team…</option>
<option value="fulfilment">Fulfilment</option>
<option value="network">Network</option>
</select>
<p class="form-error" id="av-team-err">
<i class="ri-error-warning-line"></i>
<span>Pick a team, or nobody is paged.</span>
</p>
</div>
<div class="form-field">
<label class="form-label form-label--required" for="av-qty">Quantity</label>
<div class="input-group">
<input class="form-input" id="av-qty" type="number" value="0" min="1" required
aria-invalid="true" aria-describedby="av-qty-err" />
<span class="input-affix">pallets</span>
</div>
<p class="form-error" id="av-qty-err">
<i class="ri-error-warning-line"></i>
<span>At least one pallet, or there is nothing to dispatch.</span>
</p>
</div>
<div class="form-field">
<label class="form-label form-label--required" for="av-reason">Reason</label>
<textarea class="form-input" id="av-reason" rows="2" required
aria-invalid="true" aria-describedby="av-reason-err"></textarea>
<p class="form-error" id="av-reason-err">
<i class="ri-error-warning-line"></i>
<span>An override is recorded against your name, so it needs a reason.</span>
</p>
</div>
</div>
Marking a field required
.form-label--required draws the marker and required on the control
does the enforcing. Write both: the marker is decoration, and a screen reader is told by the
attribute.
Marked and enforced: the label carries the marker, the control carries required.
No marker, no attribute. The two always agree.
<div class="sedna-col" style="max-width:460px; gap:16px">
<div class="form-field">
<label class="form-label form-label--required" for="rq-host">Hostname</label>
<input class="form-input" id="rq-host" type="text" required value="exch-02.example.com" />
<p class="form-hint">Marked and enforced: the label carries the marker, the control carries <code>required</code>.</p>
</div>
<div class="form-field">
<label class="form-label" for="rq-owner">Owner</label>
<input class="form-input" id="rq-owner" type="text" placeholder="Optional" />
<p class="form-hint">No marker, no attribute. The two always agree.</p>
</div>
</div>
Inside a two-up row
The message belongs to the field, so it sits under that field's control and not under the
row; the .sedna-grid keeps the two columns aligned at the
top. A form that rejected more than one field also says so once, at the top, in a
validation summary linking to each.
<form style="max-width:560px">
<div class="sedna-grid sedna-grid--sm sedna-grid--fit">
<div class="form-field">
<label class="form-label form-label--required" for="vg-host">Host</label>
<input class="form-input" id="vg-host" type="text" value="src-db-14" required />
</div>
<div class="form-field">
<label class="form-label form-label--required" for="vg-port">Port</label>
<input class="form-input" id="vg-port" type="number" value="65900" required
aria-invalid="true" aria-describedby="vg-port-err" />
<p class="form-error" id="vg-port-err">
<i class="ri-error-warning-line"></i>
<span>Above 65535, so no port can answer.</span>
</p>
</div>
<div class="form-field sedna-span-full">
<label class="form-label" for="vg-note">Note</label>
<textarea class="form-input" id="vg-note" rows="2" aria-describedby="vg-note-warn">Temporary — remove after the migration</textarea>
<p class="form-warning" id="vg-note-warn">
<i class="ri-alert-line"></i>
<span>Notes are shown to every operator, including contractors.</span>
</p>
</div>
</div>
<div class="form-actions">
<button class="btn" type="button">Cancel</button>
<button class="btn btn--brand btn--solid" type="button">Save connection</button>
</div>
</form>