Skip to content

Aria: behavior hooks

Package aria holds the unstyled behavior of interface components, in the model of React Aria and Floating UI: hooks that implement interaction, positioning and accessibility once, and return props to put on your own elements. The look is yours (or the kit components’).

import "github.com/kuronosu/tela/aria"
You want Use
One “press” for mouse, touch, pen and keyboard aria.UsePress
A button, on any element aria.UseButton
A button that is on or off aria.UseToggleButton
A checkbox or a switch aria.UseCheckbox, aria.UseSwitch
One choice among radios aria.UseRadioGroup
A segmented control, or a toolbar of toggle buttons aria.UseToggleGroup
A slider, or a range slider aria.UseSlider
A progress bar or a meter aria.UseProgressBar, aria.UseMeter
A calendar that selects a date, or a range of dates aria.UseCalendar, aria.UseRangeCalendar
A color picker: the color, an area, sliders and a hex field aria.UseColorState, aria.UseColorArea, aria.UseColorSlider, aria.UseColorField
To know that a mouse or a pen is over an element aria.UseHover
A focus ring that shows after the keyboard, not after a click aria.UseFocusRing
A dialog that keeps the focus, or a toolbar with arrow keys aria.UseFocusScope
A popover, tooltip, menu or select list next to its button aria.UseFloating
An overlay that Escape and a press outside dismiss, modal or not aria.UseOverlay
A dialog or an alert dialog (role, name, focus) aria.UseDialog
A popover positioned next to its trigger and dismissed aria.UsePopover
The open state of an overlay and its trigger’s props aria.UseOverlayTrigger
A menu button (press, long press, arrow keys) aria.UseMenuTrigger
A tooltip with delays aria.UseTooltipTrigger
A list of options to select from, with the keyboard aria.UseListBox
The menu that a menu button opens aria.UseMenu
A menu bar (File, Edit, View) aria.UseMenuBar
A context menu: right click, long press, Shift+F10 aria.UseContextMenuTrigger
Tabs and their panels aria.UseTabs
A select: a button that shows the chosen item and opens a list aria.UseSelect
A text field that filters a list of suggestions aria.UseCombobox
What is selected in those aria.Selection

The select and the combobox are built on the list box and the popover.

A hook takes an options struct and returns a struct. Its Props field is one tela.Prop with everything the hook needs on the element (handlers, ref, role, aria states); the other fields are state to style from:

press := aria.UsePress(aria.PressOptions{
OnPress: func(e aria.PressEvent) { save() },
Disabled: busy,
})
return Button(press.Props, Class("rounded-md px-3 py-1.5"),
If(press.Pressed, Class("scale-95")), Text("Save"))

Props merges several props: the handlers of one event all run, in the order given, so hook props combine with your own and with each other:

ref := tela.UseElementRef()
btn := aria.UseButton(aria.ButtonOptions{Ref: ref, OnPress: func(aria.PressEvent) { save() }})
ring := aria.UseFocusRing(aria.FocusRingOptions{Ref: ref})
hover := aria.UseHover(aria.HoverOptions{})
return Button(btn.Props, ring.Props, hover.Props,
OnPointerDown(func() { track("down") }),
Class("rounded-md px-3 py-1.5 transition"),
If(hover.Hovered, Class("bg-accent")),
If(ring.FocusVisible, Class("ring-2 ring-primary")), Text("Save"))

One ref per element. The hooks that need the element itself (press, focus ring, focus scope, and the controls built on press) attach a ref to it, and an element has one. When several hooks serve one element, make the ref once with tela.UseElementRef and give it to each as Ref, as above. Two different refs on one element are a mistake, and dev mode panics naming both call sites and the fix (outside dev mode the last one wins, and a press never sees the pointer leave). That includes an h.Ref(other) next to the props of a hook: use the Ref field of a hook’s result, or pass the ref to the hook. The same ref twice is fine.

Controlled or not. A hook that holds state keeps it itself (uncontrolled, starting at DefaultSelected or DefaultOpen), and lets you keep it (controlled) when you pass a pointer to your value (Selected: &on, Open: &open): it then shows it and only asks OnChange or OnOpenChange for the next value, like an Input with Value and OnChange. A change you do not store is refused. OnChange is also called for an uncontrolled control, to follow it.

box := aria.UseCheckbox(aria.ToggleOptions{DefaultSelected: true}) // uncontrolled
box = aria.UseCheckbox(aria.ToggleOptions{OnChange: func(on bool) { save(on) }}) // uncontrolled, notified
on, setOn := tela.UseState(false)
sw := aria.UseSwitch(aria.ToggleOptions{Selected: &on, OnChange: setOn.Set}) // controlled

UsePress is React Aria’s usePress: the same press for every input.

Input The press
Mouse, touch, pen Starts when the primary button, a finger or the pen goes down on the element. A release over the element completes it; leaving the element ends it (OnPressEnd, no OnPress) and coming back while held starts it again, unless ShouldCancelOnPointerExit. The system taking the touch, a touch that becomes a scroll, or the element unmounting cancel it.
Enter On the focused element, presses at once on the key down. Key repeats are ignored.
Space On the focused element, starts on the key down and completes on the key up. Repeats are ignored, and a blur in the middle cancels it.
Virtual A click that no pointer or key made: an agent’s “click” action, a Label forwarding its click to the control, assistive technology. PointerType is aria.PointerVirtual.

The callbacks are OnPressStart, OnPressUp, OnPressEnd, OnPressChange and OnPress; PressEvent has the PointerType, the modifier keys and the position of the event (X, Y in the window, LocalX, LocalY in the element; the center for the keyboard and virtual presses). The result’s Pressed is true while pressed: use it for the look, since the active: variant follows the pointer only.

A press stops the propagation of the events that make it, so a pressable card inside a pressable row presses only the card. Call e.ContinuePropagation() in a callback to let them bubble. It also prevents the default action of the click: a Button with press props does not submit its Form (use UseButton’s Type, or call the handler from OnPress), and a Label does not forward the click. A disabled press (Disabled: true) ignores everything. The hook adds no role and no tab stop: for a button use UseButton.

UseButton is a press plus Role("button"), a tab stop and Disabled: it makes a Div or an A a button, and a Button a correct one.

Its Type is HTML’s: "button" (the default) does nothing to the Form that contains it, "submit" submits it and "reset" resets it, when the press completes (after OnPress), whatever the element is:

save := aria.UseButton(aria.ButtonOptions{Type: "submit"})
return Form(OnSubmit(func(tela.SubmitEvent) { send() }),
Input(Name("title")),
Div(save.Props, Class("rounded-md bg-primary px-3 py-1.5"), Text("Send")))

A Button that has the props of a press hook is type="button" until the hook says "submit": Enter in a text field presses the first button of type submit (the form’s default button), and a select’s trigger, a switch or a menu button that came first in the form must not be it. An explicit h.Type still wins. A Div made a button by a hook with Type: "submit", as above, is a submit button too: Enter in any field of the form presses it, as an HTML submit button; when the default button is disabled, Enter submits nothing (HTML).

ElementRef.Click() is the DOM’s element.click(): from a handler or an effect it dispatches a click (a virtual press for the hooks) at the element and runs its activation behavior, so a hook-made button with Type: "submit" submits through it, a Button in a Form submits or resets by its h.Type, and a Label forwards to its control. A disabled or unmounted element ignores it.

UseToggleButton, UseCheckbox and UseSwitch take aria.ToggleOptions (Selected, DefaultSelected, OnChange, the press callbacks OnPress, OnPressStart, OnPressEnd, OnPressChange and OnPressUp, Disabled, ReadOnly, ID, Ref) and return Selected, Pressed, ID and Props:

Hook Role and state Keys
UseToggleButton button, aria-pressed Enter and Space
UseCheckbox checkbox, aria-checked Space (not Enter, as in HTML)
UseSwitch switch, aria-checked Space
func Notifications() tela.Node {
box := aria.UseCheckbox(aria.ToggleOptions{DefaultSelected: true})
return Div(Class("flex items-center gap-2"),
Button(box.Props, Class("size-4 rounded border checked:bg-primary")),
Label(For(box.ID), Text("Notifications")))
}
  • Render a checkbox or switch with a Button: it is the element a Label can label, so Label(For(box.ID), ...) names the control and a click on the label toggles it.
  • A selected checkbox or switch matches checked: (and peer-checked:, group-checked:), and aria-checked:; a toggle button matches aria-pressed:. checked: matches an element whose h.AriaChecked is true, because Tela has no native checkbox.
  • ReadOnly keeps the control focusable and readable but not changeable (aria-readonly). There is no indeterminate state yet.

The aria snapshot shows them as Playwright does:

- checkbox "Notifications" [checked]
- switch "Wi-Fi" [checked]
- switch "Bluetooth" [disabled]
- button "Bold" [pressed]
hover := aria.UseHover(aria.HoverOptions{OnHoverStart: func(aria.HoverEvent) { preload() }})
return Div(hover.Props, If(hover.Hovered, Class("ring-2")), Text("Card"))

Hovered is true while a mouse or a pen is over the element or its descendants. A touch never hovers (the enter that a touch press makes is ignored). The hover: variant covers styling; use the hook when Go needs to know.

UseFocusRing returns Focused and FocusVisible: the ring shows after the keyboard moves the focus (Tab, arrows) and not after a click, as Chromium’s :focus-visible does. With Within: true it follows the focus anywhere inside the element (a group that rings all its parts).

ring := aria.UseFocusRing(aria.FocusRingOptions{})
return Button(ring.Props, If(ring.FocusVisible, Class("ring-2 ring-primary")), Text("Save"))

UseFocusScope(aria.FocusScopeOptions{Contain, Restore, AutoFocus}) makes an element a focus scope and returns Props and a Manager:

  • Contain keeps the focus inside the scope (a dialog): Tab and Shift+Tab wrap around inside it, a press on something that is not focusable (the scrim of an alert dialog) leaves the focus where it is, and a press or a script that would focus an element outside keeps it inside. When the focused element inside unmounts (a button that removes its own row), the focus goes to the scope’s element if it is focusable (TabIndex(-1), as UseDialog gives it), else to its first tabbable element. Popovers and selects opened from inside the scope count as inside.
  • Restore gives the focus back, when the scope unmounts, to the element that had it when the scope mounted (the button that opened the dialog).
  • AutoFocus focuses the first tabbable element after the frame that mounts the scope, unless the focus is already inside.
scope := aria.UseFocusScope(aria.FocusScopeOptions{Contain: true, Restore: true, AutoFocus: true})
return Div(scope.Props, Role("dialog"), AriaLabel("Settings"), ...)

The Manager moves the focus among the focusable elements of the scope from your handlers, in tree order; each method reports whether it found one:

scope := aria.UseFocusScope(aria.FocusScopeOptions{})
return Div(scope.Props, Role("toolbar"), OnKeyDown(func(e tela.KeyboardEvent) {
switch e.Key {
case tela.KeyArrowRight:
e.PreventDefault()
scope.Manager.FocusNext(aria.FocusOptions{Wrap: true})
case tela.KeyArrowLeft:
e.PreventDefault()
scope.Manager.FocusPrevious(aria.FocusOptions{Wrap: true})
}
}), buttons)

FocusFirst, FocusLast, FocusNext and FocusPrevious take aria.FocusOptions: Wrap goes around at the ends, Tabbable leaves out the elements that only a script can focus (TabIndex(-1)), From starts at another element of the scope.

plans := []string{"free", "pro", "team"}
plan, setPlan := tela.UseState("pro")
g := aria.UseRadioGroup(aria.RadioGroupOptions[string]{
Keys: plans, Value: &plan, OnValueChange: setPlan.Set, AriaLabel: "Plan",
})
return Div(g.Props, Class("flex flex-col gap-2"),
Range(len(plans), func(i int) tela.Node {
k := plans[i]
return Div(Key(k), Class("flex items-center gap-2"),
Button(g.RadioProps(k), Class("size-4 rounded-full border checked:bg-primary")),
Label(For(g.RadioID(k)), Text(k)))
}))
- radiogroup "Plan":
- radio "free"
- text: free
- radio "pro" [checked]
- text: pro

UseRadioGroup is React Aria’s useRadioGroup and useRadio with useRadioGroupState. At most one radio is checked. The value follows the rule of the other controls: Value *K makes it controlled (a value the app does not store is refused; a value that is not one of Keys, such as "", checks nothing), DefaultValue *K starts an uncontrolled group (DefaultValue: new("pro")), and OnValueChange is called for both. With no value nothing is checked. Disabled, DisabledKeys, ReadOnly and Required are the states of the group and its radios.

  • The arrow keys (all four, in either Orientation) move the focus to the next enabled radio, wrapping, and check it; Direction: "rtl" swaps left and right of a horizontal group. Space checks the focused radio and Enter does nothing. A press (a click, a tap, a Label) checks a radio.
  • Tab enters the group at the checked radio, or at the first enabled one when none is checked: the group is one tab stop.
  • Render a radio with a Button: a Label(For(g.RadioID(k))) then names it and checks it when clicked, and Enter in a text field of a Form still submits the form (a radio is never its default button). A Div works too, but a Label only names it.
  • The group is named by AriaLabel, AriaLabelledBy or the element with LabelProps. checked: and aria-checked: match the checked radio.

A segmented control, or a toolbar of toggle buttons: React Aria’s useToggleButtonGroup and useToggleButtonGroupItem. The selection is an aria.Selection, controlled with Selected and uncontrolled with DefaultSelected.

modes := []string{"list", "grid"}
view := aria.UseToggleGroup(aria.ToggleGroupOptions[string]{
Keys: modes, DefaultSelected: aria.SelectionOf("list"),
DisallowEmptySelection: true, AriaLabel: "View",
})
return Div(view.Props, Class("inline-flex rounded-md border"),
Range(len(modes), func(i int) tela.Node {
return Button(view.ItemProps(modes[i]), Key(modes[i]),
Class("px-3 py-1 aria-checked:bg-accent"), Text(modes[i]))
}))
SelectionMode Group Items
"single" (the default) radiogroup radio, aria-checked
"multiple" toolbar button, aria-pressed

The arrows of the orientation move the focus without toggling (Home and End jump; ShouldFocusWrap goes around the ends); Enter, Space and a press toggle. The group is one tab stop: Tab enters at the item that had the focus last, else the first one that is on. Pressing the item that is on turns it off unless DisallowEmptySelection.

React Aria’s useSlider and useSliderThumb with useSliderState. A slider has one thumb per value, so a range slider is two:

s := aria.UseSlider(aria.SliderOptions{
DefaultValues: []float64{20, 80}, Min: 0, Max: 100, Step: 5,
AriaLabel: "Price", ThumbLabels: []string{"Minimum price", "Maximum price"},
})
track := tela.UseSize(s.TrackRef)
return Div(s.Props, Class("w-64 p-4"),
Div(s.TrackProps, Class("relative h-2 rounded-full bg-muted"),
Range(len(s.Values), func(i int) tela.Node {
return Div(s.ThumbProps(i), Key(i), Class("absolute -top-1.5 -ml-2.5 size-5 rounded-full bg-primary"),
Style(tela.TranslateFn(func() (x, y float32) {
return float32(s.ThumbPercent(i)) / 100 * track.Get().Width, 0
})))
})))
- group "Price":
- slider "Minimum price"
- slider "Maximum price"
  • Values *[]float64 controls the values, DefaultValues starts them, OnValuesChange is called with all the values on every change and OnChangeEnd when the user stops (the pointer is released, or a key changed a value). A single slider is a slice of one value, as in Radix and shadcn/ui.
  • A press on the track moves the nearest thumb to the point and keeps dragging it; a press on a thumb drags it without a jump. Mouse, touch and pen drag the same way, and the thumb keeps the pointer wherever it goes. Thumbs never cross (nor come nearer than MinStepsBetweenThumbs steps), and values round to Step from Min.
  • Keys on a thumb: the arrows move it by one Step (by a page with Shift), PageUp and PageDown by a page (a tenth of the range, rounded to a step), Home and End to its limits. Direction: "rtl" mirrors a horizontal slider and its left and right arrows; a "vertical" slider has Min at the bottom.
  • ThumbPercent(i) (0 to 100, where React Aria’s fractions go from 0 to 1) is made to be read in the paint-phase closure of the thumb, as above: dragging moves the thumb by repainting, and an uncontrolled slider builds when the drag starts and ends (and when the pointer rests for a moment, so that aria-valuenow follows), not on every move. A controlled slider builds as often as the app’s state changes.
  • aria-valuetext is FormatValue(v), by default the number as it is written; ValueText(i) is the same text to show.

Progress bars and meters: UseProgressBar, UseMeter

Section titled “Progress bars and meters: UseProgressBar, UseMeter”
p := aria.UseProgressBar(aria.ProgressBarOptions{Value: done, AriaLabel: "Upload"})
return Div(p.Props, Class("h-2 w-64 overflow-hidden rounded-full bg-muted"),
Div(Class("h-full bg-primary"), Class(fmt.Sprintf("w-[%g%%]", p.Percent))))

A progressbar or a meter role with aria-valuenow, -valuemin, -valuemax and -valuetext (the rounded percentage, “40%”, unless FormatValue). A progress bar with Indeterminate has no value. Percent goes from 0 to 100.

  • Options structs and Props, with the integrated state of ADR-101 (Value *K, Values *[]float64, Selected Selection[K]), instead of the stately layer.
  • A radio is checked on its click, not on a native input’s change; Space checks on the key down. There is no hidden input and no form value.
  • A radio group’s arrows work in both orientations as React Aria’s, and aria-orientation defaults to "vertical".
  • A toggle group adds Home and End and ShouldFocusWrap.
  • A slider has no hidden input type="range"; the thumb is the slider and takes the keys. Its percentages go from 0 to 100.

A color picker is a shared color and the parts that change it. UseColorState holds the color; UseColorArea, UseColorSlider and UseColorField (React Aria’s useColorArea, useColorSlider and useColorField, with useColorPickerState) take it as State and return props. They must be called in the component that made the state.

st := aria.UseColorState(aria.ColorStateOptions{DefaultValue: new(tela.Hex("#6366f1"))})
area := aria.UseColorArea(aria.ColorAreaOptions{State: st, AriaLabel: "Color"})
hue := aria.UseColorSlider(aria.ColorSliderOptions{State: st, Channel: aria.ColorHue})
hex := aria.UseColorField(aria.ColorFieldOptions{State: st, AriaLabel: "Hex"})
size := tela.UseSize(area.AreaRef)
track := tela.UseSize(hue.TrackRef)
return Div(Class("flex w-64 flex-col gap-4 p-4"),
Div(area.AreaProps, Class("relative h-48 rounded-md"),
Style(tela.BgFn(func() tela.Color { return aria.HSB{H: st.LiveHSB().H, S: 100, B: 100, A: 1}.Color() })),
Div(area.ThumbProps, Class("absolute left-0 top-0 -ml-2.5 -mt-2.5 size-5 rounded-full border-2 border-white"),
Style(tela.TranslateFn(func() (x, y float32) {
px, py := area.ThumbPercent()
return float32(px) / 100 * size.Get().Width, float32(py) / 100 * size.Get().Height
})))),
Div(hue.Props, Div(hue.TrackProps, Class("relative h-3 rounded-full"),
Div(hue.ThumbProps, Class("absolute -top-1 -ml-2.5 size-5 rounded-full border-2 border-white"),
Style(tela.TranslateFn(func() (x, y float32) {
return float32(hue.ThumbPercent()) / 100 * track.Get().Width, 0
}))))),
Input(hex.InputProps, Class("w-28 rounded-md border px-2 font-mono")))
- group "Color":
- slider "Color"
- group "Hue":
- slider "Hue"
- textbox "Hex": "#6366f1"

(The thumbs’ aria-valuetext is “Saturation 58%, Brightness 94%” and “Hue 239°”. The area’s background is the kit’s job: the hue’s color with a white gradient across and a black gradient down. TrackColors() of a slider gives the stops of its gradient.)

  • The color. ColorStateOptions.Value *tela.Color controls it, DefaultValue starts it and OnChange is called with every change, as ADR-101’s rule says. The state keeps the color as an aria.HSB (hue 0 to 360, saturation and brightness 0 to 100, opacity 0 to 1) and reports a tela.Color, so that dragging to a gray or to black does not lose the hue: ToHSB of a gray has no hue, the state does. A color the caller passes back is recognized as the one it reported, and a gray of the caller’s own keeps the hue it was shown with. st.Set(c) asks for a color (a swatch). Color() and HSB() are those of the last build; LiveColor() and LiveHSB() are made to be read in paint-phase closures (tela.BgFn).
  • The area drags with a mouse, a finger or a pen: a press places the thumb and goes on dragging it; a press on the thumb drags it without a jump. Its channels are XChannel (saturation) and YChannel (brightness) by default, any two of hue, saturation and brightness; the third is the state’s. ThumbPercent() returns the position in percent from the left and the top, to be read in the thumb’s paint-phase closure: dragging repaints and does not build, as the slider does (the color builds when the drag starts and ends, and when the pointer rests for a moment).
  • Keys on the area’s thumb: the arrows move it by one step (by a page with Shift), PageUp and PageDown change the Y channel by a page and Home and End the X channel, as React Aria’s area does. Direction: "rtl" mirrors the X axis and the left and right arrows. The thumb is one slider whose aria-valuetext has both channels (“Saturation 58%, Brightness 94%”).
  • A color slider is built on UseSlider for one Channel: hue (0 to 360, a page of 15), alpha (0 to 1), saturation and brightness, or, with Space: "hsl", the HSL saturation and ColorLightness. aria-valuetext is the channel and its value (“Hue 180°”; FormatValue for another language); TrackColors() returns the colors at the stops of the gradient behind it.
  • The hex field commits on blur and Enter: a code that parses (#rgb, #rgba, #rrggbb, #rrggbbaa, with or without the #) sets the color and anything else reverts to the color’s code. Characters that cannot be part of a code are not typed; ArrowUp and ArrowDown add or subtract one (PageUp and PageDown sixteen) from the color as a number. Alpha shows and accepts the opacity.
  • Conversions: ToHSB, ToHSL, HSB.Color, HSL.Color, ParseHexColor (an error that says what is wrong) and FormatHexColor. They are plain functions on tela.Color, deterministic, and clamp to the sRGB gamut.

How it differs from React Aria: the area has one thumb with one slider role and a value text for both channels, where React Aria has two hidden range inputs, since the semantics tree has no two-dimensional slider; a color is a tela.Color with the state holding the hue, where React Aria has Color objects of a color space; there is no ColorWheel, ColorSwatchPicker or color name; and there are no localized channel names, only FormatValue.

Everything above runs in telatest: app.Click, app.Press("Enter"), app.Press("Space"), app.Focus, app.Hover, app.Expect(loc, telatest.Checked(true)), app.AriaSnapshot(). Touch and pen have no telatest action yet; the package’s own tests inject them as platform events.

A month grid that selects a date, or a range of dates: React Aria’s useCalendar, useRangeCalendar, useCalendarGrid and useCalendarCell with useCalendarState. It is the behavior under a date picker (a button, a popover and a calendar): the keys, the focus, the selection, the names and the headings. The calendar is Gregorian; the names, the headings and the first day of the week come from i18n.

cal := aria.UseCalendar(aria.CalendarOptions{AriaLabel: "Appointment", Min: aria.Today()})
return Div(cal.Props, Class("w-64 p-3"),
Div(Class("flex items-center justify-between"),
Button(cal.PrevProps(), Text("<")),
H2(cal.HeadingProps(0), Text(cal.Heading(0))),
Button(cal.NextProps(), Text(">"))),
Div(cal.GridProps(0), Key(cal.Month(0).String()),
Div(cal.RowProps(), Class("flex"),
MapIndex(cal.WeekdayNames(i18n.NameShort), func(i int, name string) tela.Node {
return Div(cal.ColumnHeaderProps(i), Key(name), Class("w-8 text-xs"), Text(name))
})),
Map(cal.Weeks(0), func(week []aria.Date) tela.Node {
return Div(cal.RowProps(), Key(week[0].String()), Class("flex"),
Map(week, func(day aria.Date) tela.Node {
return Button(cal.CellProps(0, day), Key(day.String()),
Class("size-8 rounded-md aria-selected:bg-primary aria-disabled:opacity-40"),
If(cal.IsOutsideMonth(0, day), Class("text-muted-foreground")),
Text(cal.DayText(day)))
}))
})))
- application "Appointment, February 2026":
- button "Previous" [disabled]: <
- heading "February 2026" [level=2]
- button "Next": ">"
- grid "February 2026":
- row "Sunday Monday Tuesday Wednesday Thursday Friday Saturday":
- columnheader "Sunday": Su
- ...
- row "Sunday, February 8, 2026 Monday, February 9, 2026 ...":
- gridcell "Tuesday, February 10, 2026" [selected]: "10"
- gridcell "Thursday, February 12, 2026" [disabled]: "12"

A date is an aria.Date, Date{Year: 2026, Month: time.October, Day: 5}: a day without a time or a zone, comparable with ==, usable as a map key and as the state of tela.UseState, with AddDays, AddMonths and AddYears (the day is clamped to the shorter month: January 31 plus a month is February 28), Compare, Weekday and String (“2026-10-05”, the key of a cell). aria.DateOf(t) and d.Time(loc) convert from and to a time.Time; the zero Date is “no date”. A time.Time is not comparable by == and carries a zone and daylight saving time that a calendar day does not have, which is why the hooks do not use it.

State follows the rule of the other controls. The selected date is uncontrolled when Value is nil (DefaultValue: new(aria.Today()) starts it) and controlled when Value points to the app’s state, which may refuse a change; OnValueChange is called for both. The focused date, the one with the tab stop, which decides the month on display, works the same with FocusedDate, DefaultFocusedDate and OnFocusChange (it starts at the selected date, else today). Min and Max bound what can be focused or selected; IsDateUnavailable marks dates that are shown but not selectable (booked days); Disabled and ReadOnly are the states of the whole calendar. VisibleMonths shows one to twelve months (Weeks(i) and CellProps(i, day) take the month’s index), and the month buttons move by that many. FirstDayOfWeek overrides the locale’s, FixedWeeks always shows six weeks, Locale and Direction override the component’s, and Today fixes what IsToday calls today (the default is the wall clock; give a fixed date in tests).

Keys, on a cell: the arrows move a day or a week (left and right are swapped in a right-to-left calendar), PageUp and PageDown a month, Shift with them a year (the day of the month is kept, or the last one), Home and End the first and last day of the week, Enter and Space select. The month on display follows the focus; dates outside Min and Max cannot be reached (the focus stops at the limit); unavailable dates can be focused, as in React Aria, and have aria-disabled.

Ranges. UseRangeCalendar takes a Value *aria.DateRange and selects in two steps: a press or Enter anchors the range, the pointer over a date (or the focus on it) previews the range up to it (IsInRange, IsRangeStart, IsRangeEnd), and a second press completes it, whichever end comes first; Escape cancels. While a range is anchored, the dates beyond the nearest unavailable one are not selectable unless AllowsNonContiguousRanges.

Parts. Props is the application (named by AriaLabel and the visible range, “Appointment, October 2026”), GridProps(i) a grid named by its heading (HeadingProps(i)), RowProps() a row, ColumnHeaderProps(i) a columnheader named by the full weekday, and CellProps(i, day) a gridcell with the full localized date as its name (“Monday, October 5, 2026”), aria-selected, aria-disabled and the tab stop. A cell is one element, a focusable gridcell, which the ARIA grid pattern allows: render it with a Button, key it by its date and the grid by its month. Dates outside the month fill the first and last week (IsOutsideMonth(i, day)); pressing one selects it and shows its month. Style with the aria-selected:, aria-disabled:, disabled: and focus-visible: variants and the queries IsToday, IsUnavailable and IsFocused. WeekdayNames(width) are the names in the locale at an i18n.NameWidth, ordered from the first day, and DayText(day) the number in the locale’s digits.

  • A date is an aria.Date, not a CalendarDate of @internationalized/date; the calendar is Gregorian only, and there are no time zones or era.
  • A cell is one gridcell element instead of a td and an inner div role="button"; its aria-label is the full date, without React Aria’s “Today” and “Selected” prefixes (the state is in aria-selected, and IsToday is for the look).
  • Home and End go to the start and end of the week (the APG’s keys); the calendar has no live region that announces a month change.
  • The labels of the month buttons are English (“Previous”, “Next”) unless PreviousLabel and NextLabel say otherwise.
  • Outside dates are cells too, and pressing one moves to its month, where React Aria’s grid hides them.
  • A range is not dragged with the pointer; it is two presses.

UseFloating places a floating element next to an anchor. It is a port of Floating UI’s computePosition and its middleware, built into Tela’s frame pipeline so that the element is in the right place in the first frame that shows it and follows its anchor while an ancestor scrolls, with no frame of its own and no cost while nothing changes.

func Popover(open bool) tela.Node {
anchor := tela.UseElementRef()
arrow := tela.UseElementRef()
fl := aria.UseFloating(anchor,
aria.Placement("bottom-start"), aria.Offset(8),
aria.Flip(), aria.Shift(8), aria.Arrow(arrow))
return Fragment(
Button(Ref(anchor), Text("Open")),
If(open, Portal(
Div(fl.Props, Class("w-64 rounded-md border bg-popover p-4 shadow-md"),
Text("Content"),
Div(Ref(arrow), fl.ArrowProps, Class("size-2 rotate-45 border bg-popover"))))),
)
}
  • anchor is the tela.ElementRef of the element the popover goes next to (attach it with h.Ref).
  • fl.Props goes on the floating element. It makes it fixed at the window’s top-left corner (a later class such as absolute wins) and places it. Put the element in a Portal so that no ancestor clips it.
  • The options are functional options applied in the order given, like Floating UI’s middleware array, and the order matters: Offset before Flip and Shift, Arrow last, Size after Flip and Shift.
  • fl.Placement is the placement after Flip chose one ("top-start"). It reaches your build one frame after the position, so style with it (origin of an animation, the arrow’s rotation), and use the values below for what must be right in the same frame.

aria.Placement("bottom-start") takes one of Floating UI’s 12 placements: top, right, bottom (the default) and left, each with -start and -end. In a right-to-left element (h.Dir("rtl") on the floating element) -start and -end mirror, as Floating UI’s rtl does for top and bottom. A mistake panics in dev mode with the closest valid placement:

aria: Placement("bottm-start"): unknown placement "bottm-start". Did you mean "bottom-start"? ...
Option What it does
aria.Offset(8) The gap between the anchor and the element. aria.OffsetOptions adds CrossAxis (skid along the anchor’s edge) and AlignmentAxis.
aria.Flip() Goes to the opposite side when the placement would leave the window. aria.FlipOptions: Padding, FallbackPlacements, FallbackStrategy, FallbackAxisSideDirection, NoFlipAlignment, NoMainAxis, NoCrossAxis, CrossAxisAlignment.
aria.Shift(8) Slides along the anchor’s edge to stay 8 px inside the window. aria.ShiftOptions: CrossAxis, NoMainAxis, Limiter (aria.LimitShift(...) keeps it attached to the anchor).
aria.Size(func(a aria.Available) { a.MaxHeight(a.Height) }) Gives you the room the element has (a.Width, a.Height, a.Rects) and limits it with a.MaxWidth(w) and a.MaxHeight(h).
aria.Arrow(ref) Computes where an arrow element points at the anchor.
aria.Hide() Tracks whether the anchor is scrolled out of view (fl.ReferenceHidden, fl.Escaped).
aria.AutoPlacement() Picks the placement with the most room, instead of Flip.
aria.AnchorRect(f) A virtual anchor (see below).

Every options struct is a variadic argument and its zero value is Floating UI’s defaults, so aria.Flip() and aria.Flip(aria.FlipOptions{Padding: 8}) both work. Options that default to true in Floating UI are negated here (NoFlipAlignment) so that the zero value stays the default.

Size limits the element’s border box in the same frame, with no flash of the unlimited element. Give the element overflow-y-auto (or put its content in a scrolling child) and a long list scrolls inside the window:

fl := aria.UseFloating(anchor, aria.Placement("bottom-start"), aria.Offset(4),
aria.Flip(), aria.Shift(8),
aria.Size(func(a aria.Available) {
a.MaxHeight(a.Height)
}, aria.SizeOptions{Padding: 8}))
Portal(Div(fl.Props, Class("flex w-64 flex-col overflow-y-auto rounded-md border bg-popover"), items))

A limit that apply does not ask for in a run is removed. Putting Size after Flip and Shift gives the placement Floating UI gives; before them the two can settle on a different placement than Floating UI’s (ADR-099).

Attach the ref given to aria.Arrow to the arrow element, with fl.ArrowProps beside it. ArrowProps makes the arrow absolute in the floating element and moves it, in the same frame, to the edge that faces the anchor (half of it outside the border box, as in Floating UI’s recipe) and along it to point at the anchor’s center, even when the popover shifted. fl.Side and fl.Align tell the side for rotation. For your own placement of the arrow, fl.ArrowX and fl.ArrowY are Floating UI’s arrow data as tela.Values: read them inside a tela.TranslateFn.

For a menu at the pointer, give UseFloating the zero tela.ElementRef and an AnchorRect that returns a rectangle in window coordinates:

pointer := tela.UseValue(tela.Rect{})
fl := aria.UseFloating(tela.ElementRef{},
aria.Placement("bottom-start"), aria.Flip(), aria.Shift(4),
aria.AnchorRect(pointer.Get))
// in an OnContextMenu handler: pointer.Set(tela.Rect{X: e.X, Y: e.Y})

A Value that AnchorRect reads places the element again when it changes, without building.

Hiding a popover whose anchor scrolled away

Section titled “Hiding a popover whose anchor scrolled away”
fl := aria.UseFloating(anchor, aria.Flip(), aria.Hide())
Div(fl.Props, Style(tela.OpacityFn(tela.MapValue(fl.ReferenceHidden, func(hidden bool) float32 {
if hidden {
return 0
}
return 1
}))))
  • Positioning runs inside the frame, on the anchor as painted (scroll offsets, transforms and layout animations applied), not on rectangles that a ResizeObserver or scroll listener noticed a frame later. There is no autoUpdate to configure.
  • The size middleware does not measure the element again inside the computation: Tela lays the tree out again within the frame (at most twice) and runs the position again with the new size.
  • The clipping area is the window cut by the overflow clips of the element’s containing-block chain: for an element in a Portal, the window. There is no boundary or rootBoundary option, and padding is one number.
  • offset takes numbers only (no function of the placement), the arrow computes with the floating element’s border box, and there is no inline middleware.

The overlay hooks follow React Aria’s useOverlay, useModalOverlay, useDialog, usePopover, useOverlayTrigger, useMenuTrigger and useTooltipTrigger, with the open state built in. You render the overlay (in a Portal, while it is open); the hooks give the props.

func Filters() tela.Node {
trig := aria.UseOverlayTrigger(aria.OverlayTriggerOptions{})
pop := aria.UsePopover(aria.PopoverOptions{
Trigger: trig.Ref, Ref: trig.OverlayRef, Open: trig.Open, OnClose: trig.Close,
Placement: "bottom-start", Offset: 4,
})
dlg := aria.UseDialog(aria.DialogOptions{Ref: trig.OverlayRef, AriaLabel: "Filters"})
return Fragment(
Button(trig.TriggerProps, Class("rounded-md border px-3 py-1.5"), Text("Filters")),
Portal(Presence(If(trig.Open,
Div(pop.Props, dlg.Props, Exit("opacity-0"),
Class("w-64 rounded-md border bg-popover p-4 shadow-md transition starting:opacity-0"),
Text("Content"))))))
}

The trigger, the popover and the dialog all put props on the overlay’s element, so they share its ref: trig.OverlayRef is made by the trigger hook and passed to the others as Ref (the dev panic of the previous section tells you when one is missing).

UseOverlay(aria.OverlayOptions{Open, OnClose, Dismissable, KeyboardDismissDisabled, ShouldCloseOnBlur, ShouldCloseOnInteractOutside, Modal}) returns Props that put the element on the overlay stack while it is mounted and open:

  • Escape calls OnClose of the top overlay only, and nothing below it sees the key. With KeyboardDismissDisabled the overlay still takes the key but ignores it.
  • A press outside (the left button, a touch or a pen; at its release, with the press kept from what is under it and its click cancelled) calls OnClose of the top overlay when it is Dismissable and ShouldCloseOnInteractOutside(e) (default: yes) allows it. Inside means inside in the element tree: the content of a Portal rendered from inside an overlay, and the overlays opened from it, count, so a press in a popover opened from a dialog does not close the dialog.
  • Modal makes everything outside the overlay (and outside the overlays above it) inert while it is open: it is not hit, cannot be focused, is out of the Tab order and is hidden from the aria snapshot, as React Aria’s ariaHideOutside. This folds React Aria’s useModalOverlay into one hook: there are no underlayProps, since a press outside already hits nothing below; draw a scrim as an ordinary element.
  • ShouldCloseOnBlur calls OnClose when the focus moves to another element outside the overlay.

An alert dialog is an overlay that is not Dismissable.

UseDialog(aria.DialogOptions{Role, Modal, AriaLabel, Ref}) returns Props and TitleProps: the dialog (or alertdialog) role, aria-modal, the name (the element with TitleProps, or AriaLabel) and a focus scope that moves the focus to the first tabbable element inside when the dialog opens (the dialog itself when there is none), keeps Tab inside it when Modal, and gives the focus back to the element that had it when the dialog closes.

dlg := aria.UseDialog(aria.DialogOptions{Modal: true, Ref: trig.OverlayRef})
ov := aria.UseOverlay(aria.OverlayOptions{Open: trig.Open, OnClose: trig.Close, Dismissable: true, Modal: true})
Portal(If(trig.Open,
Div(ov.Props, dlg.Props, Class("w-96 rounded-xl bg-background p-6 shadow-xl"),
H2(dlg.TitleProps, Text("Delete project?")), ...)))
- dialog "Delete project?":
- heading "Delete project?" [level=2]
- button "OK"
- button "Cancel"

Holds the open state (Open *bool to control it, DefaultOpen, OnOpenChange) and returns TriggerProps (a press that toggles, the button role, aria-haspopup from Type (“dialog”, “menu”, “listbox”, “tree” or “grid”), aria-expanded and, while open, aria-controls the overlay), Open, SetOpen, Toggle, Close, and the refs Ref (the trigger’s) and OverlayRef.

UsePopover(aria.PopoverOptions{Trigger, Open, OnClose, Placement, Offset, Floating, Modal, ...}) positions the element with UseFloating (the placement and offset, Flip and Shift(12), then the Floating options you add, such as a Size, then an arrow) and dismisses it with the overlay stack: Escape, a press outside, or the focus going elsewhere close it, but not a press on the trigger, which toggles it (a modal popover closes on a trigger press, since the trigger is inert). ArrowProps goes on an arrow element inside. The popover is where its trigger says in the first frame that shows it.

UseMenuTrigger(aria.MenuTriggerOptions{Trigger, Type, Open, DefaultOpen, OnOpenChange, Disabled, OnPress}) returns TriggerProps, MenuProps, Open, FocusStrategy, SetOpen, Toggle, Close, Ref and MenuRef. A mouse or a pen opens the menu at the press and a touch at the release (focusing the menu itself); Enter, Space and ArrowDown open it with FocusStrategy "first", ArrowUp with "last". With Trigger: "longPress" a press that lasts opens it ("first"), the quick press is OnPress, and the keys need Alt. The menu collection (UseMenu) reads FocusStrategy to focus its first or last item and calls Close when an item is chosen. MenuProps holds the ref, aria-labelledby the trigger, and a focus scope that gives the focus back to the trigger.

tip := aria.UseTooltipTrigger(aria.TooltipTriggerOptions{})
fl := aria.UseFloating(tip.Ref, aria.Placement("top"), aria.Offset(6), aria.Flip(), aria.Shift(8))
return Fragment(
Button(tip.TriggerProps, Text("Save")),
Portal(Presence(If(tip.Open,
Div(tip.TooltipProps, fl.Props, Exit("opacity-0"),
Class("rounded bg-foreground px-2 py-1 text-xs text-background transition starting:opacity-0"),
Text("Saves the file (Ctrl+S)"))))))

React Aria’s rules: the tooltip opens Delay (1.5 s) after the pointer rests on the trigger, or at once when the keyboard focuses it (a focus that a mouse press gave does not count), and closes CloseDelay (0.5 s) after the pointer leaves, when the trigger is pressed (a pointer, or Enter or Space; other keys such as Shift or the arrows leave it open), when the focus leaves, and with Escape (which closes it before anything under it). Moving the pointer onto the tooltip keeps it open. The tooltips of a window are a group: opening one closes the others, and while one is open, or for CloseDelay after the last closed, the next one opens at once (the warm-up and cool-down). Trigger: "focus" ignores the pointer. A zero delay is the default; aria.NoDelay is none. The tooltip’s TooltipProps give it the tooltip role and make the trigger aria-describedby it (h.AriaDescribedBy).

The delays run on the frame clock, with no goroutine: app.Advance drives them in telatest, and a pending or open tooltip asks for no frames.

- button "Save"
- tooltip "Saves the file (Ctrl+S)"

Render the overlay inside a Presence with If(open, ...) and give its element Exit classes and a transition (and starting: for the entrance), as in the examples. While it exits the overlay is inert (ADR-080): it is out of the overlay stack, so the page under it works, a second Escape reaches the next overlay, and it is gone from the aria snapshot; the exit ends when its transitions do.

  • One hook for modal and non-modal overlays (Modal), and no underlay props; popovers are non-modal unless Modal is set (React Aria’s are modal unless isNonModal).
  • ShouldCloseOnInteractOutside receives the press (tela.PointerEvent, with its position), not the element; blur always closes.
  • A popover’s trigger is excluded from outside presses by comparing the press with the trigger’s bounds.
  • Tooltips: the Escape is a window-level default action (the tooltip is on the overlay stack as an Escape-only overlay, so it does not take presses), and there is no separate UseTooltip.
  • aria-haspopup="dialog" is set for dialogs (React Aria omits it).

A list box, a menu, a tab list, a select and a combobox are collections: keyed lists of items with a focused key and a selection. You give the keys (any comparable type) on every build and render one element per key with the props of ItemProps(key); the hook holds the state, moves the focus and selects, with React Aria’s rules.

All of them take Keys []K and DisabledKeys []K; all but the tab list take TextValue func(K) string (the text of an item, for typeahead; without it the key as fmt.Sprint prints it, so typeahead works with string keys and you give TextValue for IDs; DisallowTypeAhead turns it off). They return a result with Props for the collection’s element (for the select and the combobox, ListBox.Props) and these, shared by all (aria.Collection[K]):

ItemProps(k) The props of the item with key k: role, aria-selected / aria-checked / aria-disabled, tab stop, ref and handlers.
IsSelected(k), IsFocused(k), IsFocusVisible(k), IsDisabled(k) The state, for styling in Go.
Focused() The focused key and whether there is one.
FocusKey(k), Focus(strategy), ClearFocus() Move the focus from a handler or an effect.
ItemRef(k) The ref ItemProps attaches (one ref per element: do not attach another).
Section(name) The props of a group of items and of its heading.

Style items with the aria-selected:, aria-checked: and aria-disabled: variants (the item’s own props, so they match exactly when the item is selected, checked or disabled), with focus-visible: (the element that has the focus, a ring only after the keyboard) and with the booleans above. A disabled item also gets Disabled, so disabled: matches too.

sel := aria.SelectionOf("b", "c")
sel.Has("b") // true
sel.With("a").Keys() // [b c a]
sel.Without("b").Keys() // [c]
sel.In(allKeys) // the selected keys in the order of allKeys
aria.All[string]() // React Aria's "all"

A Selection[K] is an immutable set: its methods return new values, so it is safe in tela.UseState and shared freely. All is a selection of every item that can be selected; it does not list the keys (Len is 0), so turn it into keys with Resolve(keys) when you need them. Equal compares the keys (== compares identity, so two equal sets are not ==).

The zero Selection means “not given”: a hook option left at it is uncontrolled, the hook keeps the selection (starting at DefaultSelected). A non-zero Selected is controlled: the collection shows it and only asks OnSelectionChange for the next one, so a change you do not store is refused. aria.SelectionOf[string]() (no keys) is a controlled, empty selection. OnSelectionChange is also called for an uncontrolled collection.

// Uncontrolled.
aria.ListBoxOptions[string]{Keys: fruit, SelectionMode: "multiple", DefaultSelected: aria.SelectionOf("banana")}
// Controlled.
sel, setSel := tela.UseState(aria.SelectionOf("banana"))
aria.ListBoxOptions[string]{Keys: fruit, SelectionMode: "multiple", Selected: sel, OnSelectionChange: setSel.Set}
var fruit = []string{"apple", "banana", "cherry"}
lb := aria.UseListBox(aria.ListBoxOptions[string]{
Keys: fruit, TextValue: strings.ToUpper, SelectionMode: "single", AriaLabel: "Fruit",
})
return Div(lb.Props, Class("w-48 rounded-md border p-1"),
Range(len(fruit), func(i int) tela.Node {
k := fruit[i]
return Div(lb.ItemProps(k), Key(k),
Class("rounded px-2 py-1 aria-selected:bg-primary aria-selected:text-primary-foreground focus-visible:ring-2"),
Text(k))
}))
- listbox "Fruit":
- option "apple"
- option "banana" [selected]
- option "cherry"

Render the items as Divs or Lis, not Buttons (a Button activates on Enter and Space by itself). The options:

Option Does
SelectionMode "none" (default), "single", "multiple".
SelectionBehavior "toggle" (default: a click toggles the item, so plain clicks build a multiple selection) or "replace" (a click selects only that item; Ctrl+click toggles, Shift+click selects a range; the arrow keys move the selection).
Selected, DefaultSelected, OnSelectionChange See above.
DisallowEmptySelection, DisallowSelectAll Keep one item selected; turn off Ctrl+A.
OnAction Called when an item is activated: Enter, a tap, or a click when nothing is selected ("toggle") or the list does not select. With "replace" and a selection, a click selects and a double click or Enter runs the action.
Orientation, Direction "horizontal" lists use the left and right arrows (swapped by "rtl").
ShouldFocusWrap ArrowDown on the last item focuses the first.
ShouldFocusOnHover The mouse focuses the item it moves over.
AutoFocus, FocusStrategy Focus on mount: the first selected item, else aria.FocusFirst / aria.FocusLast, else the list.
AriaLabel, AriaLabelledBy, Ref Name the list; share its ref with other hooks.

Counting a selection. sel.Len() counts an explicit selection only: for aria.All it is 0. Use len(sel.In(keys)) for the number of selected items whatever the selection is.

Keyboard. Tab enters the list (the first selected item, else the first, takes the focus; only the focused item is a tab stop). ArrowUp and ArrowDown, Home and End move the focus and skip disabled items; PageUp and PageDown move a viewport of items; typing text moves to the next item that starts with it (case-insensitive; the typed string expires after one second on the frame clock, and a Space typed inside that second is a character of the search and selects nothing, as in React Aria); Space and Enter select or act; with a multiple selection Shift+ArrowDown extends the range, Ctrl+A (Cmd+A on a Mac) selects everything, Ctrl+ArrowDown moves without selecting ("replace") and Escape clears the selection.

Sections. lb.Section("fruit") returns Props for a group of items (role group, named by its heading) and HeadingProps for the heading; the keys stay one flat list, so the arrow keys cross sections.

- listbox "Food":
- group "Fruit":
- text: Fruit
- option "Apple"

Give the items to a h.VirtualList with lb.Props on the list itself (it carries the ref the hook scrolls through):

h.VirtualList(len(keys), func(int) float32 { return 32 }, func(i int) tela.Node {
return Div(lb.ItemProps(keys[i]), Class("h-8"), Text(keys[i]))
}, lb.Props, Class("h-64"))

Keys of items that are not rendered can be focused: End, typeahead and the page keys scroll the list to the item, which takes the focus when it mounts. Ctrl+A selects the whole list.

With ShouldUseVirtualFocus the list keeps the focus and highlights an item (aria-activedescendant); the items are not focusable and IsFocused(k) says which one is highlighted. ExternalFocus is the same for a list that never has the focus because another element does, a combobox’s input: that element calls lb.HandleKeyDown from its key handler and puts lb.ActiveDescendant() on itself; a press on an option leaves the focus where it is.

Input(AriaLabel("Search"), OnKeyDown(lb.HandleKeyDown), lb.ActiveDescendant(), OnBlur(lb.ClearFocus))

The input keeps its own Home, End, Space, Ctrl+A and Escape, and there is no typeahead; the arrows, PageUp, PageDown and Enter go to the list.

UseMenu is the collection of a menu button. Call it in the component that is rendered while the menu is open (it focuses an item when it mounts), give it the trigger’s FocusStrategy, Close and MenuRef, and put the trigger’s MenuProps on the same element: they hold the focus scope that gives the focus back to the trigger, so UseMenu adds none.

func ActionsButton() tela.Node {
mt := aria.UseMenuTrigger(aria.MenuTriggerOptions{})
pop := aria.UsePopover(aria.PopoverOptions{
Trigger: mt.Ref, Ref: mt.MenuRef, Open: mt.Open, OnClose: mt.Close,
Placement: "bottom-start", Modal: true,
})
return Fragment(
Button(mt.TriggerProps, Text("Actions")),
Portal(If(mt.Open, ActionsMenu{Trigger: mt, Popover: pop})))
}
type ActionsMenu struct {
Trigger aria.MenuTriggerResult
Popover aria.PopoverResult
}
func (m ActionsMenu) Build() tela.Node {
menu := aria.UseMenu(aria.MenuOptions[string]{
Keys: actions, TextValue: strings.ToUpper,
OnAction: run, OnClose: m.Trigger.Close,
AutoFocus: true, FocusStrategy: m.Trigger.FocusStrategy, Ref: m.Trigger.MenuRef,
})
return Div(m.Popover.Props, m.Trigger.MenuProps, menu.Props,
Class("w-48 rounded-md border bg-popover p-1 shadow-md"),
Range(len(actions), func(i int) tela.Node {
return Div(menu.ItemProps(actions[i]), Key(actions[i]),
Class("rounded px-2 py-1.5 focus-visible:bg-accent aria-disabled:opacity-50"), Text(actions[i]))
}))
}
- menu "Actions":
- menuitem "Edit"
- menuitem "Duplicate"
- menuitem "Archive" [disabled]

ArrowDown on the trigger opens the menu with the first item focused, ArrowUp the last; a mouse opens it with the menu itself focused. Enter, Space and a click choose an item: it is selected (with a selection mode), OnAction(key) runs, OnClose closes the menu and the focus goes back to the trigger. Escape closes it. The mouse focuses the item it moves over; typeahead and disabled items work as in a list box.

SelectionMode: "single" makes the items radio items (menuitemradio) and "multiple" checkbox items (menuitemcheckbox), with aria-checked:

- menu "View":
- menuitemcheckbox "Ruler" [checked]
- menuitemcheckbox "Grid"

Choosing an item closes the menu unless the selection is multiple (several checkbox items are toggled in a row); CloseOnSelect (a *bool) overrides it. A single selection never unchecks by choosing the checked item. There are no submenus yet (nor in the menu bar).

UseMenuBar is the WAI-ARIA menubar pattern over the same menu: a row of triggers, each with its own menu. Only one menu is open at a time, so the hook is called once, for the bar, and the menu that is open is rendered by a component of its own, keyed by the open menu so that each one mounts anew and focuses its first item.

var titles = map[string]string{"file": "File", "edit": "Edit"}
func AppMenuBar() tela.Node {
bar := aria.UseMenuBar(aria.MenuBarOptions[string]{
Keys: []string{"file", "edit"}, AriaLabel: "Application",
})
return Fragment(
Div(bar.Props, Class("flex gap-1"),
Range(2, func(i int) tela.Node {
k := []string{"file", "edit"}[i]
return Button(bar.TriggerProps(k), Key(k),
Class("rounded px-3 py-1 aria-expanded:bg-accent"), Text(titles[k]))
})),
Portal(If(bar.Open, tela.WithKey(AppMenu{bar}, bar.OpenKey))))
}
type AppMenu struct{ Bar aria.MenuBarResult[string] }
func (m AppMenu) Build() tela.Node {
k := m.Bar.OpenKey
pop := aria.UsePopover(m.Bar.PopoverOptions(k))
menu := aria.UseMenu(aria.MenuOptions[string]{
Keys: items[k], OnAction: run, OnClose: m.Bar.Close,
AutoFocus: true, FocusStrategy: m.Bar.FocusStrategy, Ref: m.Bar.MenuRef(k),
})
return Div(pop.Props, m.Bar.MenuProps(k), menu.Props, Class("w-48 rounded-md border bg-popover p-1"),
Range(len(items[k]), func(i int) tela.Node {
return Div(menu.ItemProps(items[k][i]), Key(items[k][i]), Text(items[k][i]))
}))
}
- menubar "Application":
- menuitem "File"
- menuitem "Edit" [expanded]
- menu "Edit":
- menuitem "Undo"

MenuBarOptions[K] has Keys, DisabledKeys, Disabled, Open *K, DefaultOpen *K, OnOpenChange(key, open), Direction, NoWrap, AriaLabel, AriaLabelledBy and Ref. The open menu is a key: uncontrolled by default, controlled with Open (a key that is not in Keys, such as "", means all closed). The result has Props (the menubar), TriggerProps(k) (a menuitem with aria-haspopup and aria-expanded), MenuProps(k), PopoverOptions(k) (the options of UsePopover: below the trigger, not modal), MenuRef(k), TriggerRef(k), Open, OpenKey, IsOpen(k), FocusStrategy, SetOpen(k, open), Close and Ref.

The bar is one tab stop that remembers its last trigger. ArrowLeft and ArrowRight (swapped by Direction: "rtl") move between the triggers, wrapping unless NoWrap; Home and End jump; ArrowDown, Enter and Space open the menu of the focused trigger on its first item, ArrowUp on its last. While a menu is open ArrowLeft and ArrowRight close it and open the neighbor’s, and hovering another trigger does the same (the popover is not modal, so the triggers stay under the pointer). A press on the open menu’s trigger closes it, so do Escape, a press outside and choosing an item, and the focus goes back to the trigger. Tab closes the menu and leaves the bar. A touch opens a menu on the release. There are no submenus, no typeahead on the triggers and no mnemonics (Alt+letter).

UseContextMenuTrigger(aria.ContextMenuTriggerOptions{Open, OnOpenChange, Disabled, NoLongPress, Ref, MenuRef}) returns the props of an area that opens a menu at the pointer: TriggerProps handles a right click (the contextmenu event, at the pointer), a touch that stays down 500 ms (at the finger, with the first item focused) and Shift+F10 or the ContextMenu key (at the area’s top-left corner, with the first item focused). The event’s default is prevented and its propagation stopped, so the innermost area wins. The result is shaped like UseMenuTrigger: MenuProps, Open, FocusStrategy, SetOpen, Close, Ref, MenuRef, plus OpenAt(x, y) and Anchor, the virtual rectangle that AnchorRect takes. PopoverOptions() fills UsePopover for it: anchored at the point, flipped and shifted to stay in the window.

func Notes() tela.Node {
cm := aria.UseContextMenuTrigger(aria.ContextMenuTriggerOptions{})
return Fragment(
Div(cm.TriggerProps, Class("h-48 rounded-md border"), Text("Right click here")),
Portal(If(cm.Open, NoteMenu{cm})))
}
type NoteMenu struct{ CM aria.ContextMenuTriggerResult }
func (m NoteMenu) Build() tela.Node {
pop := aria.UsePopover(m.CM.PopoverOptions())
menu := aria.UseMenu(aria.MenuOptions[string]{
Keys: actions, OnAction: run, OnClose: m.CM.Close, AriaLabel: "Actions",
AutoFocus: true, FocusStrategy: m.CM.FocusStrategy, Ref: m.CM.MenuRef,
})
return Div(pop.Props, m.CM.MenuProps, menu.Props, Class("w-48 rounded-md border bg-popover p-1"), /* items */)
}

The menu is the one of a dropdown menu: UseMenu takes the same results, so a component that renders a menu can serve both. Escape, a press outside with any mouse button (a right click too, as in Radix) and choosing an item close it, and the focus returns to where it was. The popover is not modal: a second right click on the area closes the menu and opens a new one at the new point, and a right click in another area opens that area’s menu instead. A menu rendered in a Presence must get a new key at each opening (count them in OnOpenChange), or the exiting menu comes back without the focus. The long press opens the menu for a touch or a pen only, and a held mouse button clicks as usual (NoLongPress turns the gesture off). The keyboard menu opens at the area’s corner because a key event does not say which element has the focus.

tabs := aria.UseTabs(aria.TabsOptions[string]{Keys: names, AriaLabel: "Settings"})
sel, _ := tabs.Selected.First()
return Div(
Div(tabs.Props, Class("flex gap-1 border-b"),
Range(len(names), func(i int) tela.Node {
k := names[i]
return Div(tabs.TabProps(k), Key(k),
Class("px-3 py-2 aria-selected:border-b-2 aria-selected:border-primary focus-visible:ring-2"), Text(k))
})),
Div(tabs.PanelProps(sel), Class("p-4"), panel(sel)))
- tablist "Settings":
- tab "account" [selected]
- tab "password"
- tabpanel "account": account settings

UseTabs is React Aria’s useTabList, useTab and useTabPanel in one hook. The selected tab is the only tab stop; the arrow keys (up and down for Orientation: "vertical"; swapped by Direction: "rtl") move between tabs and wrap, Home and End jump, and disabled tabs are skipped; a tab list has no typeahead, as in React Aria. By default the focused tab is selected (KeyboardActivation: "automatic"); with "manual" the arrows only move the focus and Enter or Space selects. A tab list always has one selected, enabled tab: the first when none is given. The selection is a Selection of one key, uncontrolled or controlled like any other (tabs.Selected.First() is the key). A tab has aria-controls the panel while it is selected and PanelProps(k) names the panel by its tab; render the panel of the selected tab only. PanelProps makes the panel a tab stop (as React Aria does when it has no focusable content): add TabIndex(-1) after it when the panel has controls of its own.

A select is a button that shows the chosen item and opens a list of options next to it. UseSelect is the menu button (UseMenuTrigger with aria-haspopup="listbox"), a list box and a popover in one hook. It returns the props of five elements: the label, the trigger, the element inside it that shows the value, the popover and the list.

sel := aria.UseSelect(aria.SelectOptions[string]{
Keys: fruit, TextValue: capital, Placeholder: "Pick a fruit", MatchWidth: true,
})
return Fragment(
Span(sel.LabelProps, Text("Favorite fruit")),
Button(sel.TriggerProps, Class("flex w-48 justify-between rounded-md border px-3 py-2"),
Span(sel.ValueProps, Text(sel.Value)), Icon(chevronDown)),
Portal(If(sel.Open,
Div(sel.PopoverProps, Class("rounded-md border bg-popover p-1 shadow-md"),
Div(sel.ListBox.Props, Class("max-h-60 overflow-auto"),
Range(len(fruit), func(i int) tela.Node {
k := fruit[i]
return Div(sel.ListBox.ItemProps(k), Key(k),
Class("rounded px-2 py-1.5 focus-visible:bg-accent aria-selected:font-medium"),
Text(capital(k)))
}))))))

The aria snapshot, closed and open, reads like React Aria’s select in Chromium (checked against Playwright): a button named by its value and its label, and the listbox next to it.

- text: Favorite fruit
- button "Banana Favorite fruit" [expanded]: Banana
- listbox "Favorite fruit":
- option "Apple"
- option "Banana" [selected]
- option "Cherry" [disabled]

The options are those of a list box for Keys, TextValue (the text shown for the selected item, fmt.Sprint(key) when omitted), DisabledKeys and the single Selected, DefaultSelected and OnSelectionChange; those of a menu button for Open, DefaultOpen and OnOpenChange; and these:

Option Does
Placeholder What sel.Value says while nothing is selected.
Disabled The trigger is inert and disabled.
Placement, Offset, Floating Where the popover goes ("bottom-start" by default), as in UsePopover.
MatchWidth The popover is as wide as the trigger.
Modal The rest of the window is inert while the list is open (React Aria’s select is modal; the default here is a non-modal popover).
DisallowTypeAhead Turns off typeahead on the trigger and in the list.
AriaLabel Names the select when there is no label element.
Ref The trigger’s ref, for hooks used together on one element.

Keyboard and pointer. A press, Enter, Space and ArrowDown open the list (ArrowUp too, with the last item as the strategy); the selected item has the focus, else the first (the last for ArrowUp), else the list itself. Choosing an item (a click, Enter or Space) selects it, closes the list and gives the focus back to the trigger; choosing the selected item closes it without a change. Escape and a press outside close it, Tab closes it and moves on from the trigger, the mouse focuses the item it moves over, and typeahead and disabled items work as in a list box. On the closed trigger typing text selects the next item that starts with it (a one-second buffer; a space inside a search is a character) and ArrowLeft and ArrowRight select the previous and next item, with the list closed, as React Aria does.

Name. The trigger is named by the value followed by the label, the way React Aria sets aria-labelledby="value label": LabelProps marks the label (a click on it focuses the trigger) and ValueProps the element that shows the value; AriaLabel adds a name when there is no label. The list is named by the label too.

The list lives in the component that calls the hook, and its items are rendered by you only while sel.Open. There is no hidden native <select>: a Form does not submit the value, read sel.Selected (sel.Selected.First() is the key) in OnSubmit.

A combobox is a text field with a list of suggestions. UseCombobox holds the text, the filter, the selection and the open state, and returns the props of the input, an optional toggle button, the popover and the list. The input keeps the focus at all times: the list items are highlighted (virtual focus, aria-activedescendant), never focused.

cb := aria.UseCombobox(aria.ComboboxOptions[string]{
Items: fruit, TextValue: capital, AriaLabel: "Fruit", MatchWidth: true,
})
return Fragment(
Div(Class("flex"),
Input(cb.InputProps, Class("w-48 rounded-l-md border px-3 py-2")),
Button(cb.ButtonProps, Class("rounded-r-md border px-2"), Icon(chevronDown))),
Portal(If(cb.Open,
Div(cb.PopoverProps, Class("rounded-md border bg-popover p-1 shadow-md"),
Div(cb.ListBox.Props, Class("max-h-60 overflow-auto"),
Range(len(cb.Keys), func(i int) tela.Node {
k := cb.Keys[i]
return Div(cb.ListBox.ItemProps(k), Key(k), Class("rounded px-2 py-1.5"),
If(cb.ListBox.IsFocused(k), Class("bg-accent")), Text(capital(k)))
}))))))
- combobox "Fruit" [expanded]: blu
- button "Show suggestions" [expanded]: v
- listbox "Fruit":
- option "Blueberry"

The items. Give Items (all of them) and the hook shows the ones that match the text with Filter (aria.Contains by default, or aria.StartsWith, aria.EndsWith or your own func(text, query string) bool); render cb.Keys. Or give Keys, already filtered by you (a server search, a fuzzy match), and the hook shows them as they are. aria.Contains and its siblings ignore case and diacritics ("zur" finds “Zürich”: React Aria’s collator with base sensitivity). After the list was opened by hand (the button, the arrows) every item is shown until the text changes; cb.ShowAll says so, for a caller who filters Keys.

The text. InputValue (a *string), DefaultInputValue and OnInputChange work like the selection of a list box: uncontrolled by default, controlled when InputValue is non-nil. The text starts as the text of the selected item, and choosing an item sets it to that item’s TextValue.

Option Does
MenuTrigger When the list opens: aria.MenuTriggerInput (typing; the default), aria.MenuTriggerFocus (also when the input gets the focus, with every item) or aria.MenuTriggerManual (only the arrows and the button).
AllowsCustomValue The text may stay as typed when the input loses the focus; the selection is cleared. Without it the text goes back to that of the selected item (empty when none is).
AllowsEmptyCollection The list stays open when nothing matches, to show a “no results” message.
Disabled, ReadOnly The input and the button are inert; a read-only input never opens the list.
ShouldFocusWrap The arrows go around the ends.
AnchorRef, MatchWidth, Placement, Offset, Floating The popover is placed next to AnchorRef (the input by default; give the wrapper of the input and the button), as wide as it with MatchWidth.
Selected, DefaultSelected, OnSelectionChange, Open, DefaultOpen, OnOpenChange, TextValue, DisabledKeys, AriaLabel, Ref As above.

Keyboard (React Aria’s useComboBox). Typing filters the list and, in the default mode, opens it. ArrowDown and ArrowUp open it on the first or last item (the selected item when there is one) and then highlight the next and previous; PageUp and PageDown move a page. Enter chooses the highlighted item (sets the text, closes the list: selection and text agree); with none highlighted, or a closed list, it commits the text as Tab does. Escape closes the list and puts the text back to that of the selected item (cleared when none is); it reaches an enclosing dialog only when there was nothing to put back. Home, End, Space, ArrowLeft and ArrowRight stay with the text field (the arrows drop the highlight). While an IME composes text no key navigates, and the list follows the preedit text without disturbing the composition.

Pointer. Pressing an option chooses it and the input keeps the focus; the mouse highlights the option it moves over. A press outside moves the focus away, which commits the text and closes the list (the press reaches what it hits). The toggle button opens the list with every item or closes it, and keeps the focus on the input; it is not a tab stop and is labelled “Show suggestions” (set ComboboxOptions.ButtonLabel to change or translate the label).

The label: cb.LabelProps on a label element names the input and a click on it focuses the input; without one, give AriaLabel.

  • No collection object, Item or Section elements and no Node type: the keys are a Go slice and the items are ordinary elements. One state per hook and one ref per key replace the hook that React Aria calls in every item.
  • The selection is an immutable Selection with an All value, controlled when non-zero, instead of selectedKeys as a Set or "all".
  • A menu’s radio items cannot be unchecked by choosing them again; there are no submenus, per-item closeOnSelect, disabledBehavior, links or press, drag and release across menu items.
  • Typeahead is a case-insensitive prefix match (no collator).
  • When the focused item goes, the focus moves like react-stately’s useListState does (the item that took its place, never a disabled one, going back at the end), and a collection that had the focus keeps it.
  • Select: no hidden native select (a Form does not submit it), the popover is non-modal by default, and an item is chosen on the click (React Aria selects on the press’s release, so a press that starts on the trigger can be dragged onto an item); the trigger’s aria-labelledby points at the value and label elements you mark with ValueProps and LabelProps; no isRequired, validation, description or error message.
  • Combobox: Items with Filter, or Keys filtered by you, replace defaultItems/items with defaultFilter; Contains and StartsWith fold case and diacritics (NFD without combining marks) instead of using a collator; a press outside the popover does not dismiss it by itself: the input blurs and that commits the text; blur always commits (no shouldCloseOnBlur); aria-autocomplete is always list; the button’s label is ButtonLabel, “Show suggestions” by default (React Aria localizes it; here you pass the text); OnOpenChange does not tell what opened the list; no name or formValue.
  • Shift+Tab into a list focuses its first item, not the last one: Tela’s focus events do not carry the element the focus came from.
  • Menu bar and context menu: React Aria has neither hook. The menu bar follows the WAI-ARIA menubar pattern (and Radix’s Menubar) on top of the menu trigger’s rules, with one press handler per trigger key and a non-modal popover; no submenus, typeahead on the triggers or mnemonics. The context menu follows Radix’s ContextMenu; a keyboard menu opens at the area’s corner.

examples/aria is a “Command center” screen in the style of shadcn/ui built only from the hooks on this page, the elements of h and classes with the theme tokens: a project combobox, a tooltip, two menus (one of checkbox items), a modal dialog with a form (an Input, a select, a switch, a submit button), tabs and a multiple-selection list box, with entrances and exits, light and dark. Its tests drive the menu, the combobox and the dialog with the keyboard alone and assert their aria snapshots and where the focus is; its README lists which hook does what.

The Actions menu of the example