Skip to content

Accessibility

Andres Felipe Martinez Salazar edited this page Nov 2, 2026 · 4 revisions

Accessibility

Every element has a place in a semantics tree: a role, an accessible name, a description, states and a value, computed the way browsers do (HTML-AAM and AccName 1.2, checked against Playwright). The tree is what tests query with ByRole, what tela inspect prints, and what an agent reads to understand a screen. An operating-system accessibility backend (AT-SPI, UI Automation, NSAccessibility) is not there yet; the tree is built on demand, so it costs nothing while you do not ask for it.

Implicit roles

Element Role
Button button
H1 to H6 heading, with [level=N]
P paragraph
Img img (named by Alt)
A with Href link
Input, Textarea textbox (named by its Label)
Div, Span none

Elements without a role are left out of the snapshot, and their text is merged into their parent's.

Roles and ARIA props

For components built from divs, give the role yourself with Role (WAI-ARIA 1.2). An unknown role panics in dev mode with the closest valid one. The props follow React's aria-* attributes:

Prop Sets
Role The role: "switch", "dialog", "tab", "progressbar"...
AriaLabel The accessible name, for elements whose content does not name them (an icon button)
AriaLabelledBy Names an element with the text of one or more others (joined by a space), attached with Ref
AriaDescription A longer hint read after the name
AriaHidden Hides the subtree from the semantics tree, not from the screen
AriaChecked Checked state of a checkbox, radio or switch
AriaPressed Makes a button a toggle button and sets whether it is pressed
AriaSelected Selected state of a tab, option, row or cell
AriaExpanded Whether what a button controls (a menu, a disclosure) is expanded
AriaDisabled Disabled for assistive technologies without making it inert (Disabled does both)
AriaRequired A field the user must fill in
AriaReadOnly A widget the user can focus but not edit
AriaBusy An element that is being updated (a list that loads)
AriaInvalid A field whose value failed validation; it wins over an Input's own validity (an email field with a malformed address is already invalid)
AriaSort How a table is sorted by a column header: "ascending", "descending", "other" or "none"
AriaCurrent The current item of a set: "page" (the link of the page being shown), "step", "location", "date", "time" or "true"; "false" and "" mean none
AriaLevel Level of a custom heading, list item, row or tree item
AriaValueNow, AriaValueMin, AriaValueMax, AriaValueText The value of a range widget (slider, progressbar, spinbutton, meter)

These props also drive the aria-* class variants: aria-checked:, aria-expanded:, aria-pressed:, aria-selected:, aria-disabled:, aria-hidden:, aria-busy:, aria-required:, aria-readonly: and aria-invalid: (and their group- and peer- forms) match when the element's prop is true, so the look follows the semantics: Button(AriaExpanded(open), Class("aria-expanded:bg-slate-100")); aria-[sort=ascending]: and aria-[sort=descending]: match the AriaSort of a header (group-aria-[sort=descending]:rotate-180 turns its arrow), and aria-[current=page]: (also step, location, date, time and true) match the AriaCurrent of a link or step (Tailwind has no bare aria-current:). The aria snapshot prints [invalid] (from AriaInvalid(true) only, on the roles that support it) but not required, readonly, busy, the sort or the current item, as Playwright's does not.

func WifiSwitch() tela.Node {
	on, setOn := tela.UseState(true)
	return Button(Role("switch"), AriaChecked(on), AriaLabel("Wi-Fi"),
		Class("h-6 w-11 rounded-full"), IfElse(on, Class("bg-indigo-600"), Class("bg-slate-300")),
		OnClick(func(tela.MouseEvent) { setOn.Set(!on) }),
		Div(Class("size-5 rounded-full bg-white")),
	)
}

There are no checkbox, radio, select or slider elements: kit has the components (kit.Checkbox, kit.RadioGroup, kit.Select, kit.Slider) and aria the hooks behind them. For a control of your own, a Button or Div with a Role, the state props, TabIndex and key handlers is how you build it.

A dialog named by its title

func ConfirmDialog(confirm func()) tela.Node {
	title := tela.UseElementRef()
	return Div(Role("dialog"), AriaLabelledBy(title), Class("rounded-xl bg-white p-6 shadow-xl"),
		H2(Ref(title), Class("text-lg font-semibold"), Text("Delete file?")),
		P(Class("text-slate-500"), Text("This cannot be undone.")),
		Button(AutoFocus(true), OnClick(func(tela.MouseEvent) { confirm() }), Text("Delete")),
	)
}

Progress, with a value

func Progress(percent float64) tela.Node {
	return Div(Role("progressbar"), AriaLabel("Upload"),
		AriaValueNow(percent), AriaValueMin(0), AriaValueMax(100),
		Class("h-2 w-64 rounded-full bg-slate-200"),
		Div(Class("h-2 rounded-full bg-indigo-500"), Style(tela.Width(float32(percent)*2.56))),
	)
}

Labels

Label names a text field and focuses it when clicked (see Forms and Text Input). For anything else, use AriaLabel or AriaLabelledBy. Mark decorative images with Alt(""): the semantics tree leaves them out; Alt panics on elements that are not an Img.

Looking at the tree

tela inspect prints the tree as a Playwright aria snapshot, one line per role, with the accessible name and the states:

- heading "Settings" [level=1]
- switch "Email notifications" [checked]
- button "Save changes" [disabled]

From a test, App.AriaSnapshot returns the same text, App.ExpectAria compares it and App.AriaGolden stores long ones in a file. The headless telatest.AriaSnapshot works without a test. See Testing and CLI.

Queries use the same tree: telatest.ByRole("switch", "Wi-Fi") finds the switch above by what an assistive technology would announce, which is why they survive restyling.

Keyboard access

A Button is focusable and activates with Enter and Space. Any other element becomes focusable with TabIndex, and you handle the keys with OnKeyDown; see Events. The focus-visible: variant draws the focus ring only for keyboard focus.

Clone this wiki locally