Navigation

v0.0.4
Building

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 doesA 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 valueSet 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-sizePlace 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 classesRead 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.

PatternImportWhat the script doesScript
CalendarcalendarWrites the weeks of the shown month, marks today, the chosen day or range and the days that cannot be chosen, and moves through the gridRequired
ComboboxcomboboxHides 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 fieldRequired
Command palettecommandFilters the commands, moves the active option, clicks it on Enter and closes the dialog; opens it from anywhere with a shortcutRequired
ComparecompareCopies the range input's value into --compare-position where the browser has no scroll-driven animations; changes nothing where it hasRequired
ListboxlistboxMoves the active option with the arrow keys and typed letters, and checks the chosen option, or several in a multiselectable listRequired
Menumenu, contextMenuMoves 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 pointerOptional
OTPotpMoves focus from slot to slot, and spreads a pasted or autofilled code over the slotsRequired
SplittersplitterWrites the first pane's size to --splitter-size and aria-valuenow as the handle is dragged or moved with the keysRequired
StepperstepperMakes the two buttons call stepDown() and stepUp() on the number field, and disables the one whose limit is reachedRequired
TabstabsIn a role="tablist": keeps one tab in the tab order, moves and selects with the arrow keys, and shows the panel of the selected tabOptional
TreetreeMakes the tree one tab stop, walked and folded with the arrow keysOptional
WheelwheelMoves aria-selected to the option that rests under the mark, and gives a focused column the keys of a listboxRequired

#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>
app.js
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.

app.js
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 menu gets the arrow keys, every tree becomes one tab stop and every tabs that is a role="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. Call contextMenu(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:

Combobox.jsx
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:

Combobox.svelte
<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:

Combobox.vue
<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 as YYYY-MM-DD, or the two ends of a range.
  • month: the month to show first, as YYYY-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.

BehaviorEventDispatched fromCarries
comboboxinput, then change, when an option is chosenThe fieldThe value is the field's value
listboxinput, then changeThe listboxRead aria-checked="true" on its options
stepperinput, then change, when a button changed the numberThe fieldThe value is the field's value
splitterinput on each change of size; change when a drag ends or a key moved the handleThe handleRead aria-valuenow
otpinput from a slot the script writes to; change when every slot is filledThe slot; the rootdetail.value: the code
calendarchangeThe rootdetail.value: the day as YYYY-MM-DD, or [start, end] once both ends of a range are chosen
wheelchange when a column comes to rest on another optionThe columndetail.value: the option's data-value, or its text; detail.option: the element
treechange when a row with role="treeitem" that is not a branch is selectedThe rootdetail.item: the row
tabschange when another tab is selectedThe tablistdetail.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.

app.js
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.

Behaviorvalue
calendarThe 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
comboboxWhat 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
listboxWhat the chosen option stands for, null for none; a list of them when the listbox has aria-multiselectable="true"
otpThe characters the slots hold, in order
splitterThe first pane's size, in percent
stepperThe field's number; null while it is empty
compareThe range input's number
tabs, treeWhat the selected tab or row stands for; null for none
wheelWhat 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.

app.js
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.

app.js
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)  // true

The renderer uses that data to produce the contract's markup:

DataRender as
titleThe text of calendar-title
weekdayscalendar-weekday column headers with the short name as text and full name as aria-label
weeksA calendar-week row for each week
A day's date, text, labelThe button's value, text and aria-label
selected, tabaria-selected and tabindex="0" for the initial chosen day, -1 for the others
outside, rangecalendar-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:

KeysAction
ArrowDown, ArrowUpOpen the list and move the active option, wrapping at the ends
Home, EndMove to the first or the last option while the list is open
EnterChoose the active option and close the list
EscapeClose the list; the field keeps its text
TypingFilter 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 combobox

own 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.