Materials, motion, focus, and portals

Updated: Sep 24, 2026
Use the toolkit’s shared interaction state for material paint, content scale, and focus feedback. Keeping these effects on one timeline prevents visual artifacts and makes every input method produce consistent feedback.
This page covers public API. Names in code are exported from @wearables-ui-toolkit/mrbd unless noted otherwise.

Interaction state

Two enums describe state:
EnumMembersMeaning
State
DEFAULT, FOCUSED, PRESSED
The interaction state tracked by InteractableBase.
VisualState
NONE, DEFAULT, FOCUSED, PRESSED
The state a material paints.
InteractionState pairs a State with isDisabled. A disabled interactable paints the DEFAULT visual state, and a component that already carries its own standalone visual (a bare avatar or glyph, for example) collapses the resting DEFAULT state to NONE so it draws no resting backdrop.
ContentScaleInsets holds the per-state horizontal insets used for the “breathing” content scale. The scale is computed from width only and applied uniformly to both axes, so content never distorts.
Recommended practices:
  • Let the component that owns interaction also own the material state transition. For interactable components, Container owns it through InteractableBase.
  • Supply configuration from component code instead of running another state machine beside the material.
  • Animate scale, material color, stroke, and glow from the same transition so their feedback stays synchronized.
  • Programmatic focus must produce the same visual state as keyboard or directional-pad focus. This consistency is an accessibility invariant.
  • Paint the intended resting or focused state on the first frame. Avoid an initial animation from an unscaled state because it makes page entry appear unstable.

Materials

A ContainerMaterial is an ordered list of layers plus the current visual state. Each layer renders exactly one effect — a solid fill, one gradient, one shadow, one stroke.
Layer propertyMeaning
placement
LayerPlacement.BACKGROUND (behind content) or LayerPlacement.FOREGROUND (in front).
sortOrder
Z-order inside a placement group, between MIN_SORT_ORDER and MAX_SORT_ORDER, defaulting to DEFAULT_SORT_ORDER.
blendMode
How the layer composites against its backdrop.
clipsToShape
Whether the layer is clipped to the container shape. Defaults to true.
animatesContinuously
Whether the layer needs a running animation frame loop.
supportsDiscretePartialFocus
Whether the layer shifts its focus-aware origin during rubber-band and handoff feedback.
alphaForState
The layer’s target alpha per visual state.
Layers in the same placement normally share one canvas. The host only runs an animation-frame loop while a continuously-animating layer is actually visible in the current state, so an off-state effect costs nothing.
A state change is a single tween from 0 to 1, described by MaterialStateTransition (from, to, progress). Every layer interpolates its own animated properties across that one tween, which is why glow, stroke width, gradient scale, and alpha stay synchronized.

Ownership and lifetime

import { useMemo, type ReactNode } from 'react';
import { Container, MaterialLibrary } from '@wearables-ui-toolkit/mrbd';

function UnboundedContainer({ children }: { children: ReactNode }) {
  const material = useMemo(() => MaterialLibrary.unbounded(), []);
  return <Container material={material}>{children}</Container>;
}
  • A material instance carries mutable visual state and per-layer caches, so exactly one mounted host can own it at a time. Mounting the same instance in two containers concurrently throws.
  • Memoize each factory call for its host. Avoid calling a factory inline during render because each new instance restarts the transition.
  • Reuse the owner’s instance across renders to keep transitions continuous.
  • withInset() returns a derived material with material-owned insets, and withoutInnerShadow() returns one with the inner-shadow layer removed. Both create independent layer instances. A stateful custom layer must therefore implement clone() so derived materials do not share mutable state.
  • Prefer immutable or intentionally shared values in callbacks passed to createLayer. Use a layer class when the layer owns mutable state.
Start with a MaterialLibrary factory instead of assembling layers by hand. To change a stock component’s paint, use its supported material or configuration rather than modifying its layers.
The ContainerMaterialConfig flags foregroundBlendsWithContent and backgroundBlendsWithBackdrop exist for materials whose blend modes need live DOM or backdrop pixels. Both split compositing into extra surfaces, so enable them only when the effect genuinely requires it.

Shape providers

Materials describe paint; components own shape. Override geometry through a component’s shapeProvider prop.
ProviderUse
RoundedRectangleShapeProvider(cornerRadius, smoothing)
Smooth rounded rectangles. Defaults to a large radius.
TailShapeProvider(direction, cornerRadius, smoothing)
Bubble geometry with a 'left', 'right', or 'none' tail.
Custom ShapeProvider
Any other geometry. Implement getShapePath, getStrokePath, and optionally getCssBorderRadius.
Prefer radii from the CornerRadius scale so custom shapes stay aligned with toolkit geometry. A provider supplies both the fill path and the inset stroke path as SVG path data, which keeps inner shadow, glow stroke, noise, and scrim layers on the component’s outline. Shape providers must be immutable because geometry is cached per instance and keyed by getShapeProviderId. Create a new provider when its configuration changes, then reuse that provider until the next change.
The toolkit uses smooth-corner geometry when a component draws a path, tail, cutout, or special panel shape. Preserve that geometry instead of substituting border-radius. Use generateSmoothRoundedRectPath or getCachedSmoothRoundedRectPath when authored drawing code needs the same outline.

Press and activation

InteractableBase normalizes pointer and keyboard activation so both produce the same visuals and the same callbacks.
  • Pointer press enters PRESSED on mouse down and leaves it on mouse up, mouse leave, blur, or a global pointer release.
  • Enter and Space are handled on key down and key up. The default action is suppressed, the element runs the same press-in/press-out visuals, and key up dispatches a single activation.
  • onActivate fires exactly once per activation and receives the originating mouse or keyboard event (InteractableActivationEvent). onClick still receives the genuine DOM click.
  • A press that is released faster than the minimum feedback window is held at PRESSED for the full window before returning, so a quick tap is never invisible.
  • Activation with no preceding pointer press — a programmatic or assistive click — emits a brief visual press pulse so feedback is still shown.
  • onLongPress fires while the control is still pressed after the long-press timeout, and the click that would follow is suppressed.
  • Repeated fast activation cannot strand the control in an impossible state: the pending release is recomputed when it runs, and it is cancelled on unmount.
Interaction guarantees:
  • Press-in and press-out states are transient. Activation returns to FOCUSED while focus remains on the control.
  • A control must not remain visually pressed after activation; stale feedback would misrepresent its current state.
  • Application logic may debounce a duplicate semantic action, but it must not suppress press feedback because every activation needs a visible response.

Focus navigation

FocusNavigationProvider implements directional focus for a subtree. App renders one around your content with automatic initial focus enabled.
PropDefaultBehavior
enabled
true
Turns the whole behavior on or off.
containerRef
provider-owned wrapper
Scopes focus queries to an element you own.
blockTabNavigation
false
When true, Tab is suppressed so navigation is directional only.
autoFocusFirst
false
Lets the focus coordinator claim the top-left interactable on mount and on page change.
Keep the default Tab traversal for keyboard and assistive-technology access. Enable directional-only input only when the host input model requires it.
When an arrow key is pressed, the provider:
  1. Searches for the nearest eligible target in that direction, first within the focus sections that contain the current element and then across the whole container. Candidates are scored so distance along the movement axis dominates perpendicular offset, and a candidate directly in line with the current element wins over a closer but off-axis one.
  2. Skips anything hidden, inert, aria-hidden, zero-sized, or clipped out of view by an ancestor that cannot scroll on that axis.
  3. If no target exists, asks the nearest scroll container to scroll in that direction.
  4. If neither focus nor scroll can move, signals an invalid direction to the focused element, which surfaces as onInvalidFocusDirection and the rubber-band effect.
Escape restores focus to the first eligible target when focus has left the provider or rests on the provider’s container element; it never steals focus from a descendant control. Where a real mouse is expected, a primary-button click that lands near a focusable moves focus to the nearest one, so a mouse behaves like reaching that element by arrow key. The provider turns this off on Meta Ray-Ban Display, where input is directional and a synthetic tap must not move focus.
useFocusNavigation exposes isActive, moveFocus(direction), activateFocused(), and clearFocus() for the rare case where you need to move focus programmatically.
Focus guarantees:
  • Exactly one component is focused at a time. The coordinator clears the previous owner before the new one claims focus.
  • Keep the toolkit component as the interaction target instead of adding a second focusable wrapper. One target gives assistive technology and directional navigation a consistent owner.
  • Use initialFocusEligible={false} to exclude a control from automatic initial focus while preserving its place in the tab order.
preserveFocusedInteractableDuringNavigation() holds the current focus owner’s visual state briefly while an embedding browser moves document focus during same-document navigation. Call it before the navigation and invoke the returned function to release it.

Partial focus, handoff, and rubber-band

Two effects share the same partial-focus channel.
Handoff. When focus moves between two interactables on the same page, the outgoing element receives an outgoing signal with the incoming element’s rectangle and the incoming element receives the mirror. Components animate their focus-aware gradient origins toward and away from each other using SpringConfigs.PARTIAL_FOCUS_HANDOFF, so the previous control leans toward the new one and the new one enters from the old one’s direction. Handoff is suppressed when either element is on an exiting page or when the two elements belong to different pages — route transitions and focus handoff must not fight each other. onPartialFocusHandoff exposes the signal to custom components.
Rubber-band. When directional input cannot move focus or scroll, the focused component runs a discrete two-stage rubber-band. Each stage lasts AnimationDurations.CONTAINER_RUBBERBAND and uses Interpolators.CONTAINER_RUBBERBAND_STAGE_ONE then Interpolators.CONTAINER_RUBBERBAND_STAGE_TWO.
Both are gated by the same props:
PropDefaultBehavior
partialFocusSupportedAxis
PartialFocusSupportedAxis.XY
Restrict feedback to one axis, or set None to disable it.
isRubberbandTranslationEnabled
true
When false, rubber-band moves only the partial-focus material origins and leaves the element in place.
onInvalidFocusDirection
—
Called with 'up' \| 'down' \| 'left' \| 'right' when directional input fails.
Individual layers opt out by setting supportsDiscretePartialFocus to false, so a layer that should stay anchored does not slide with the effect.

Route transitions

PageTransition is the router-agnostic page host.
PropDefaultBehavior
transitionKey
required
Identity of the current page. A change starts a transition.
config
DefaultPageTransitionConfig
Motion configuration.
direction
'forward'
Chooses the forward or back motion from the config.
disabled
false
Swaps pages with no animation.
initialFocus
'top-left'
'top-left', 'none', { selector }, or a resolver function, used when there is no saved state for the key.
preservePageState
true
Snapshots and restores scroll offsets and the focused element per key.
timingScale
'auto'
Adapts transition speed to the runtime. A positive number overrides it and is clamped to a safe range.
onTransitionEnd
—
Fires when the transition settles, including the non-animated path.
DefaultPageTransitionConfig is InAppPageTransitionConfig: use it for normal screen-to-screen movement. AppSwitchPageTransitionConfig is available for larger context changes.
Behavior guarantees:
  • Before the enter animation starts, the host waits for the incoming page’s images to load and decode, bounded by a short timeout, so the page does not animate in half-painted.
  • The exiting page is marked aria-hidden and inert, so it never takes focus or input during the transition.
  • With preservePageState, scroll positions are restored instantly before the page becomes visible and focus is restored to the element that had it, with retries so a lazily loaded page still lands focus once its content mounts.
  • Page content updates during an in-flight transition are applied to the entering page only; the exiting page keeps its snapshot.
Pair PageTransition with route preloading so the incoming page’s code is already warm. RoutePreloadProvider queues preload requests, drains them on an idle callback, and holds the queue while directional input is still arriving, so warming never competes with a scroll or focus move. RoutePreloadTarget and useRoutePreloadTarget request a preload on focus, pointer enter, and visibility (each individually switchable). createPreloadableLazyComponent gives you a preload() function to hand to the provider’s preloadRoute.

Floating portals

Tooltips, context menus, vertical menus, toasts, and Modal’s expanded read-more dialog render into the app’s floating portal root rather than directly under the document body. ContextMenu and VerticalMenu reach it through the tooltip popup that hosts them, which is why they are anchored with the tooltip API rather than positioned by hand.
App mounts that root inside its content root, as an absolutely positioned, full-bleed layer stacked above page content; it does not capture pointer events itself, so only the overlays it hosts are interactive. useFloatingPortalRoot() returns the nearest provided root and falls back to document.body when there is none. FloatingPortalRootProvider lets you scope a different root when embedding the toolkit inside another surface.
This shared root gives overlays three important properties:
  • They inherit the same theme custom properties.
  • They use the application’s coordinate space, which keeps anchors correct when the app does not fill the browser viewport.
  • They use the application’s compositing context, which keeps blend modes and additive-light display rendering consistent on the device.
Recommended practices:
  • Anchor popup coordinates to the component that owns the popup.
  • Treat popup direction as a preference. Custom popup content such as a menu flips when space is tight, while a built-in text tooltip keeps the requested side and clamps within the bounds.
  • Clamp overlays to the application’s content area rather than the browser window.

Animation practices

Prefer browser-native animation primitives:
  • CSS transforms and opacity.
  • CSS custom properties written at transition boundaries.
  • Stable SVG elements with animated transforms.
  • useSpringAnimation when you need a physical curve, writing to the DOM from onChange.
Avoid:
  • Per-frame React state for visual animation.
  • Remounting animated layers on focus change.
  • Rewriting SVG geometry attributes every frame when a transform would do.
  • Separately timed child animations that should be one synchronized transition.
Source timing from AnimationDurations, easing from Interpolators, and spring parameters from SpringConfigs. Build CSS transition strings with createTransition to keep each duration paired with its easing.
Reduced-motion preferences must be honored for accessibility. usePrefersReducedMotion() reports the setting, reducedMotionDuration(durationMs, prefersReduced) collapses a JavaScript-driven duration to zero, and useSpringAnimation snaps to its target when reduced motion is active. CSS-driven components must also use @media (prefers-reduced-motion: reduce).