Behaviors
Attach reference behaviors, read their values and events, and manage their lifecycle in plain JavaScript or a framework.
On this page
Most components need no script: the element they style already has the behavior. A few patterns have no native element, such as a combobox, a calendar grid or a splitter. For those the stylesheet draws the states from ARIA and native attributes, and a behavior is the reference script that coordinates input, focus and selection. Use this guide to attach one, read its value and release its listeners when the element leaves the page.
#What a behavior is
A behavior is one file with one function, named after the pattern. It takes the root element and returns a cleanup function that stops its listeners, observers and timers. Cleanup does not restore the original markup; a calendar, for example, keeps the month it drew. It is a plain ES module with no dependency, no framework and no build step.
| A behavior does | A behavior never does |
|---|---|
Set and remove ARIA and native attributes: aria-selected, aria-checked, aria-expanded, aria-activedescendant, aria-valuenow, hidden, tabindex, open, a field's value | Set a style, a class for a state, a size or a position |
Move focus, and call the platform: showPopover(), showModal(), stepUp(), scrollIntoView() | Reimplement the platform: opening a popover or a dialog, light dismiss, the focus trap and Escape of a modal |
Pass what only an event knows through a custom property the contract publishes: --menu-x, --splitter-size | Place the element or apply limits. The stylesheet reads the property and does both |
| Create the rows a pattern repeats when they are data, such as the weeks of a month, with the contract's classes | Read or write anything outside the contract: it finds parts by the contract's classes and roles |
The stylesheet can work with another script that maintains the same contract: the attributes, values and keyboard behavior must still agree. The framework components attach these same behaviors rather than reimplementing their keys.
From input to visible state
The DOM attribute connects behavior to appearance. Pressing Enter on an active listbox option makes the behavior set aria-checked="true". The stylesheet reads that attribute and reveals the check. The script chooses the option; CSS draws its chosen state.
#The behaviors
Each is imported from @master/ui/behaviors/<name>.js. The last column is the script field of the contract: required means the pattern does not work without a script, optional means the script adds to markup that already works.
| Pattern | Import | What the script does | Script |
|---|---|---|---|
| Calendar | calendar | Writes the weeks of the shown month, marks today, the chosen day or range and the days that cannot be chosen, and moves through the grid | Required |
| Combobox | combobox | Hides the options that do not match what is typed, moves the active option, opens and closes the list, and writes the chosen option into the field | Required |
| Command palette | command | Filters the commands, moves the active option, clicks it on Enter and closes the dialog; opens it from anywhere with a shortcut | Required |
| Compare | compare | Copies the range input's value into --compare-position where the browser has no scroll-driven animations; changes nothing where it has | Required |
| Listbox | listbox | Moves the active option with the arrow keys and typed letters, and checks the chosen option, or several in a multiselectable list | Required |
| Menu | menu, contextMenu | Moves focus through the items with the arrow keys and typed letters, opens a submenu with the arrow that points into it; contextMenu opens a menu at the pointer | Optional |
| OTP | otp | Moves focus from slot to slot, and spreads a pasted or autofilled code over the slots | Required |
| Splitter | splitter | Writes the first pane's size to --splitter-size and aria-valuenow as the handle is dragged or moved with the keys | Required |
| Stepper | stepper | Makes the two buttons call stepDown() and stepUp() on the number field, and disables the one whose limit is reached | Required |
| Tabs | tabs | In a role="tablist": keeps one tab in the tab order, moves and selects with the arrow keys, and shows the panel of the selected tab | Optional |
| Tree | tree | Makes the tree one tab stop, walked and folded with the arrow keys | Optional |
| Wheel | wheel | Moves aria-selected to the option that rests under the mark, and gives a focused column the keys of a listbox | Required |
#Attach one
First load the stylesheet, then write the contract's markup. Run the module after the root element exists, and pass the element carrying the root class. The bare package import below assumes your application resolves npm modules; a raw browser module needs a resolvable URL or import map.
<div class="combobox"> <div class="input-group"> <input class="input-group-field" id="region" role="combobox" aria-expanded="false" aria-controls="region-list" aria-autocomplete="list" autocomplete="off" aria-label="Region"> <button type="button" class="combobox-toggle" tabindex="-1" aria-label="Show regions"></button> </div> <div id="region-list" popover="manual" class="listbox" role="listbox" aria-label="Regions"> <div class="listbox-option" role="option" id="region-hnd" data-value="hnd" aria-checked="false">Tokyo</div> <div class="listbox-option" role="option" id="region-sin" data-value="sin" aria-checked="false">Singapore</div> <div class="listbox-empty">No regions match.</div> </div></div>import { combobox } from '@master/ui/behaviors/combobox.js'const detach = combobox(document.querySelector('.combobox'))Type “Tok”, press ArrowDown and Enter, and confirm that the field shows Tokyo and detach.value is hnd. Call detach() when the element leaves the page. Attaching the same behavior twice to one root detaches its previous instance; it does not clean up listeners your application added.
#Attach all of them
attach() finds every pattern by its root class and attaches its behavior. It looks in the whole document, or in the element it is given and under it, and returns one function that detaches everything it attached.
import { attach } from '@master/ui/behaviors'const detach = attach()Three things to know before choosing it over single imports:
- It attaches the optional behaviors too. Every
menugets the arrow keys, everytreebecomes one tab stop and everytabsthat is arole="tablist"switches its panels. To leave one as the platform has it, import behaviors by name. - It passes no options. A calendar that picks a range, or has a first and a last day, is attached by name.
- A context menu belongs to a target only the page knows, so
attach()does not attach one. CallcontextMenu(target, menu).
It does not watch the document. For markup added later, call attach(element) on what was added and keep that call's cleanup function too. Call each cleanup before removing the corresponding roots.
#In a framework
For React, Svelte and Vue, each of these patterns has a component to copy into the project: it renders the markup, attaches the behavior and passes the value in and out. React, Svelte and Vue lists them. The rest of this section is for attaching a behavior yourself, in a component of your own.
The following snippets show attachment and cleanup only; place the full combobox markup from Attach one inside each root. Reattach when configuration options change. For application values and changing options, prefer the copied framework component or its adapter helper.
In React, useEffect takes the detach function as its cleanup:
import { useEffect, useRef } from 'react'import { combobox } from '@master/ui/behaviors/combobox.js'export function Combobox({ children }) { const root = useRef(null) useEffect(() => combobox(root.current), []) return <div className="combobox" ref={root}>{children}</div>}In Svelte, a behavior has the signature of an attachment, so {@attach} takes it as it is:
<script> import { combobox } from '@master/ui/behaviors/combobox.js'</script><div class="combobox" {@attach combobox}> <!-- the field and the listbox --></div>An action returns an object, so in a version of Svelte without attachments wrap the behavior: const action = (node) => ({ destroy: combobox(node) }), then use:action.
In Vue, attach when the component is mounted and detach when it is unmounted:
<script setup>import { onMounted, onUnmounted, ref } from 'vue'import { combobox } from '@master/ui/behaviors/combobox.js'const root = ref(null)let detachonMounted(() => { detach = combobox(root.value) })onUnmounted(() => detach?.())</script><template> <div class="combobox" ref="root"><slot /></div></template>#Options
The behaviors below accept an options object as their second argument, for example calendar(root, options).
#Calendar
value: the chosen day asYYYY-MM-DD, or the two ends of a range.month: the month to show first, asYYYY-MM.min,max: the first and last day that can be chosen.range: choose two days and every day between them.weekStart: the day in the first column, from 0 for Sunday to 6 for Saturday.locale: a BCP 47 language tag for the displayed names.disabled: a function that returns whether a day cannot be chosen.draw: a function that renders the grid instead of the behavior.
#Combobox
filter defaults to true: the behavior hides options whose text does not contain what was typed. Set it to false when the application supplies the matching options.
#Command palette
shortcut is the key combined with Command or Control to open and close the palette. It defaults to 'k'; set it to false to disable the shortcut. filter works as it does for the combobox.
#OTP
pattern is a regular expression each character must match. When omitted, the behavior reads the first slot's pattern attribute and falls back to digits when there is none.
#Splitter
step sets how far an arrow key moves the handle, in percentage points. It defaults to 5.
#Events
A behavior reports a change the way a native control does: with a DOM event that bubbles. Listen with addEventListener, or with the framework's own binding for native events.
| Behavior | Event | Dispatched from | Carries |
|---|---|---|---|
combobox | input, then change, when an option is chosen | The field | The value is the field's value |
listbox | input, then change | The listbox | Read aria-checked="true" on its options |
stepper | input, then change, when a button changed the number | The field | The value is the field's value |
splitter | input on each change of size; change when a drag ends or a key moved the handle | The handle | Read aria-valuenow |
otp | input from a slot the script writes to; change when every slot is filled | The slot; the root | detail.value: the code |
calendar | change | The root | detail.value: the day as YYYY-MM-DD, or [start, end] once both ends of a range are chosen |
wheel | change when a column comes to rest on another option | The column | detail.value: the option's data-value, or its text; detail.option: the element |
tree | change when a row with role="treeitem" that is not a branch is selected | The root | detail.item: the row |
tabs | change when another tab is selected | The tablist | detail.value: the tab's data-value, or its text; detail.tab: the element |
The change of an OTP field, a calendar, a wheel, a tree and a tablist is a CustomEvent, because the value is not on one native control. A slot of an OTP field is a native input, so its own change bubbles through the root as well, without a detail. The command palette, the menu and the compare dispatch nothing of their own. A command runs from the click of its option, which Enter sends too, and the items of a menu are native buttons, links and inputs.
import { calendar } from '@master/ui/behaviors/calendar.js'const root = document.querySelector('.calendar')const detach = calendar(root, { value: '2026-11-13', min: '2026-11-01' })root.addEventListener('change', (event) => { console.log(event.detail.value)})#The value
Where a pattern has a value that no single native control holds, the function its behavior returns has a value. Read it for what is chosen now. Set it to choose from outside: that marks the pattern and dispatches nothing, as setting the value of a native input does.
| Behavior | value |
|---|---|
calendar | The day as YYYY-MM-DD, or [start, end] in a range, the end null until it is chosen; null for none. Setting it shows the month of the new value |
combobox | What the chosen option stands for; null while the field holds text no option was chosen for. Setting it writes the option's text into the field |
listbox | What the chosen option stands for, null for none; a list of them when the listbox has aria-multiselectable="true" |
otp | The characters the slots hold, in order |
splitter | The first pane's size, in percent |
stepper | The field's number; null while it is empty |
compare | The range input's number |
tabs, tree | What the selected tab or row stands for; null for none |
wheel | What each column's selected option stands for, in the order of the columns |
An option, a tab and a row stand for their data-value, or for their text when they have none. Give data-value when the text is not what the application stores: data-value="hnd" on an option that says Tokyo.
import { combobox } from '@master/ui/behaviors/combobox.js'const region = combobox(document.querySelector('.combobox'))region.value = 'hnd' // checks the option, writes "Tokyo" into the fieldconsole.log(region.value) // "hnd"A value the script writes into a field goes through the setter of the element's prototype. React shadows value on an input to notice its own writes and ignores an input event for a value it has already seen; written this way, a choice reaches onChange like typing does.
#Options from a server
By default the combobox and the command palette filter the options that are in the markup. When the page decides what matches, by asking a server or searching an index, pass filter: false: the script hides nothing, and the page writes the options as the reader types. Options that arrive while the list is open get an active option again, so Enter still chooses the first match.
import { combobox } from '@master/ui/behaviors/combobox.js'const root = document.querySelector('.combobox')const list = root.querySelector('[role="listbox"]')const selection = combobox(root, { filter: false })let request = 0root.querySelector('[role="combobox"]').addEventListener('input', async (event) => { const current = ++request if (selection.value !== null) return // Choosing an option also dispatches input. const regions = await search(event.target.value) if (current !== request || !root.isConnected) return list.replaceChildren(...regions.map(option))})Here search(text) is the application's async search function and option(region) builds one element with listbox-option, role="option", a unique id, data-value and aria-checked="false". Keep labels as text rather than injecting server strings as HTML. The request counter prevents an older response replacing newer results; provide loading, empty and error feedback in the application.
#A grid of your own
The calendar is the one behavior that writes elements: the weeks of the month it shows. A renderer that should write them itself, a template or a framework that renders on a server, passes draw. The script then writes no element. It calls draw with the month to show, as YYYY-MM, whenever that is not the month in the grid, and marks the days once they are there: today, the chosen ones, the ones that cannot be chosen and the one in the tab order.
calendarMonth() gives a month as data, the same data the script draws from: its title, the seven weekday names and its weeks. Each day is a calendar-day button whose value is its date; that is how the script knows the days it did not write. Given a value, and range for two ends, the data also says how each day is drawn: selected, tab for the one day in the tab order, and range for the classes of a day in a range. A renderer that writes them has the chosen day in its first HTML, before any script runs.
import { calendarMonth } from '@master/ui/behaviors/calendar.js'const month = calendarMonth('2026-11', { value: '2026-11-13', locale: 'en-GB'})const chosen = month.weeks.flat().find(day => day.selected)console.log(chosen.date) // "2026-11-13"console.log(chosen.tab) // trueThe renderer uses that data to produce the contract's markup:
| Data | Render as |
|---|---|
title | The text of calendar-title |
weekdays | calendar-weekday column headers with the short name as text and full name as aria-label |
weeks | A calendar-week row for each week |
A day's date, text, label | The button's value, text and aria-label |
selected, tab | aria-selected and tabindex="0" for the initial chosen day, -1 for the others |
outside, range | calendar-day-outside when needed and the returned range class names |
After the grid exists, attach calendar(root, { value, locale, draw }), using the same initial value and locale. Your draw(month) callback renders another month's rows; the behavior marks the current selection and focus once those rows are present. Keep the title, weekday headers and grid labeling when replacing rows.
The calendar components for React, Svelte and Vue are written this way, so the month is in the first HTML.
#The keys
The keyboard list of a contract specifies the behavior shipped with that pattern. Read it with npx master-ui contract combobox:
| Keys | Action |
|---|---|
| ArrowDown, ArrowUp | Open the list and move the active option, wrapping at the ends |
| Home, End | Move to the first or the last option while the list is open |
| Enter | Choose the active option and close the list |
| Escape | Close the list; the field keeps its text |
| Typing | Filter the options and open the list on the first match |
A key the behavior handles calls preventDefault(). A key it does not handle is left alone, so Enter in a combobox with no active option still submits the form. Hidden items and those with disabled or aria-disabled="true" are skipped, and the horizontal arrow keys follow the direction of the text.
#Own a behavior
Keep importing the packaged behavior while it meets the application's needs; package upgrades then bring its fixes. When the project needs different keyboard or selection behavior, take the source and maintain that implementation:
npx master-ui own comboboxown copies the component's stylesheet, its contract and its behavior into the project, by default ui/combobox.css, ui/combobox.json and ui/combobox.js. From then on the script is the project's. Import it from there instead of from the package. Customization covers the rest of owning a component.
The combobox, command, listbox and menu behaviors import shared helpers from ./internal.js; own copies it beside them.
Keep the contract true while editing: the attributes the script moves are the ones the stylesheet reads, and the keyboard list should say what the keys now do.
#Without any script
The table lists packaged behaviors, not every application action that may need JavaScript. Native controls supply their own interaction where the browser supports it; consult the browser support guide for enhancement fallbacks. Several patterns in the table also have a form without a behavior:
- Tabs can be links or radio controls. The optional behavior is for a tablist that selects and shows associated panels.
- A tree is nested
<details>elements whose<summary>is the row. Branches open and close by themselves and every row is a tab stop. - A menu is a popover of buttons and links. The browser opens and closes it, Tab moves through the items and Escape returns focus to the button. Add
role="menu"only together with the behavior, because the role promises arrow keys.
A few other contracts mark a script optional and name no behavior. The script there is a line or two for something HTML has no attribute for, such as the indeterminate state of a checkbox, and the component works without it.