HTML directives
Directives connect your HTML to state and behavior. Start with b-text,
b-on-click, and b-model, then reach for the others as you need them.
Directive arguments use hyphens (b-on-click, b-bind-href); options use chained dot modifiers (on-click.prevent.stop, b-if.hide). b-mask takes a pattern or formatter, not modifiers.
Directive quick reference
Section titled “Directive quick reference”| Directive | Purpose |
|---|---|
b-data | Create a nested state scope from an object expression. |
b-provide | Provide live named values to this element and its subtree through $provided. |
b-id | Generate named unique values in $id; bind them to id, for, or classes explicitly. |
b-ref | Register a concrete element under a literal name in the local, read-only $ref registry. |
b-text | Reactively set an element’s text content. |
b-bind-name | Reactively bind an attribute or DOM property, e.g. b-bind-href="$data.url". |
b-on-event | Listen for a DOM event, e.g. b-on-submit="$data.save($event)". |
b-model | Two-way bind a text-like input, checkbox, radio, or select. |
b-if, b-else-if, b-else | Select the first matching branch. Default: remove/dehydrate inactive branches. .hide: preserve in the DOM; .detach: preserve outside the DOM. |
b-script | Execute a contextual function expression inside a marked script element. |
b-mask | Format an input with a Unicode pattern string or synchronous formatter function. Optional moneyMask(options) returns a formatter. |
b-transition | Animate enter/leave with named CSS transition classes. |
b-for | Render an array, iterable, object, or integer. |
b-key | Give a repeated item a stable string or number identity. Used with b-for. |
b-template | Unwrap an element or instantiate a <template>’s contents in place, hydrating them without a rendered wrapper. Implied on a <template> that carries any b- directive. |
b-html | Insert markup without activating Brio; .hydrate activates templates explicitly marked with trustedHTML(). |
b-ignore | Skip hydration for this element and its descendants. |
b-cloak | Removed when Brio hydrates the element; pair with [b-cloak] { display: none; } to hide raw templates until mount. |
b-teleport | Reactively move an element or template range relative to an Element/ref or document selector. Default placement: after. |
b-effect | Run a reactive expression for side effects in the element’s owned scope. |
b-router-outlet | Render the component matching the current optional route. |
b-on-enter, b-on-exit | Observe intersection/visibility changes. |
b-on-addednodes, b-on-removednodes, b-on-attrs, b-on-text | Observe DOM mutations. |
b-on-resize | Observe element or document size changes. |
b-options | Configure observer directives on the same element; see Observer options. |
A few directives working together
Section titled “A few directives working together”<div b-data="{ query: '', visible: true, results: ['Brio', 'HTML'] }"> <label> Search <input b-model="$data.query"> </label>
<button b-on-click="$data.visible = !$data.visible">Toggle results</button> <ul b-if="$data.visible"> <li b-for="result, index in $data.results" b-key="$for.result"> <span b-text="`${$for.index + 1}. ${$for.result}`"></span> </li> </ul></div>Lists and stable keys
Section titled “Lists and stable keys”b-for uses item in $data.items or item, index in $data.items. Those names
declare properties of $for: read $for.item and $for.index in the row.
- Choose a stable key when items can be inserted, removed, or reordered.
Without
b-key, the index is the key. Duplicate keys are errors. - Retained rows keep their identity. Keyed reorders move only rows whose relative order changed; swapping two rows moves two rows.
- Focus and selection stay with a moved row. Brio uses the browser’s
state-preserving
moveBefore()where available, otherwise it restores focus and selection after the move. The element and local state are retained too.
A row that moves to a different b-for (a card dragged to another list) is a new element in the destination loop, so focus cannot follow it automatically. Flush, then focus the new row by key:
moveCard(card, toList); // state change: removes the row from one loop, adds it to anotherbrio.flush();document.querySelector(`[data-card-id="${card.id}"]`)?.focus();Row boundaries and clearing a list
Section titled “Row boundaries and clearing a list”A single concrete repeated element is its own boundary: Brio adds no
<!--brio:range--> comments around it. Template, b-template, and teleported
rows keep their range comments.
Clearing a list, or replacing it with items that share no old keys, disposes every old row at once. If the loop is its parent’s only content besides whitespace, the old rows are removed with a single DOM write.
Binding attributes, classes, and styles
Section titled “Binding attributes, classes, and styles”b-bind-* binds a DOM property when one exists; otherwise it sets or removes
the corresponding attribute.
| Binding value or target | Behavior |
|---|---|
Attribute value false, null, or undefined | Removes the attribute. |
aria-* boolean | Writes "true" or "false"; null/undefined still remove the attribute. For example, b-bind-aria-pressed="$data.on" announces a toggle’s state. |
class | Accepts a string, an object of class names and booleans, or freely nested arrays mixing both: ['card ' + $data.kind, { active: $data.on }]. |
style | Accepts a style object. |
| Read-only property | Binds as an attribute, including SVG geometry (r/cx) and input.form/list. |
| Writable property whose setter throws | Surfaces the error; does not silently fall back to an attribute. |
HTML parsing lowercases attribute names, so b-bind-viewBox reaches Brio as b-bind-viewbox. On SVG elements Brio restores the spec’s mixed-case attribute names (viewBox, preserveAspectRatio, gradientUnits, stdDeviation, …). The .prop modifier always assigns a DOM property and resolves its casing against the element (b-bind-tabIndex.prop, b-bind-scrollTop.prop).
Two-way input with b-model
Section titled “Two-way input with b-model”b-model text inputs and textareas preserve strings by default, including
whitespace and numeric-looking text. Supported model modifiers are .lazy
(update on change instead of input), .trim (trim whitespace), and .number
(parseFloat, keeping nonnumeric text unchanged). Text is literal by default;
b-html.hydrate opts into content hydration.
Cursor, selection, and composition
Section titled “Cursor, selection, and composition”When state changes the value of a focused text input or textarea (through
b-model or b-bind-value), Brio keeps the cursor and selection on the same
characters: text changed before the cursor shifts it, text changed after it
leaves it alone. Without this, browsers move the cursor to the end on every
programmatic write, which breaks live collaboration and server-side
reformatting. The browser’s own undo history does not survive programmatic
writes, and writing during IME composition can cancel it; avoid changing a
field’s value while compositionstart is active.
Local DOM references with b-ref
Section titled “Local DOM references with b-ref”Use b-ref when application code needs a particular element’s native API:
<section b-data=""> <input b-ref="search"> <button b-on-click="$ref.search?.focus()">Focus search</button> <dialog b-ref="dialog"> <button b-on-click="$ref.dialog.close()">Close</button> </dialog> <button b-on-click="$ref.dialog?.showModal()">Open dialog</button></section>$ref.search (also $context.$ref.search) is the actual DOM element, not a
proxy, component instance, or wrapper API. Use it for focus/selection, dialogs,
scrolling, media controls, measurement, or third-party integrations.
$el is still the current element; b-id still creates unique strings for
IDs, label associations, ARIA relationships, or selectors. Refs do not set IDs.
Ref names and local boundaries
Section titled “Ref names and local boundaries”- The name is literal, trimmed, and nonempty:
b-ref="search"does not evaluate JavaScript. Use$ref['search-input']for names requiring bracket access. Empty names, arguments, and modifiers are errors. There is norefshorthand, dynamic-name expression, ref collection, or callback-ref API. - Each mounted root,
b-databoundary, component instance, and repeated row owns a separate registry. Even emptyb-datacreates a ref boundary, although it does not create another data ancestry level. - Lookup is local-only. Missing names return
undefined; there is no parent fallback,$ref.$parent, or automatic parent collection of row refs. Ordinary wrappers, conditionals, andb-templatealone do not add a boundary. - Duplicate names in one registry throw. Separate rows/components/data scopes
may reuse names. Each keyed row retains its registry and nodes when reordered.
A same-element
b-refon a concrete row belongs to that row. Loop collection/key expressions run before new row targets register. - A component-host
b-refreferences the host element in its caller’s applicable registry. Setup/templates get a separate private registry. Projected slot content retains its author’s registry, including through template cloning. Cloning projected content with the same ref more than once can therefore cause a duplicate in the author’s registry; give repeated projected content its own explicit data boundaries. - Refs on
b-templatewrappers or native<template>elements are errors: no surviving rendered element represents that fragment. Put refs on concrete children.b-ignoreskips registration along with all other hydration.
When refs become available
Section titled “When refs become available”Refs register as their targets hydrate, before their ordinary effects/children. There is no pre-scan or descendant-ready guarantee during component setup or earlier sibling initialization. Event-time optional chaining handles missing targets; an owned effect can respond to a target appearing later:
import { effect } from '@jnreis/briojs';
app.component('search-field', { template: '<input b-ref="input">', setup({ $ref }) { effect(() => { const input = $ref.input; if (input instanceof HTMLInputElement) input.focus(); }); },});Reads ($ref.input), presence checks ('input' in $ref), and enumeration
(Object.keys($ref)) react to registrations/removals. DOM properties such as
$ref.input.value or .isConnected are not made reactive. Registry changes
may notify effects incrementally during hydration; this is not an “all mounted”
hook. Names cannot be assigned, deleted, or redefined by application code.
The npm RefMap type exposes Element | undefined; narrow the element type
before using specialized methods.
Ref lifetime and cleanup
Section titled “Ref lifetime and cleanup”- Plain
b-ifretains refs through live leave transitions, then unregisters on disposal. Reversal keeps the same ref; activation after completed removal registers a new element. .hideand.detachretain refs, including disconnected elements. Teleport changes placement, not ref ownership. A truthy ref does not imply connectivity.- Preservation modes hydrate all branches, so the same name across branches
in one registry is a duplicate even when only one branch is visible. Plain
branches may also overlap during leave transitions. Use distinct names or a
b-databoundary per branch; refs do not silently switch to the active branch. - Managed insertion and hydrated
b-htmlinherit the destination’s registry, subject to normal nested boundaries; removal/replacement unregisters owned refs. Defaultb-htmlcontent is not registered. Unmount empties that mount’s registry; remount creates a fresh one.
Use native APIs without bypassing Brio ownership: do not replace hydrated
children via a ref, establish competing input-value bindings, or write back
unconditionally from effects that observe those writes. Dispose integration
listeners/widgets through onCleanup, not by deleting registry entries.
Teleport targets and placement
Section titled “Teleport targets and placement”Use teleport when an element belongs to one part of your app but needs to appear elsewhere in the page, such as a modal or floating panel.
b-teleport evaluates a reactive expression. Supply an actual Element (including
a $ref value), a CSS selector string, or a [target, placement] pair:
<aside b-ref="panel"><p>Existing content</p></aside>
<!-- Default: immediately after the panel, not inside it. --><p b-teleport="$ref.panel">A sibling of the panel</p>
<!-- Explicitly insert inside the target. --><input b-teleport="[$ref.panel, 'prepend']"><div b-teleport="['#modal-root', 'append']">Modal content</div>
<!-- Targets and placements may depend on reactive data. --><div b-teleport="[$data.compact ? $ref.sidebar : $ref.panel, $data.placement]"> The same live element moves; its state and source context are retained.</div>| Placement | Location |
|---|---|
before | Immediately before the target, in its parent. |
after (default) | Immediately after the target, in its parent. |
prepend | Before the target’s existing children. |
append | After the target’s existing children. |
Choosing a teleport target
Section titled “Choosing a teleport target”Strings always mean selectors: 'panel' selects a <panel> tag, even if a
ref named panel exists. Write $ref.panel or $ref['panel-name'] for refs;
there is no bare-name lookup, .ref modifier, or unquoted-selector shorthand.
Tuple syntax requires exactly two items and a valid placement. Only Elements,
selector strings, and null/undefined targets are accepted; invalid expressions,
values, placements, selectors, modifiers, and self/descendant destinations throw.
Sibling placements require an element or fragment parent. Child placements
cannot insert into an inert native <template> or a Brio container whose
children are controlled by another directive, such as b-text or b-html.
You may still place output beside such a container.
Targets that appear or change later
Section titled “Targets that appear or change later”Refs retain their normal local-only lookup. A ref may be declared later in hydration: teleport waits for that target’s hydration to finish before inserting output, including component rendering. When a target is null/undefined, output stays at or returns to its authored source, without disposal or rehydration. Registration, removal, or replacement of that ref updates its placement. Missing targets do not implicitly hide the content; use a conditional when needed.
Selectors are resolved against the source element’s document, including outside the mount root. A valid selector with no match warns and restores source output. Repeated identical misses do not repeat the warning until the unresolved result changes. There is no DOM observer or polling: selectors are looked up again only when expression dependencies change. Prefer refs for conditional targets. An unchanged target/placement does not repeatedly move output. DOM connectivity, native reparenting, and sibling changes are not tracked. A retained ref can target a disconnected Element; sibling placement still requires a parent.
What moves, and what stays the same
Section titled “What moves, and what stays the same”Teleport preserves source data, providers, ref ownership, component/slot context, listeners, and node identity. It changes physical CSS ancestry and native event bubbling. Template ranges move together; keyed rows retain their order within each loop’s bounded target/placement group. Reactive retargeting while a same-element conditional or conditional loop is detached does not reconnect its output; activation uses the latest requested destination.
Cleaning up teleported content
Section titled “Cleaning up teleported content”Removing a ref target through Brio returns independently owned teleported output to its source. Removing the teleport’s original owner disposes that output wherever it lives. Selector-only targets have no reactive lifetime notification: dispose the source owner before removing those destinations, or switch the target expression first. Native DOM removal alone never unregisters a ref.
Wrapperless content with b-template
Section titled “Wrapperless content with b-template”Want several elements to behave as one branch or row, without adding a wrapper
to the rendered HTML? Use a directive-bearing <template>.
b-template is an in-place, one-time unwrap, not a component definition,
named template registry, HTML expression, or reactive template selector.
A native <template> that carries any b- directive renders in place as if it
had b-template, so b-template is only needed on other elements (or to be
explicit on a directive-free <template>):
<template b-if="$data.user"> <h2>${$data.user.name}</h2> <p b-text="$data.user.bio"></p></template><template> carries | Result |
|---|---|
No b- directive (only id, slot, class, …) | Stays inert. Brio never touches its .content, so it is safe for templates cloned by your own code or other libraries. Short aliases such as text or effect do not count. |
b-ignore | Stays inert, whatever else it carries. |
b-cloak only | The attribute is removed; the template stays inert. |
b-if, b-else-if, b-else, b-for/b-key, b-data, b-id, b-provide, b-teleport, b-effect, or b-template | Its contents render in place. |
A directive that needs a concrete element (b-bind-*, b-on-*, b-text, b-html, b-model, b-mask, b-transition, b-ref, b-if.hide, or any unknown b-*) | An error naming the directive. Put it on an element inside. |
The same explicit form still works everywhere:
<div id="app"> <template b-template> <h2>${$data.title}</h2> <p b-text="$data.description"></p> </template></div>Brio replaces the marked element with paired anchor comments and its hydrated
contents. Multiple top-level elements, text nodes, and comments are allowed.
The example renders the heading and paragraph directly inside #app.
A native <template> with no b- directive stays inert; Brio does not hydrate
its .content automatically.
Unwrapping and inherited context
Section titled “Unwrapping and inherited context”-
On
<template b-template>, Brio clones the template’s inert contents. On an ordinary container such as<div b-template>, it moves the existing children out and removes the container. Use a non-void element with children. -
Use the exact bare attribute
b-template(orb-template=""). There are no supported modifiers, arguments, or shorthand alias. Its attribute value is not evaluated;b-template="$data.template"does not select a template. -
No other directive is required. Children inherit the expression context and hydrate normally, with their own
$el. Their bindings remain reactive even though unwrapping itself happens only once. -
Same-element
b-data,b-id, andb-provideare processed before unwrapping and are available to the contents. These are useful optional companions:<template b-template b-data="{ label: 'Name' }"b-id="['input']" b-provide="{ label: $data.label }"><label b-bind-for="$id.input" b-text="$provided.label"></label><input b-bind-id="$id.input"></template>The label and input survive; the template does not. Context-creating expressions on the wrapper see that wrapper as
$el, not a rendered child.
Template companions and limits
Section titled “Template companions and limits”- Same-element
b-effectruns in the template’s owned scope, after its contents hydrate.b-teleportmoves the whole rendered range to its destination, including text, nested structural output, and boundary comments. Its selector must already exist; it is not a reactive template-source expression. - Directives that need a surviving element are an error on a wrapper (an
explicit
b-templateor a directive-bearing<template>):b-text,b-html,b-bind-*, events/observers,b-model,b-mask,b-transition,b-router-outlet, and their short aliases. Move them onto surviving children or an outer container. A registered component tag withb-templateis unwrapped rather than instantiated as a component, so it cannot take props. - Wrapper attributes are not forwarded:
class,style,id, accessibility attributes, and the wrapper’s layout/semantics disappear. Native CSS@scopefollows the style element’s new physical parent.
Template branches and keyed rows
Section titled “Template branches and keyed rows”- Keep a stable, concrete mount root;
b-templateinside it is supported, but putting it on the element passed toapp.mount()is an error. - Plain
b-if/b-else-if/b-elseandb-if.detachsupport template branches. Plain conditionals dispose and remove the complete rendered range;.detachdisconnects/reinserts the same range without stopping effects. Nested templates, loops, and teleported output are cleaned up with their owner. b-forwithb-keysupports template rows: all roots and text move together, retained keys preserve DOM/local state, and removed keys dispose their scopes. Same-elementb-idgenerates IDs per row; providers may read that row’s$for..hidestill requires a concrete branch element. There is no wrapper whose display could be changed, and text nodes have no display property. Use.detachfor wrapperless preservation or a concrete wrapper for.hide. A same-elementb-transitionis not a fragment/group animation API; use a concrete conditional branch when its whole output needs enter/leave transitions.
These combinations need no rendered wrapper:
<template b-if="$data.visible"> <h2>Conditional heading</h2> <p>Both roots are removed and disposed together.</p></template>
<ul> <template b-for="item in $data.items" b-key="$for.item.id"> <li b-text="$for.item.name"></li> <li b-text="$for.item.description"></li> </template></ul>
<template b-if.detach="$data.open" b-teleport="['#portal', 'append']"> <h2>Preserved at the destination</h2> <button b-on-click="$data.open = false">Close</button></template>Teleported keyed rows reorder together at their destination without moving back
to the source. When a conditional also owns a same-element loop, .detach
suspends that loop’s teleported row groups too; collection updates while detached
are reflected on reinsertion. As with concrete wrappers, teleporting a
descendant elsewhere does not make ancestor CSS display control it.
Directive combinations and conflicts
Section titled “Directive combinations and conflicts”Most directives work naturally together. The useful rule of thumb is to give each DOM resource one owner: children, input value, display, or structural host.
Structural directives can replace a host or return before the ordinary directive pass, so they are not an unordered set of independent behaviors.
The order Brio follows
Section titled “The order Brio follows”Changing attribute order does not change structural precedence:
b-ignoreskips the entire element and its subtree, including other directives.- A conditional chain is collected and evaluated in the parent context; only its branch instances proceed to the remaining steps.
b-dataestablishes context on that element.b-forestablishes repetition; each row is hydrated separately with$for.b-idandb-provideestablish row-local IDs/live inherited values, thenb-refregisters concrete targets in the applicable local registry.b-templateunwraps and hydrates contents, then processes its supportedb-teleport/b-effectcompanions, skipping other host processing.- A registered component tag is mounted through its component-specific path.
- Ordinary host directives are processed in attribute order; children hydrate
afterward unless a directive owns their content.
b-router-outletreturns immediately when encountered.
Same-element combinations at a glance
Section titled “Same-element combinations at a glance”Not every conflict throws: some skip a directive or let reactive effects overwrite each other. Do not rely on attribute order to resolve competing owners.
Context and structural ownership
| Combination | Behavior / recommended pattern |
|---|---|
b-if + same-element b-data, b-provide, or b-id | The condition cannot use values created by those directives on that branch. It sees the parent context. Move context creation onto an outer element if the condition needs it. |
b-if + same-element b-for | Plain b-if and .detach own the complete loop output, including template rows. The condition runs before the loop and cannot read that row’s $for. .hide requires a concrete wrapper instead. For per-row conditions, put a conditional inside the repeated row/template. |
b-for + same-element b-data | Data is created before repetition, not independently per row. The initializer cannot read the new row’s $for. Put per-item data on a child inside the row/template. |
b-for + same-element b-id | Supported: IDs are generated per row after $for is established. Retained keys keep their IDs; new keys get fresh IDs. A same-element b-key is evaluated before these new IDs exist. |
b-for + same-element b-provide | Supported: providers are created during row hydration and can use the row’s $for. |
b-for + b-key | Supported; b-key belongs on that repeated element and evaluates in its row context. It has no standalone binding behavior. |
b-template or directive-bearing <template> + structural/ordinary host directives | See the limitations above. Context, structural, teleport, and effect directives may share the wrapper; directives requiring a surviving element are an error. |
b-ref + b-template / native <template> | An error. Ref concrete children, not fragment wrappers. |
b-ref + b-data / b-for / component host | Normal data elements register in their data-local registry; rows register in their row-local registry. Component hosts register in the caller’s applicable registry; component internals are private. |
| Reused ref name across live conditional branches | Duplicate error, including inactive preserved branches and transition-overlapping branches. Use distinct names or explicit b-data boundaries. |
| Multiple conditional directives on one element | An error. Use one of b-if, b-else-if, or b-else per branch; only the leading b-if accepts mode modifiers. |
Content and input ownership
| Combination | Behavior / recommended pattern |
|---|---|
b-text + b-html (including .hydrate) | Both replace the same children. Do not combine them. Existing child markup is replaced, not used as fallback or an additional hydrated template. |
b-bind-* targeting textContent / innerHTML or b-effect mutating children + hydrated content | Direct DOM writes are not a substitute for owned content replacement and may remove nodes without disposing their bindings. Use b-text / b-html instead; keep third-party-managed subtrees under b-ignore. |
b-html + child directives | Default HTML leaves inserted directives/interpolation inert. .hydrate requires trustedHTML(), hydrates replacements in an owned content scope, and disposes the previous one. In either mode, do not separately manage those same children with an outlet, insertion API, or another content writer. |
b-router-outlet + content writers / authored child bindings | Give the outlet a dedicated empty container. It manages routed component children; other content owners conflict, and ordinary attributes after the outlet are not processed. Put unrelated behaviors on an outer wrapper. |
b-model + b-bind-value / b-bind-checked / manual value-writing effects | Competing state-to-control writers. Let b-model own the control’s value/selection; ordinary event handlers may perform additional actions but should not establish another independent value binding. |
b-model + b-mask | Supported on text-like inputs/textareas: the mask formats input before model listeners read it. Masked values are formatted strings; .number uses parseFloat, not locale-aware money parsing. Initialize model values in the expected format; changing model state alone does not cause an input event or guarantee reformatting by the mask. |
b-model on non-form elements; b-mask on non-input/textarea elements; b-script on non-script elements | Errors when processed. A component host does not acquire native input/script capabilities by using one of these directives. |
Visibility, movement, and competing bindings
| Combination | Behavior / recommended pattern |
|---|---|
b-if.hide + b-bind-style / effects writing display on the branch | Competing display owners; a reactive style write can reveal an inactive branch or override activation. Let the conditional own branch display; style a child or use .detach when appropriate. |
b-transition + dynamic class/style bindings | Class bindings add and remove only the names they own, so transition classes survive. Do not let a binding toggle a transition class itself, and avoid styles that overwrite animated properties while a transition runs; put those on a child when possible. Keyed b-for rows with b-transition animate position changes through <name>-move and briefly own the row’s inline transform; do not bind style.transform on that row. b-transition does not provide leave animation for removed b-for rows, or animate every descendant when an ancestor is removed. |
b-for + same-row b-teleport | Supported for concrete or template rows. Keyed reconciliation moves the complete row range at its destination. Each loop owns bounded groups per target/placement, preserving unrelated surrounding content through retargeting. |
b-teleport + a destination controlled by b-text / b-html / b-router-outlet / b-ignore | Child placements are errors; use before / after beside the controlled container, or choose an independently managed destination. |
b-if + teleported children | Supported, but hiding an ancestor with .hide cannot hide descendants moved elsewhere. Their effects also stay live in preservation modes. Put visibility on the surviving teleported element when it must be controlled independently. |
| Multiple bindings/effects writing the same attribute/property | No arbitration. Use one expression per target; do not combine prefixed/unprefixed aliases or different modifier spellings to install competing bindings. |
How combinations are tested
Section titled “How combinations are tested”tests/directive-interactions.test.js generates 465 pairwise co-mount tests
for all 30 directive forms in the reference table plus registered component
hosts. Each pair is mounted in one app and root using a behavior probe for each
directive; tests assert output, shared reactive updates, event/model behavior,
observer cleanup, and disposal. Failure output names the pair and expected and
actual results. This matrix verifies that distinct directives coexist across a
shared app tree; it does not imply that every same-element combination is
supported. Same-element precedence, conflicts, and placement rules are
documented above and covered by focused integration tests. The matrix also
checks that every directive listed in the reference table has a probe, so a
newly documented directive cannot silently go untested. A higher-order
regression composes b-for, b-key, b-id, and b-provide on keyed rows and
checks row-local values and IDs through reorder and disposal.
Event modifiers
Section titled “Event modifiers”Add modifiers when an event needs a little more control, such as handling only Enter or preventing a form’s default submit behavior.
| Modifier | Effect |
|---|---|
.prevent, .stop | Call preventDefault() / stopPropagation(). |
.self, .outside | Run only for events targeting the element itself / outside it. |
.window, .document | Listen on window / document. |
.once | Remove the listener after the first event that passes the other filters. |
.capture, .passive | Native listener options. |
.debounce, .throttle | Rate-limit the handler (150ms). |
.enter, .esc, .space, .tab, .up, .down, .left, .right, .delete | Run only for that key (.delete matches Delete and Backspace). Several keys mean any of them. |
.ctrl, .alt, .shift, .meta | Run only while every listed system key is held. |
<input b-on-keydown.enter.prevent="$data.addTag()" b-on-keydown.esc="$data.cancel()"><div b-on-keydown.ctrl.enter.window="$data.save()"></div>Key and system-key filters run first, so .enter.prevent cancels only Enter.
An unknown modifier (for example the typo b-on-click.prevnt) throws a
SyntaxError listing the supported names instead of being ignored.
Combining event modifiers
Section titled “Combining event modifiers”For ordinary DOM events, .outside, .window, and .document are alternative
targets, not combinations: .outside wins over .window, which wins over
.document. .debounce wins over .throttle if both are present; choose one.
Avoid .passive.prevent because a passive listener cannot cancel the default
action. .prevent/.stop execute before .self/.outside filtering, so those
filters do not prevent the cancellation/propagation side effect. Component
hosts accept the same modifiers.
.outside ignores the event that was being dispatched when its element was
created, so a menu opened by a click is not closed by that same click
bubbling up to document:
<button b-on-click="$data.open = !$data.open">Menu</button><div role="menu" b-if="$data.open" b-on-click.outside="$data.open = false">…</div>Reading updated DOM inside a handler
Section titled “Reading updated DOM inside a handler”Each event handler runs inside batch(): several writes produce one coherent
update when the handler returns. Consequently, DOM that depends on those writes
(a new b-if branch, its $ref entries, bound text/attributes) does not exist
yet inside the same synchronous handler. Call flush() to apply pending updates
first, for example
b-on-click="$data.editing = true; flush(); $ref.field.focus()". Methods can
call the exported flush() (brio.flush() in browser bundles). Code after an
await runs outside the batch and sees updates immediately.
Avoiding observer feedback loops
Section titled “Avoiding observer feedback loops”Observer b-options is specific to observer directives, not general directive
configuration. An observer on an element whose attributes/children are updated
by bindings or b-html can observe those framework writes too. Do not write the
same observed state/DOM back unconditionally from its callback or b-effect:
that can create feedback loops.
Conditional lifecycle
Section titled “Conditional lifecycle”Use a conditional to show the first matching branch. Start with plain b-if;
choose a preservation mode only when inactive content needs to keep its state.
b-else-if and b-else follow an adjacent b-if branch (whitespace and comments are allowed between branches). The first matching branch is selected; b-else is the final fallback. In every mode, conditions run in the parent scope before branch-local b-data or providers.
<section b-if="$data.loggedIn" b-data="{ clicks: 0 }"> <button on-click="$data.clicks++">${$data.clicks}</button></section><p b-else-if="$data.loading">Loading...</p><p b-else>Please log in.</p>Default: remove inactive branches
Section titled “Default: remove inactive branches”With unmodified b-if, an inactive branch is removed and dehydrated after its
leave transition completes, or immediately when no transition runs. Its bindings
and listeners remain live during leave. Reversing before completion reuses the
same element and local state; after a completed leave, activation creates fresh
branch-local b-data and component state. Parent/app state is not reset.
Keep local state with a preservation mode
Section titled “Keep local state with a preservation mode”| Mode | Inactive content |
|---|---|
b-if | Removed and dehydrated after leave; later activation creates fresh local state. |
b-if.hide | Kept in the DOM with display: none; activation restores authored visible display. |
b-if.detach | Disconnected without dehydration; activation reinserts the same nodes. |
Set a preservation mode only on the leading b-if; it applies to every
branch in that chain:
<section b-if.hide="$data.tab === 'settings'" b-data="{ edits: 0 }"> <button on-click="$data.edits++">Edits: ${$data.edits}</button></section><section b-else-if="$data.tab === 'preview'">Preview</section><section b-else>Choose a tab</section>
<aside b-if.detach="$data.open" b-data="{ count: 0 }"> <button on-click="$data.count++">Count: ${$data.count}</button></aside>Preservation timing and limits
Section titled “Preservation timing and limits”- Both modes hydrate all branches once at initial mount, even initially inactive branches. Component setup and contextual scripts run immediately; local state, bindings, listeners, and effects remain live while inactive. Neither mode pauses effects. Enclosing scope disposal cleans up all branches.
- Initially inactive branches do not enter-transition on mount. Subsequent activation/deactivation animates; hiding/detaching waits for leave completion. Rapid reversals cancel stale removal callbacks.
.hideneeds a concrete element or component host. Putb-for/b-templateinside a wrapper for that mode..detachalso supports wrapperless template branches and same-element loops. Hiding a wrapper does not hide descendants teleported elsewhere.- Combining
.hideand.detach, unknown conditional modifiers, or modifiers onb-else-if/b-elseis an error.
Contextual scripts
Section titled “Contextual scripts”Use a contextual script for initialization that belongs to a particular piece of HTML. Its cleanup belongs to that piece too.
Only <script b-script> is processed. Write an unassigned arrow expression so
the browser does not execute its body:
<script b-script> ($context) => { $context.$data.ready = true; const timer = setTimeout(() => { $context.$data.message = 'Ready'; }, 250); $context.onCleanup(() => clearTimeout(timer)); }</script>Script context and cleanup
Section titled “Script context and cleanup”- Brio constructs a function factory with an explicit context parameter, then
invokes the returned function with that context. Scripts do not use
with. - Context includes
$data,$el,$id,$for, and$eventwhen supplied by the caller.$elis the script element; use$data.$parentfor parent state. - The helpers are also lexical bindings, so
() => { $data.ready = true; }works too. - Async arrows and destructured parameters are supported. Errors surface without retrying the script.
- Async initialization does not block mounting: initialize values used by the
template before the first
await. - Register timer/listener cleanup synchronously through
$context.onCleanup.
Input masks
Section titled “Input masks”Use a pattern for familiar formats such as phone numbers, or provide a function when your formatting needs custom logic.
<input b-mask="'(###) ###-####'"><input b-mask="'aa-##-AA-*'"><!-- Two JS backslashes produce a single pattern escape: literal A --><input b-mask="'\\A##'"><input b-mask="({ value }) => value.toUpperCase()">| Token | Accepted Unicode code point |
|---|---|
* | Any code point, including emoji |
A | Letter or decimal digit: [\p{L}\p{Nd}] |
a | Letter: \p{L} |
# | Decimal digit: \p{Nd} |
Other characters, including 9, are literals. Backslash escapes the next code
point; escape a backslash to include it literally. A trailing escape is an error.
Tokens skip nonmatching input; literals are inserted and consume matching input
when present. Matching uses code points, not grapheme clusters: combining marks
do not independently match a/A, and emoji sequences can span several *
tokens. Native input restrictions still apply.
Custom input formatters
Section titled “Custom input formatters”The expression can instead evaluate to a synchronous MaskFormatter:
type MaskContext = { value: string; selectionStart: number | null; selectionEnd: number | null; selectionDirection: 'forward' | 'backward' | 'none' | null; inputType: string | undefined;};type MaskResult = string | { value: string; selectionStart?: number; selectionEnd?: number;};type MaskFormatter = (context: MaskContext) => MaskResult;For example b-mask="$data.formatter" or
b-mask="$data.createFormatter($data.options)". No registry, string-name lookup,
$mask injection, or money-specific modifier is involved. The expression and
synchronous reactive reads made while initially applying the formatter are
tracked. Prefer pure formatters; avoid mutations of reactive state in them.
Configuration changes reapply the mask. Formatters return a string or an
object with a string value; promises and malformed results throw. Explicit
selection positions must be integer UTF-16 offsets within the formatted string.
Without positions, the existing selection is preserved and clamped. Patterns
map selection to editable characters; money anchors it to digits on the right.
Brio owns composition handling, input events, model synchronization, and cleanup.
Optional money formatter
Section titled “Optional money formatter”<script src="/dist/brio.min.js"></script><script src="/dist/brio.money-mask.min.js"></script><input b-mask="brio.moneyMask({ thousands: '.', decimal: ',', precision: 2 })">import { moneyMask } from '@jnreis/briojs/money-mask';const app = createApp({ state: () => ({ amount: '', amountMask: moneyMask({ precision: 2 }) }),});<input b-model="$data.amount" b-mask="$data.amountMask">| Option | Default | Meaning |
|---|---|---|
thousands | ',' | Thousands separator. |
decimal | '.' | Decimal separator. |
precision | 2 | Integer from 0 to 100. |
Money uses ASCII-digit, fixed-minor-unit entry: 123456 becomes 1,234.56.
It is not a locale-aware number parser or Unicode digit converter.
Options are copied at factory time. Use
b-mask="brio.moneyMask($data.options)" to rebuild reactively.
npm exports the formatter types
from both @jnreis/briojs and @jnreis/briojs/money-mask.
CSS transitions
Section titled “CSS transitions”Add b-transition when a branch should ease into or out of view. You write the
CSS; Brio handles the enter/leave classes and waits before removing content.
For b-transition="'fade'":
| Phase | Classes |
|---|---|
| Enter | fade-enter, fade-enter-active, fade-enter-to |
| Leave | fade-leave, fade-leave-active, fade-leave-to |
The default name is brio. Put CSS transition properties on the active
classes, and starting/ending styles on the other classes. This directive
handles CSS transitions, not keyframe animations.
Transition completion and reversal
Section titled “Transition completion and reversal”- Brio tracks the element’s own
transitionrun,transitionend, andtransitioncancelevents for every running property, ignoring descendant events. - Removal waits for all properties, including delays. The fallback uses computed CSS duration plus delay, not a fixed timeout.
- No-duration/reduced-motion styles complete without waiting for an event.
- Unmount cancels pending frames, listeners, and timers.
Reversing a running transition in any b-if mode retargets the same element’s
CSS properties from their current interpolated values without replaying the
opposite starting pose. Plain b-if keeps the branch hydrated during leave so
a reversal can reuse it without a second element or state reset. Only a completed
leave dehydrates that branch; its next activation starts a fresh enter animation.
Enclosing cleanup cancels transitions and disposes all branches immediately.
Move transitions for keyed lists
Section titled “Move transitions for keyed lists”A keyed b-for row with b-transition also animates when it changes position, whether it was reordered or shifted by an insertion or removal. Brio measures the rows, reorders the DOM, then starts each moved row at its old position with an inverse transform and adds <name>-move while it slides home (FLIP). Style the move class:
<li b-for="task in $data.tasks" b-key="$for.task.id" b-transition="'task'">${$for.task.title}</li>.task-move { transition: transform 0.2s ease; }@media (prefers-reduced-motion: reduce) { .task-move { transition: none; } }The class is removed on the row’s own transform transitionend, or after the
computed duration.
- Interrupting a move restarts it from the row’s current visual position.
- New rows play the enter transition; rows that did not shift are untouched.
- Loops without
b-transitionnever read layout. - Component hosts support
b-transition, so ab-forof components can animate. - Moves between different loops create a new element and cannot slide.