Osy#the first language built for agents
Agents firstAgentic appsWorkflowsDurable Execution — built inSecurityTestingThe editorThe UI modelOne program

Reference / UI

accessibility

Pressable(role: UiRole.Checkbox, checked: on, label: "Email me") — say what a control IS and what STATE it is in

Tags already give an element its role, focus and keyboard behaviour. The semantic props say the rest: `role:` for a widget a tag cannot name, `checked:`/`selected:`/`expanded:`/`sort:` for the state it is in, `label:` for what it is called, and `decorative:` for what should not be announced at all.

stable6 examples compiled by CIuiaccessibilitycontrolsauthoring

Summary#

A control has to be usable by someone who cannot see it. Most of that you get for free; the rest is one small vocabulary of props.

Pressable(onClick: Toggle, role: UiRole.Checkbox, checked: on, label: "Email me") {
  Row(align: Align.Center, gap: 2) { Box(w: "18px", h: "18px"); Text("Email me"); }
}

Rendered, that is a real <button role="checkbox" aria-checked="true" aria-label="Email me">. Without the three props it is a button that draws a filled square — which says everything to someone looking at it and nothing to anyone else.

Signature#

<element>(role: <UiRole>, label: <string>,
          checked: <bool>, selected: <bool>, expanded: <bool>, current: <bool>, invalid: <bool>,
          sort: <UiSort>, decorative: <bool>)

// …and the ASSOCIATION props, whose value is a compile-scoped HANDLE, not a value:
<element>(id: <handle>, labelFor: <handle>, labelledBy: <handle>, describedBy: <handle>)

Every one is ambient: it works on any element, because any element can be a control. A Box can be a dialog and a Row can be a tab.

Description#

What you already have, free#

Start here, because it is more than it sounds. The renderer picks real HTML tags, and the tag carries the semantics:

you writeyou getwhich means
Pressable / Button<button>announced as a button, focusable, activates on Enter and Space
Link(href:)<a>announced as a link, in the page's link list
Input(value:)<input>announced as an entry field, works with every assistive technology
disabled: truethe native disabled attributeannounced as unavailable, and it stops its own events
keys: [Left, Right]tabindex + key ownershipa custom surface reachable by keyboard at all — see keys

So a page built from these is already keyboard-operable and already announces its structure. You do not write ARIA for any of that, and adding it would only be a second, staler copy of what the tag already says.

What a tag cannot say#

Three things, and they are the three that go missing:

STATE. A tag cannot say a checkbox is ticked, a tab is the current one, a dropdown is open, or a column is the one being sorted. Apps draw those — a fill, an underline, a chevron, an arrow — and drawing is not saying.

NAME. A button whose face is an icon has no text to be announced by. The icon itself is hidden from assistive technology (correctly — it is a picture of the meaning, not the meaning), so the control is announced as "button", with nothing to distinguish it from every other button on the page.

ASSOCIATION. A label rendered above an input is, to anyone reading visually, obviously that input's label. To anyone else it is a piece of text that happens to be nearby — and clicking the words does nothing, where in an ordinary form it focuses the field. Same for the red message under a rejected value: it says why, to whoever can see that it belongs to that field.

Which prop says what — role, state, label#

role: — what this element IS

Takes a member of the closed UiRole vocabulary. It is not a string, so a typo is a compile error naming the members that exist rather than an attribute every browser silently ignores.

Write it qualified — role: UiRole.Tab, never a bare Tab. In an argument slot a bare capitalised name could be a theme token, an enum member or a style keyword, so the group name is what says which vocabulary you meant. The member tables below name the members; the value you write is always UiRole. + the member. The same holds for sort:, whose vocabulary is UiSort.

Row(role: UiRole.TabList) { … }
Pressable(role: UiRole.Tab, selected: isCurrent, onClick: Show) { … }
Box(role: UiRole.Dialog, label: title) { … }

The members, grouped by what they are for:

groupmembers
toggles and choiceCheckbox Switch Radio RadioGroup Tab TabList TabPanel Option ListBox ComboBox Menu MenuItem
overlays and feedbackDialog Alert Status Progress Tooltip
groupingToolbar Group Separator List ListItem
data gridGrid GridRow ColumnHeader GridCell
landmarksNavigation Search Banner Main ContentInfo Region

Alert interrupts a reader immediately; Status waits until they are next idle. That is the whole difference between "your session has expired" and "saved", and it is worth choosing deliberately.

Landmarks are the cheapest large win on any page shell. They are the regions a reader jumps between, which is how most people using a screen reader navigate a page they have seen before. A shell that declares them is skippable; one that does not has to be read from the top every time.

Saying it is open, checked or selected

propsaysgoes with
checked:is it onrole: UiRole.Checkbox · UiRole.Switch · UiRole.Radio
selected:is it the chosen onerole: UiRole.Tab · UiRole.Option
expanded:is it openrole: UiRole.ComboBox · UiRole.Menu
current:is this the page you are ona nav Link
invalid:is this value rejectedan Input
value:how far along it is (a number)role: UiRole.Progress — the one thing about a bar nobody can see
sort:which way is it sorted (UiSort.Ascending · UiSort.Descending · UiSort.None)role: UiRole.ColumnHeader

A role: without its state is worse than neither. role: UiRole.Checkbox promises a state the element then never reports, which is invalid and leaves the control unannounced. Ship the pair.

checked:, selected: and expanded: are emitted in BOTH directionsaria-checked="false" is the required way to say "an unticked checkbox". Omitting it says something else entirely: that the element has no checked state at all. This is the opposite of how disabled works, where absence is false. You do not have to remember which is which — write the bool and the platform emits the right thing.

current:, invalid: and decorative: are absent when false, because for those absence is already what the specification means.

label: — what it is CALLED

A string, and the accessible name. Reach for it whenever the control's face is a glyph, an icon, or content that came from its caller.

IconButton(onPress: Remove, label: "Remove line") { Icon(Icons.Trash); }

When an element also renders text, label: overrides it — which is what you want for a checkbox whose contents are a tick and a word, and not what you want when the visible text is already the right name.

A visible label and an accessible name that disagree break voice control, where someone says the words they can see. If both exist, make them the same string.

decorative: — do not announce this at all

For an element that carries no information: a marker that repeats a state already reported, or a full-viewport click-catcher behind a popover.

if (sorted) { Text(desc ? "▼" : "▲", decorative: true); }

This is the right answer for a backdrop, not a dodge. A dismiss-on-click backdrop is a real <button> the size of the screen; announced, it is an unnamed control in everyone's way, and naming it would be worse, because the accessible way to dismiss an overlay is Escape.

Writing a control other people will use#

This is the part that is easy to get half-right, because the two duties fail differently.

CARRY what the control knows. A Checkbox knows whether it is ticked; a Tab knows whether it is selected. The caller already passed that in, so making them repeat it as an accessibility argument is exactly the drift the control exists to remove.

EXPOSE what only the CALLER knows — which is almost always the name. IconButton(icon: save) cannot know whether it means "Save" or "Save draft". A theme switcher cannot know whether your app says "Theme", "Appearance" or "Dark mode".

A control that hard-codes its accessibility denies its caller the ability to be correct. A control is a black box: if it takes no label, no app using it can supply one, however much it wants to. That is worse than the gap itself, because the app cannot route around it.

// ✗ the caller cannot fix this from outside
[Composable] component IconButton(Action onPress) {
  render { Pressable(onClick: onPress) { Slot; } }
}

// ✓ carries what it knows, takes what it cannot know
[Composable] component IconButton(Action onPress, string label = "") {
  render { Pressable(onClick: onPress, label: label) { Slot; } }
}

The association props — one element POINTING AT another

label: gives a control a NAME. These give it a RELATIONSHIP: which words label it, and which message describes it.

Text("Email", labelFor: box);
Input(value: email, id: box, describedBy: note, invalid: error != "");
if (error != "") { Text(error, id: note); }
propsays
id:declares a handle — "this element is the one called box"
labelFor:these words are the label for that element — a real <label for>
labelledBy:this element is named by that one, when the namer cannot be a <label>
describedBy:this element is described by that one — a hint, or a validation message

The value is a HANDLE, not a string, and never an id. You write a bare name; the platform mints the actual id. The handle is scoped to the component, so two components may both use box, and one component rendered twenty times gets twenty distinct ids. A handle declared twice, or a reference naming one that does not exist, is a compile error.

That is the whole reason it is not a string. A mistyped for="emailFeild" is silent: it renders, it validates, and clicking the words focuses nothing at all. And an id you write yourself has to be unique across a page you cannot see all of at once — which is a promise no component can keep about itself.

labelFor: and labelledBy: are not two spellings of one thing. aria-labelledby gives the accessible NAME. <label for> gives the name and the click: pressing the words focuses the input, which on a form of small controls is most of the hit area, and which people use without thinking about it. Reach for labelFor: whenever the target is a real form control; labelledBy: is for the rest — a Box acting as a dialog, named by its heading.

An element carrying labelFor: is a <label>, whatever it would otherwise render as. for on a <span> would render, validate, and focus nothing.

Inside a foreach, or a per-item block, each row gets its OWN pair. A handle is scoped to one iteration, so writing the pair straight into the loop is right — and it is the best answer there, because the name is already on screen and does not have to be printed twice:

foreach (var m in members) {
  Row { Text(m.Name, labelFor: score); Input(value: m.Score, id: score); }
}

The two ends must be in the same row, though, and that is checked. A pair split across the loop boundary — labelFor: outside it and id: inside, or the reverse — names one row's element from a place where there is no one row, so the compiler refuses it and says which end to move.

A wrapper names the control its caller hands it — Slot(id: field). This is what a form component is, and until it existed a Field could not name its own field: the caption lives in the wrapper's tree and the input arrives from the caller's, and a handle does not cross that boundary on its own.

[Composable] component Field(string label) {
  render { Stack(gap: 1) { Text(label, labelFor: field); Slot(id: field); } }
}
Field("Name") { Input(value: draft.Name); }     // ← the caller writes nothing

The caption is written once and the pair is a real <label for>. label: "Name" on the Input is the fallback where there is no caption to point at; here it would write the same words twice and give up the click. See Slot (child content).

A reference whose target did not render points at nothing, and contributes nothing — which is right when there is nothing to say. A describedBy: naming a message inside an if that did not run simply adds no description. What cannot happen is a reference to a handle that does not exist at all: that is the compile error above.

osy lint finds the ones you missed#

Two rules, over every component you write:

  • ui-control-state-not-announced — this component has a checked/selected/open/sorted parameter or field and sets no semantic prop anywhere, so it draws a state it never says.
  • ui-control-without-an-accessible-name — this control's face is a glyph or a caller-filled slot and it takes no name.
osy lint

Both are CONSIDER rather than errors: a bool open might genuinely drive nothing a reader needs. In practice they are nearly always real. Run over the platform's own kit when the rules were written, they returned ten controls — every one of which is fixed, and the kit is now held to the same rule it ships you.

Examples#

A tab strip that says what it is. Tabs is the container, Tab carries its own selected state:

[Composable] component Tabs() {
  render { Row(role: UiRole.TabList, align: Align.End, gap: 1) { Slot; } }
}

[Composable] component Tab(string label, bool selected = false, Action onPress = null) {
  render { Pressable(label, role: UiRole.Tab, selected: selected, onClick: onPress); }
}

An icon-only button that its caller can name — the parameter is the whole point:

[Composable] component IconAction(Action onPress, string label = "") {
  render { Pressable(onClick: onPress, label: label) { Slot; } }
}

A page shell with landmarks, so a reader can jump straight past the chrome instead of reading it every time:

[Page("/shell")]
[AllowAnonymous]
component Shell() {
  render {
    Stack {
      Row(role: UiRole.Banner) { Text("Acme"); }
      Row {
        Stack(role: UiRole.Navigation, label: "Sections") { Text("Orders"); }
        Stack(role: UiRole.Main) { Text("…"); }
      }
      Row(role: UiRole.ContentInfo) { Text("(c) Acme"); }
    }
  }
}

A sortable column header. The arrow is decoration, because sort: has already said which way it goes:

[Composable] component SortHeader(string label, bool sorted = false, bool desc = false, Action onSort = null) {
  render {
    Row(role: UiRole.ColumnHeader, sort: sorted ? (desc ? UiSort.Descending : UiSort.Ascending) : UiSort.None,
        align: Align.Center, gap: 1) {
      Pressable(onClick: onSort) { Text(label); }
      if (sorted) { Text(desc ? "v" : "^", decorative: true); }
    }
  }
}

A checkbox that reports its state — the shape the platform's own Checkbox uses:

[Composable] component Tickbox(bool checked, string label, Action onToggle = null) {
  render {
    Pressable(onClick: onToggle, role: UiRole.Checkbox, checked: checked, label: label) {
      Row(align: Align.Center, gap: 2) {
        Box(w: "18px", h: "18px", borderW: 1);
        Text(label);
      }
    }
  }
}

A field whose label points at its input and whose message describes it — the shape every form is made of:

[Composable] component LabelledField(string label, Binding<string> value, string error = "") {
  render {
    Stack(gap: 1) {
      Text(label, labelFor: box);
      Input(value: value, id: box, describedBy: note, invalid: error != "");
      if (error != "") { Text(error, id: note); }
    }
  }
}

Clicking the words focuses the input, and a screen reader reads the field and then the reason it was rejected, as one thing. Neither is expressible with label: alone.

See also#

Related

Osyrin.Ui (the UI kit)

The bundled UI kit — ready-made styled controls like `Button`, the shared design-system vocabularies (`Tone`, `Size`)…

component

The one archetype for all UI: a bounded reactive unit — typed props, reactive members (fields, `live`…

keys

`keys:` declares that an element owns a set of keys: it becomes focusable, those keys stop scrolling the page, and…

Validation

You declare a field's rules once, on the entity — `[Required]`, `[Pattern]`, `[MaxLength]` — and give each rule the…

Ui — drive the app's UI from a test

Drive the real UI from a test: navigate to a route, click what a person would click, and assert on what the screen…