Skip to content

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.

DirectivePurpose
b-dataCreate a nested state scope from an object expression.
b-provideProvide live named values to this element and its subtree through $provided.
b-idGenerate named unique values in $id; bind them to id, for, or classes explicitly.
b-refRegister a concrete element under a literal name in the local, read-only $ref registry.
b-textReactively set an element’s text content.
b-bind-nameReactively bind an attribute or DOM property, e.g. b-bind-href="$data.url".
b-on-eventListen for a DOM event, e.g. b-on-submit="$data.save($event)".
b-modelTwo-way bind a text-like input, checkbox, radio, or select.
b-if, b-else-if, b-elseSelect the first matching branch. Default: remove/dehydrate inactive branches. .hide: preserve in the DOM; .detach: preserve outside the DOM.
b-scriptExecute a contextual function expression inside a marked script element.
b-maskFormat an input with a Unicode pattern string or synchronous formatter function. Optional moneyMask(options) returns a formatter.
b-transitionAnimate enter/leave with named CSS transition classes.
b-forRender an array, iterable, object, or integer.
b-keyGive a repeated item a stable string or number identity. Used with b-for.
b-templateUnwrap 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-htmlInsert markup without activating Brio; .hydrate activates templates explicitly marked with trustedHTML().
b-ignoreSkip hydration for this element and its descendants.
b-cloakRemoved when Brio hydrates the element; pair with [b-cloak] { display: none; } to hide raw templates until mount.
b-teleportReactively move an element or template range relative to an Element/ref or document selector. Default placement: after.
b-effectRun a reactive expression for side effects in the element’s owned scope.
b-router-outletRender the component matching the current optional route.
b-on-enter, b-on-exitObserve intersection/visibility changes.
b-on-addednodes, b-on-removednodes, b-on-attrs, b-on-textObserve DOM mutations.
b-on-resizeObserve element or document size changes.
b-optionsConfigure observer directives on the same element; see Observer options.
<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>

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 another
brio.flush();
document.querySelector(`[data-card-id="${card.id}"]`)?.focus();

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.

b-bind-* binds a DOM property when one exists; otherwise it sets or removes the corresponding attribute.

Binding value or targetBehavior
Attribute value false, null, or undefinedRemoves the attribute.
aria-* booleanWrites "true" or "false"; null/undefined still remove the attribute. For example, b-bind-aria-pressed="$data.on" announces a toggle’s state.
classAccepts a string, an object of class names and booleans, or freely nested arrays mixing both: ['card ' + $data.kind, { active: $data.on }].
styleAccepts a style object.
Read-only propertyBinds as an attribute, including SVG geometry (r/cx) and input.form/list.
Writable property whose setter throwsSurfaces 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).

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.

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.

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.

  • 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 no ref shorthand, dynamic-name expression, ref collection, or callback-ref API.
  • Each mounted root, b-data boundary, component instance, and repeated row owns a separate registry. Even empty b-data creates 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, and b-template alone 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-ref on a concrete row belongs to that row. Loop collection/key expressions run before new row targets register.
  • A component-host b-ref references 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-template wrappers or native <template> elements are errors: no surviving rendered element represents that fragment. Put refs on concrete children. b-ignore skips registration along with all other hydration.

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.

  • Plain b-if retains refs through live leave transitions, then unregisters on disposal. Reversal keeps the same ref; activation after completed removal registers a new element.
  • .hide and .detach retain 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-data boundary per branch; refs do not silently switch to the active branch.
  • Managed insertion and hydrated b-html inherit the destination’s registry, subject to normal nested boundaries; removal/replacement unregisters owned refs. Default b-html content 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.

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>
PlacementLocation
beforeImmediately before the target, in its parent.
after (default)Immediately after the target, in its parent.
prependBefore the target’s existing children.
appendAfter the target’s existing children.

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.

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.

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.

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.

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> carriesResult
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-ignoreStays inert, whatever else it carries.
b-cloak onlyThe 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-templateIts 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.

  • 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 (or b-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, and b-provide are 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.

  • Same-element b-effect runs in the template’s owned scope, after its contents hydrate. b-teleport moves 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-template or 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 with b-template is 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 @scope follows the style element’s new physical parent.
  • Keep a stable, concrete mount root; b-template inside it is supported, but putting it on the element passed to app.mount() is an error.
  • Plain b-if / b-else-if / b-else and b-if.detach support template branches. Plain conditionals dispose and remove the complete rendered range; .detach disconnects/reinserts the same range without stopping effects. Nested templates, loops, and teleported output are cleaned up with their owner.
  • b-for with b-key supports template rows: all roots and text move together, retained keys preserve DOM/local state, and removed keys dispose their scopes. Same-element b-id generates IDs per row; providers may read that row’s $for.
  • .hide still requires a concrete branch element. There is no wrapper whose display could be changed, and text nodes have no display property. Use .detach for wrapperless preservation or a concrete wrapper for .hide. A same-element b-transition is 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.

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.

Changing attribute order does not change structural precedence:

  1. b-ignore skips the entire element and its subtree, including other directives.
  2. A conditional chain is collected and evaluated in the parent context; only its branch instances proceed to the remaining steps.
  3. b-data establishes context on that element.
  4. b-for establishes repetition; each row is hydrated separately with $for.
  5. b-id and b-provide establish row-local IDs/live inherited values, then b-ref registers concrete targets in the applicable local registry.
  6. b-template unwraps and hydrates contents, then processes its supported b-teleport / b-effect companions, skipping other host processing.
  7. A registered component tag is mounted through its component-specific path.
  8. Ordinary host directives are processed in attribute order; children hydrate afterward unless a directive owns their content. b-router-outlet returns immediately when encountered.

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

CombinationBehavior / recommended pattern
b-if + same-element b-data, b-provide, or b-idThe 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-forPlain 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-dataData 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-idSupported: 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-provideSupported: providers are created during row hydration and can use the row’s $for.
b-for + b-keySupported; 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 directivesSee 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 hostNormal 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 branchesDuplicate error, including inactive preserved branches and transition-overlapping branches. Use distinct names or explicit b-data boundaries.
Multiple conditional directives on one elementAn 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

CombinationBehavior / 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 contentDirect 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 directivesDefault 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 bindingsGive 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 effectsCompeting 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-maskSupported 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 elementsErrors when processed. A component host does not acquire native input/script capabilities by using one of these directives.

Visibility, movement, and competing bindings

CombinationBehavior / recommended pattern
b-if.hide + b-bind-style / effects writing display on the branchCompeting 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 bindingsClass 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-teleportSupported 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-ignoreChild placements are errors; use before / after beside the controlled container, or choose an independently managed destination.
b-if + teleported childrenSupported, 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/propertyNo arbitration. Use one expression per target; do not combine prefixed/unprefixed aliases or different modifier spellings to install competing bindings.

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.

Add modifiers when an event needs a little more control, such as handling only Enter or preventing a form’s default submit behavior.

ModifierEffect
.prevent, .stopCall preventDefault() / stopPropagation().
.self, .outsideRun only for events targeting the element itself / outside it.
.window, .documentListen on window / document.
.onceRemove the listener after the first event that passes the other filters.
.capture, .passiveNative listener options.
.debounce, .throttleRate-limit the handler (150ms).
.enter, .esc, .space, .tab, .up, .down, .left, .right, .deleteRun only for that key (.delete matches Delete and Backspace). Several keys mean any of them.
.ctrl, .alt, .shift, .metaRun 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.

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>

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.

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.

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>

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.

ModeInactive content
b-ifRemoved and dehydrated after leave; later activation creates fresh local state.
b-if.hideKept in the DOM with display: none; activation restores authored visible display.
b-if.detachDisconnected 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>
  • 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.
  • .hide needs a concrete element or component host. Put b-for / b-template inside a wrapper for that mode. .detach also supports wrapperless template branches and same-element loops. Hiding a wrapper does not hide descendants teleported elsewhere.
  • Combining .hide and .detach, unknown conditional modifiers, or modifiers on b-else-if / b-else is an error.

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>
  • 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 $event when supplied by the caller. $el is the script element; use $data.$parent for 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.

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()">
TokenAccepted Unicode code point
*Any code point, including emoji
ALetter or decimal digit: [\p{L}\p{Nd}]
aLetter: \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.

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.

<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">
OptionDefaultMeaning
thousands','Thousands separator.
decimal'.'Decimal separator.
precision2Integer 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.

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'":

PhaseClasses
Enterfade-enter, fade-enter-active, fade-enter-to
Leavefade-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.

  • Brio tracks the element’s own transitionrun, transitionend, and transitioncancel events 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.

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-transition never read layout.
  • Component hosts support b-transition, so a b-for of components can animate.
  • Moves between different loops create a new element and cannot slide.