Accessibility: ARIA, roles & focus
How to make Rask components accessible — setting ARIA attributes, roles, and keyboard focus on any element, plus the analyzer that catches missing image alt text.
- Typed ARIA:
AriaLabel,AriaExpanded,AriaLive… - The
Ariadictionary RoleandTabIndex- Language of parts (
LangandDir) - Attribute order
- Images and alt text (RASK023)
- What's not covered yet
Typed ARIA: AriaLabel, AriaExpanded, AriaLive…
Every aria-* state and property the WAI-ARIA spec defines is a
typed step on every element, generated at build time from the spec itself (pinned in
src/Rask.Core/Dom/mdn.snapshot.json, refreshed with the MDN data). The name is the DOM's own IDL name —
ariaLabelledByElements is written from markup as ids, so it is AriaLabelledBy — and the type is the
spec's value type:
| Spec value type | C# | Example |
|---|---|---|
| true/false, true/false/undefined | bool? |
.AriaExpanded(open), .AriaHidden() (no argument = true) |
| tristate, token | a generated enum | .AriaChecked(AriaChecked.Mixed), .AriaLive(AriaLive.Polite), .AriaCurrent(AriaCurrent.Page) |
| token list | a generated [Flags] enum |
.AriaRelevant(AriaRelevant.Additions \| AriaRelevant.Text) |
| integer, number | int?, double? (invariant) |
.AriaLevel(2), .AriaValueNow(0.5) |
| ID reference(s), string | string? |
.AriaLabelledBy("title"), .AriaDescribedBy("hint err"), .AriaLabel("Close") |
Button.AriaExpanded(_open).AriaControls("menu").AriaHasPopup(AriaHasPopup.Menu)["Options"]
// <button aria-controls="menu" aria-expanded="false" aria-haspopup="menu">Options</button>
Div.Role(AriaRole.Status).AriaLive(AriaLive.Polite)[_statusMessage]
// <div role="status" aria-live="polite">…</div>
null leaves an attribute out, so .AriaExpanded(null) is "undefined" in the spec's sense. AriaRole holds
every concrete role as a constant (AriaRole.Tablist, AriaRole.Menuitemcheckbox — each name is the role's
token, PascalCased); Role stays a string, so a role the constants do not list yet still works.
The keyword enums have no per-keyword steps (Div.Polite would not say what is polite) — pass the value. A
boolean or keyword is stored and written as the interned literal it renders as, so the typed steps allocate
nothing per render; an element that names none of them pays nothing at all.
The Aria dictionary
The dictionary stays for what the typed steps cannot say — an attribute newer than the snapshot, a value
built at run time from a key. Every element exposes an Aria parameter — a string → string? dictionary modelled exactly on the
Data (data-*) bag. Each entry renders as aria-{key}="{value}": the key is used
verbatim (so you write "label", not "aria-label") and the value is HTML-encoded. A null value
emits a bare attribute.
Button.Aria(new() { ["label"] = "Close", ["expanded"] = "false" })["✕"]
// <button aria-label="Close" aria-expanded="false">✕</button>
Span.Class("icon").Aria(new() { ["hidden"] = "true" })["\U0001F5D1"]
// <span class="icon" aria-hidden="true">🗑</span> — decorative icon, skipped by screen readers.
// The glyph carries no meaning a reader needs; the BUTTON around it carries the accessible name.
A typed step and a bag entry for the same attribute never both render: the typed value wins and the bag's
entry is skipped, so a component can take a call site's bag and still set .AriaExpanded(open) itself.
Role and TabIndex
role and tabindex are not aria-* attributes, so they have their own typed parameters on every
element:
Div.Role(AriaRole.Dialog).TabIndex(-1).AriaModal(true).AriaLabelledBy("title")[
H2.Id("title")["Edit product"],
// ...
]
Role is a string?; TabIndex is an int? (0 to make a non-interactive element focusable, -1
to take it out of the tab order but keep it programmatically focusable).
Language of parts (Lang and Dir)
Lang marks the language of an element's content as a BCP 47 tag, and it belongs on any run of text in
a different language from the page — not only on <html>. A screen reader switches pronunciation on it,
and without it a French quotation inside an English page is read with English phonetics. That is
WCAG 3.1.2 Language of Parts, a
Level AA criterion:
P["The exhibition is called ", Span.Lang("fr")["Les Demoiselles"], "."]
Dir is the same idea for direction — "ltr", "rtl" or "auto". Reach for "auto" on text you did
not author and whose language you do not know at render time (a display name, a comment, a search
query): the browser takes the direction from the first strongly-typed character, which is the only
correct answer when the content is arbitrary.
Hidden and Inert belong to the same family. Hidden removes an element from every presentation
including the accessibility tree — prefer it to a display-none class, which hides an element visually
while leaving a screen reader announcing it. Inert makes a whole subtree unfocusable and unreachable,
which is the correct primitive behind a modal (see Focus trapping).
Attribute order
Accessibility attributes render in the universal attribute block, after data-* and before any
tag-specific attributes. The full documented order is:
id, class, style, title,
lang, dir, hidden, inert, popover, contenteditable, spellcheck, translate,
data-*, role, tabindex, aria-* (typed, then the Aria bag), Attributes, then tag-specific
The typed aria-* attributes render in the spec's (alphabetical) order, then the bag's entries in its own
order, less any a typed step already wrote. Tests assert this order; it is stable across releases.
Images and alt text (RASK023)
Img requires an Alt for accessibility. The RASK023 analyzer warns when
an Img chain omits it:
Img.Src("/logo.png").Alt("Rask logo") // informative image
Img.Src("/divider.png").Alt("") // decorative: empty alt hides it from assistive tech
Pass Alt: "" for purely decorative images so screen readers skip them; pass a meaningful string
for everything else.
Form validation
A Rask UI field wires its label, description, error and validation state to assistive tech on its own — you
add nothing. Ui.Input and Ui.Textarea render:
aria-invalid="true"when the field is invalid — a bound field whose form holds a message for it, or a controlled field givenInvalid()— so the failed state is exposed programmatically, not only as a red border;aria-describedbynaming what is visible around the control, error first: the field'sUi.Error(only while it holds a message — a hidden message named byaria-describedbywould still be read aloud), then theDescription. Omitted when there is nothing; and- a real
<label for>fromLabel, which is the control's accessible name.
Ui.Input.Bind(() => model.Email).Label("Email").Description("We never share it.")
// valid → <input id="f-email" aria-describedby="f-email-description" …>
// invalid → <input id="f-email" aria-invalid="true" data-invalid
// aria-describedby="f-email-error f-email-description" …>
// <div id="f-email-error" role="alert" aria-live="polite">Enter a valid email</div>
The hint, error and message ids derive from the field id — Id if you set one, otherwise the bound
member's name or the label text. That id also anchors the <label for> association, so if you render
the same bound field more than once on a page (a repeated form, a list of rows), give each field an
explicit unique Id so every for and aria-describedby resolves to the right element.
Building your own control from the core Input/Validation.Message primitives? Mirror the same
attributes: .AriaInvalid(AriaInvalid.True).AriaDescribedBy(errorId) on the control, and give the message
element that id. See forms-validation.md.
Focus trapping (overlays)
Any element that carries data-rask-focus-trap gets accessible-overlay focus management from the
runtime — no component library's JavaScript, no per-component wiring. While the element is in the DOM, focus moves into
it on open (its [autofocus] element, else the element itself), Tab/Shift+Tab cycle within it
(focus can't reach the inert page behind), and focus returns to the previously-focused element when it
closes. If the trap (or a descendant) carries data-rask-dismiss, Escape closes it by triggering that
element's click handler — no per-keystroke server round-trip. The trap follows the attribute as well as the
element: adding or removing data-rask-focus-trap on an element that stays mounted engages or releases it.
Rask UI's Ui.Modal needs none of this: it is a modal <dialog> — opened by a Ui.ModalTrigger's
command="show-modal", or by the runtime when the page's state says so (Ui.Modal.Open(…),
data-rask-modal-open) — so the browser's own modal dialog makes the page inert, closes on Escape and returns
focus to what opened it. While one is open the runtime also stops the page behind it from scrolling
(data-rask-lock="scroll").
As Flux UI's does, a Ui.Modal opens with focus on nothing: an empty autofocus placeholder inside the dialog
takes the focus the browser would hand the first field, then leaves, so no control is ringed before the reader
chose one and the first Tab lands on the first control. A modal has no title of its own — its content
carries the heading, exactly as Flux's does. Its close button is named "Close modal".
A dialog should opt in deliberately: an open modal traps focus, is labelled (aria-labelledby
its title, or aria-label from the title text), and dismisses on Escape (except with a static backdrop,
which keeps Escape inert). Build your own overlay the same way — add data-rask-focus-trap
(via the Data dictionary) and mark your close control with data-rask-dismiss.
A menu that must escape an overflow: hidden/auto ancestor uses the platform's own answer instead:
a [popover], which the browser lifts into the top layer, dismisses on Escape and on a click outside,
and gives a real ::backdrop. Ui.Select's .Listbox and .Combobox variants, Ui.Menu and Ui.Modal are all
built that way. C# hears the browser's own dismissal through OnToggle, which is what lets a control
keep aria-expanded truthful rather than drifting the moment Escape is pressed.
A second runtime helper contains the navigation keys while such a list is open — the live client never
calls preventDefault, so without it ArrowDown would scroll the document behind the list and Enter
would submit the surrounding form. It keys off aria-expanded on the closest [role=combobox], and
deliberately leaves Escape alone, since Escape's default is the dismissal.
A tree — the DevTools panel's component tree — has the same shape and its own rule: one focusable [role=tree] with the cursor named by
aria-activedescendant, the arrows, Home/End, Page keys and Space contained while it has focus (Enter
left alone — a focused non-form element has no default for it). Because the cursor is an attribute
rather than the focus, the runtime also scrolls the named row back into view when it moves out of sight,
and in a virtualized tree — where that row is not rendered at all — it scrolls to where the row will be,
which is what loads it.
Menus
An element with role="menu" is a list with a cursor inside it, and the cursor is where FOCUS is: the row the
cursor is on carries data-active and tabindex="0", and the runtime moves focus to it whenever a render moves
the attribute — roving focus, as Flux UI's menus have it, so a screen reader follows the arrow keys row by row.
The runtime treats a menu like one: the arrows, Home/End, Page keys and Space are contained so they move the
cursor rather than scrolling the page, and Enter or Space press the row that has focus — its own click handler,
link or checkbox runs exactly as a pointer would run it. ArrowDown on the closed button that opens a menu (an invoker with aria-haspopup whose popover is, or
holds, a role="menu") opens it onto its first row; a pick closes the popover the menu sits in unless the row, or something around it,
carries data-rask-keep-open; Tab out of an open menu closes it; a click outside closes it and focus is handed
back to the trigger. Escape is the browser's, and hands focus back too. Rask UI's Ui.Menu — what a
Ui.Dropdown opens — builds on this, with menuitem, menuitemcheckbox and menuitemradio rows and Flux's
keyboard: the arrows stop at the ends and step over disabled rows, a letter jumps to the row starting with it,
ArrowRight or Enter opens a submenu and ArrowLeft closes it. A Ui.Navmenu is not a menu: it is a <nav> of
links, reached with Tab. Ui.Context is the same menu opened by a right-click: the runtime shows its popover at
the pointer (data-rask-contextmenu), the ContextMenu key and Shift+F10 open it at the focused element, the
menu takes focus as it opens, and focus goes back where it was when it closes —
so give its area something focusable, or a keyboard user has no way in. Ui.Command, the command palette,
keeps focus in its search box instead: the commands are the options of a listbox, the lit one is the box's
aria-activedescendant, and Enter presses it — as Flux writes it, the box has no combobox role.
A [popover] carrying data-rask-popover-open="true"|"false" is shown or hidden to match whenever the attribute
changes — how a controlled menu opens from C#, which cannot call showPopover(). Escape and a press outside
are the browser's and win until the server has answered them: a reply rendered before it heard of one does not
open the popover again, and the toggle the runtime's own show or hide raises is not reported as the reader's.
Tooltips
A tooltip that is only drawn is a tooltip a screen reader never says. Rask UI's Ui.Tooltip joins its trigger
to its content at render: a trigger with text of its own carries aria-describedby, so the tooltip is read
after its name, and a trigger without — an icon button — carries aria-labelledby, so the tooltip IS its name.
The content is role="tooltip" and aria-hidden, read through that reference and not a second time in the
reading order. It shows on keyboard focus (:focus-visible) as well as on hover, stays while that focus lasts
even if the pointer passes over and leaves, and Escape dismisses it — for any trigger, since the runtime shows
it (data-rask-tooltip). A Ui.Button with a Tooltip is wired the same way: an icon-only one is named by
aria-labelledby, not by an aria-label copied from the tooltip.
The trigger has to be one element for this — an HTML element or a kit component that is one; around a
composite nothing is wired, as Flux wires nothing but the trigger. And a tooltip is a hint: a touch
screen has no hover, so what matters there is Toggleable(), which a tap opens. See
ui-kit.md for the little that is left undone.
Tabs
An element with role="tablist" is a ring of <button role="tab">s of which one — the selected one — is a
tab stop (tabindex="0", the rest -1), and the runtime gives it the arrow keys (a
behaviour hook, loaded by a page that carries a tablist): ArrowRight and
ArrowDown move to the next tab, ArrowLeft and ArrowUp to the previous, past any tab that is
disabled and around the ends. The tab arrived at is focused and pressed, so its own click handler runs
exactly as a pointer would run it: selection follows focus, with no Enter or Space. The key's default is
prevented, so an arrow does not scroll the page behind the row. From a button in the row that is not a tab —
an "Add tab" action — the arrows start from the selected tab.
Tab leaves the row for whatever comes next, which in a Ui.TabGroup is the shown role="tabpanel"
(tabindex="0", named by its tab through aria-labelledby; the tab names it back with aria-controls).
Home and End are not handled and stay the page's — the behaviour of the Flux tabs Rask UI mirrors. Only
buttons take part: a link you give role="tab" goes somewhere, and an arrow key must not navigate.
Rask UI's Ui.Tabs builds on this; so does any tablist you write by hand. Ui.Tabs also leaves the
selecting to the runtime (data-rask-tabs): aria-selected, the tab stop and the shown panel move in the
press itself, before any answer from the server, so a screen reader hears the new tab at once. See
ui-kit.md.
Controls that are waiting
A <button> (or <input type=button|submit>) whose own handler is still running after 200 ms is marked
by the runtime with aria-busy="true" and data-loading, on both hosts, and a second activation is dropped
until the first handler's render has landed. It is deliberately not disabled: disabling the control
the reader just pressed moves keyboard focus to the document body, so the next Tab starts from the top of
the page. The mark comes off when the dispatch finishes, when the connection drops, or after a 30 s
backstop. Opt a control — or a container of them — out with data-rask-loading="off"
(Ui.Button.Loading(false)), and opt a non-button element in with data-rask-loading. See
ui-kit.md.
The mark lasts until the page the handler answered with is whole: when a slow read outlives the handler, the
control stays busy until its rows have arrived. A container can ask to be marked too
(data-rask-loading="within", which Ui.Pagination writes): it gets data-loading only — a styling
hook, which is what a table dims its rows by. It is not aria-busy, because it is not the control, and
nothing inside it becomes unpressable or leaves the tab order: the rows stay readable and clickable, and
only the control that was pressed says it is busy.
A field whose value was sent — .Live(), .Debounce(…), a server OnInput search, a change — is marked too,
after the same 200 ms and until the answer to that event: data-loading only, as Livewire puts it on a
wire:model.live input. It is not aria-busy (a field being typed into is not unavailable) and nothing around it
is marked. A field that waits for the next action sends nothing and is never marked; data-rask-loading="off"
opts a field out as it does a button.
Toasts
Ui.Toast is Flux's toast, announced as Flux announces it: the host is role="status" and each toast is
aria-atomic="true", so a toast is read politely and whole — heading and text together — whatever its
variant. A variant is its icon's shape as well as its colour. A timed toast waits while the pointer is over
it — or, in a stack, over any toast of the stack — and then runs out what it had left; focus inside it holds
nothing, as on Flux, so one that must not be missed is raised with .UntilDismissed(). A toast on its own closes on Escape; its
close button and any action or link are ordinary controls in the tab order. See
ui-kit.md and Toast messages.
Navigation
A NavLink to the page being shown writes aria-current="page" beside its active class — what a screen reader
announces as "current page", where a class says nothing. An empty ActiveClass opts out of both, and an
aria-current the call site sets wins. Rask UI's Ui.NavbarItem and Ui.NavlistItem are built on it.
Client-side (SPA) route changes on the Server live runtime are handled accessibly without any wiring:
- Progress. A slow server-side route render surfaces the top progress bar (the same one a slow handler round-trip uses), after a ~300 ms grace so a fast navigation never flashes it.
- Focus. A forward navigation moves focus into the new page's
<main>(or its first<h1>), so a keyboard user continues from the new page instead of the now-removed nav link at the top of the document. Give your layout a<main>(Main[...]) to anchor this. A page that asks for focus itself gets it instead — seeautofocusbelow. - Announcement. The new page's
<title>is announced through a politearia-liveregion, so a screen-reader user hears the route changed — wherever focus went.
Back/Forward (popstate) navigation leaves scroll to the browser's native restoration, and moves focus only
to an autofocus field that arrives with the page. (The progress bar, the move to <main> and the
announcement are the Server host's today; the WASM navigation path is a follow-up. autofocus is acted on
by both hosts.)
autofocus on a page reached in place
A browser acts on autofocus when it loads a document. A page reached in place — a NavLink, Go()
from a handler, a redirect — is a patch of the document that is already open, where the attribute would be
only an attribute. So the runtime acts on it, on the Server host and in WebAssembly:
Ui.Input.Bind(() => Model.Name).Label("Name").Autofocus(true) // focused by a load AND by a link to the page
- After a navigation, the first
autofocuselement in document order that is on screen and enabled has focus, as it has after a load — in place of the move to<main>. The route is still announced. A link to a#fragmentfocuses the fragment's element, as a browser loading that address does. - On the page the reader is on, an element that arrives with
autofocus— a row a handler added, a section a condition revealed, a field in a dialog that is already open — is focused once, as it arrives. An element that merely keeps the attribute from one render to the next is never focused again. - A reader who is typing is not interrupted. While focus is in a text field, a
<textarea>, a<select>or editable content, an arrival does not take it (and is not owed it later). A navigation does. - What is not on screen is the browser's. A closed
<dialog>or[popover]gives focus to its ownautofocuselement when it opens, natively, so the runtime leaves an element that arrives hidden alone. (Ui.Modaltherefore opens as described above: with focus on nothing.) - Back and Forward focus an
autofocusfield that arrives with the page — a browser going back to a document does — without scrolling to it, so the restored scroll position stands.
What's not covered yet
This is the framework primitive layer. Higher-level affordances — skip links and automated axe-core scans in
the sample E2E suite — are tracked as follow-up work. Today you build those from the typed Aria*/Role/TabIndex
primitives above (plus the focus trap) and standard semantic HTML (Nav, Main, Aside, Label.For(…),
Th.Scope(…), …).