How to use this component
Composite is a headless component that manages 1D and 2D keyboard navigation and focus management for a collection of items. It associates a "composite" container element with nested "item" and optional "group" elements.
The component automatically routes focus between items based on your configured orientation and grouping. It intercepts:
- Directional keys:
ArrowUp,ArrowDown,ArrowLeft,ArrowRight - Boundary keys:
Home,End,PageUp,PageDown
Under the hood, the component implements the roving tabindex pattern.
Basic invocation
The basic invocation of this primitive yields a hash of three different modifiers (composite, item, and group). Applying these modifiers registers the elements with the composite manager:
The primitive itself doesn't provide any visual styling or ARIA roles to the container or items, and doesn't generate any extra HTML beyond what is yielded. It solely provides the focus orchestration and keyboard routing functionalities to the elements the modifiers are applied to.
Setting an initial active item
Use @defaultCurrentId to set which registered item starts as active (tabindex="0"). The value should match the id of one of the elements using the item modifier.
Choosing a stable @defaultCurrentId can help preserve a consistent re-entry focus target, per WAI-ARIA focus management guidance.
<Hds::Composite @defaultCurrentId="second-item" as |C|>
<div role="menu" {{C.composite}}>
<button id="first-item" type="button" role="menuitem" {{C.item}}>
First Item
</button>
<button id="second-item" type="button" role="menuitem" {{C.item}}>
Second Item (Initially Active)
</button>
<button id="third-item" type="button" role="menuitem" {{C.item}}>
Third Item
</button>
</div>
</Hds::Composite>
Notice: Set @defaultCurrentId equal to null if you want the composite container to receive initial focus instead of any item.
Constraining orientation
If your composite is strictly a horizontal toolbar or a vertical list, you can restrict the navigation axis using the @orientation argument.
- Vertical Option A
- Vertical Option B
- Vertical Option C
<Hds::Composite @orientation="vertical" as |C|>
<ul role="listbox" {{C.composite}}>
<li role="option" tabindex="-1" {{C.item}}>
Vertical Option A
</li>
<li role="option" tabindex="-1" {{C.item}}>
Vertical Option B
</li>
<li role="option" tabindex="-1" {{C.item}}>
Vertical Option C
</li>
</ul>
</Hds::Composite>
If set to vertical, left/right arrow keys will be ignored. If set to horizontal, up/down keys will be ignored.
Grouping and 2D navigation
If you register groups using the group modifier, the component acts as a smart router, changing from 1D linear navigation to 2D grid navigation.
<Hds::Composite as |C|>
<div role="grid" {{C.composite}}>
<div role="row" {{C.group}}>
<button type="button" role="gridcell" {{C.item}}>Row 1, Col 1</button>
<button type="button" role="gridcell" {{C.item}}>Row 1, Col 2</button>
</div>
<div role="row" {{C.group}}>
<button type="button" role="gridcell" {{C.item}}>Row 2, Col 1</button>
<button type="button" role="gridcell" {{C.item}}>Row 2, Col 2</button>
</div>
</div>
</Hds::Composite>
When groups are present, ArrowRight/ArrowLeft navigate within the row (group), and ArrowDown/ArrowUp navigate the column index across groups. If an item does not exist at a specific column index in an adjacent group (an uneven grid), the routing logic will intelligently resolve to the closest enabled item.
Looping and wrapping
You can control how the focus behaves when a user reaches the start or end of the composite using the @loop and @wrap arguments.
- Looping (
@loop): If true, reaching the end of a row/column will cycle focus back to the beginning of that same row/column. - Wrapping (
@wrap): If true (and in a 2D grouped context), reaching the end of a row will move focus to the beginning of the next row.
Use @loop when you want to cycle inside the same row/column:
<Hds::Composite @loop={{true}} as |C|>
<div role="grid" {{C.composite}}>
<div role="row" {{C.group}}>
<button type="button" role="gridcell" {{C.item}}>Start</button>
<button type="button" role="gridcell" {{C.item}}>Middle</button>
<button type="button" role="gridcell" {{C.item}}>End (Loops to Start)</button>
</div>
<div role="row" {{C.group}}>
<button type="button" role="gridcell" {{C.item}}>Next Row Start</button>
<button type="button" role="gridcell" {{C.item}}>Next Row End</button>
</div>
</div>
</Hds::Composite>
Use @wrap when you want to move into the next/previous row or column in grouped layouts:
<Hds::Composite @wrap={{true}} as |C|>
<div role="grid" {{C.composite}}>
<div role="row" {{C.group}}>
<button type="button" role="gridcell" {{C.item}}>Start</button>
<button type="button" role="gridcell" {{C.item}}>Middle</button>
<button type="button" role="gridcell" {{C.item}}>End (Wraps to Next Row)</button>
</div>
<div role="row" {{C.group}}>
<button type="button" role="gridcell" {{C.item}}>Next Row Start</button>
<button type="button" role="gridcell" {{C.item}}>Next Row End</button>
</div>
</div>
</Hds::Composite>
Both arguments accept either a boolean (true/false) or a specific axis string ('horizontal' or 'vertical') if you only want to loop/wrap in one direction.
If both are enabled for the same axis, looping takes precedence and wrapping is not applied for that key press.
Disabled items
You can pass a disabled named argument directly to the item modifier. The component will automatically apply the correct disabled and aria-disabled attributes to the DOM node, and the internal navigation logic will intelligently skip over it when routing keyboard events.
<Hds::Composite as |C|>
<div role="menu" {{C.composite}}>
<button type="button" role="menuitem" {{C.item}}>
Active Item
</button>
{{! The disabled argument prevents focus routing and applies disabled attributes }}
<button type="button" role="menuitem" {{C.item disabled=true}}>
Disabled Item (Skipped)
</button>
<button type="button" role="menuitem" {{C.item}}>
Another Active Item
</button>
</div>
</Hds::Composite>
Component API
Composite
[C].composite
yielded modifier
composite modifier yielded as a contextual modifier (see below).
[C].group
yielded modifier
group modifier yielded as a contextual modifier (see below).
[C].item
yielded modifier
item modifier yielded as a contextual modifier (see below).
defaultCurrentId
string | null
tabindex="0"). If explicitly set to null, no item is initially active and the composite container itself receives the focus. If left unset, the component automatically selects the first enabled item.
loop
boolean | string
- true
- false (default)
- horizontal
- vertical
"horizontal" or "vertical").
orientation
string
- horizontal
- vertical
wrap
boolean | string
- true
- false (default)
- horizontal
- vertical
Contextual modifiers
Because Composite is a headless component, it yields element modifiers rather than UI components.
[C].composite
The container modifier, yielded contextually.
element
HTMLElement
keydown) and manages the roving tabindex across its registered descendants.
[C].group
The grouping modifier, yielded contextually.
element
HTMLElement
[C].item
The interactive element modifier, yielded contextually.
element
HTMLElement
focus event listener to ensure mouse interactions keep the internal state synced with keyboard navigation.
disabled
boolean
- false (default)
<button >). When true, the modifier automatically applies the disabled and aria-disabled="true" attributes to the element, and ensures the item is safely skipped during keyboard routing.