ContainerMaterial from the exported layer classes, layer factories, and canvas drawing primitives. This extension point preserves material behavior only when the result covers every visual state.MaterialLibrary factory.MaterialLibrary.themedPrimary(theme).ContainerMaterial only when the visual behavior genuinely needs a different layer model.MaterialLibrary.themedPrimary(theme) for color changes. A new layer system adds state and performance work without changing the surface role.import { useMemo, type ReactNode } from 'react';
import {
Container,
ContainerMaterial,
ContainerMaterialLayer,
LayerPlacement,
VisualState,
createGlowStrokeLayer,
createIdleMaterialLayer,
createPressedMaterialLayer,
fillEllipticalRadialGradient,
getInsetAdjustedShaderHeight,
shiftedX,
shiftedY,
type CanvasLayerDrawParams,
type GradientStop,
} from '@wearables-ui-toolkit/mrbd';
export interface BloomPalette {
focusCore: string;
focusFalloff: string;
}
/** A soft focus bloom rising from the lower edge of the surface. */
class FocusBloomLayer extends ContainerMaterialLayer {
private readonly stops: readonly GradientStop[];
constructor(palette: BloomPalette) {
super('focus-bloom', LayerPlacement.BACKGROUND, {
sortOrder: 100,
supportsDiscretePartialFocus: true,
alphaForState: (state) =>
state === VisualState.FOCUSED || state === VisualState.PRESSED ? 1 : 0,
});
this.stops = [
{ offset: 0, color: palette.focusCore },
{ offset: 1, color: palette.focusFalloff },
];
}
protected paint(
ctx: CanvasRenderingContext2D,
params: CanvasLayerDrawParams,
alpha: number,
): void {
const { width, height, partialFocusPosition, shapeContext } = params;
fillEllipticalRadialGradient(ctx, {
path: params.path,
width,
height,
cx: shiftedX(0.5, width, partialFocusPosition),
cy: shiftedY(0.9, height, partialFocusPosition),
radius: 0.8 * getInsetAdjustedShaderHeight(height, shapeContext),
scaleX: 1.6,
stops: this.stops,
alpha,
blend: this.blendMode,
});
}
}
export function BloomTile({
children,
palette,
onOpen,
}: {
children: ReactNode;
palette: BloomPalette;
onOpen: () => void;
}) {
const material = useMemo(
() =>
new ContainerMaterial({
layers: [
createIdleMaterialLayer(),
new FocusBloomLayer(palette),
createPressedMaterialLayer(),
createGlowStrokeLayer(),
],
}),
[palette],
);
return (
<Container
material={material}
focusable
clickable
onClick={onOpen}
className="bloom-tile"
>
{children}
</Container>
);
}
.bloom-tile {
padding: var(--uit-spacing-medium);
color: var(--uit-color-text-primary);
}
paint; the layer does not read the transition, start a timer, or touch the DOM.createLayer(id, placement, render, options?) wraps a render closure as a layer. Use it for effects that are naturally CSS: the returned properties are applied to a dedicated layer element that is clipped to the shape path. Because it is a real DOM element, it can read semantic tokens directly.import { useMemo, type ReactNode } from 'react';
import {
AnimationDurations,
Container,
ContainerMaterial,
LayerPlacement,
VisualState,
createLayer,
createTransition,
} from '@wearables-ui-toolkit/mrbd';
export function StatusTile({ children }: { children: ReactNode }) {
const material = useMemo(
() =>
new ContainerMaterial({
layers: [
createLayer(
'base',
LayerPlacement.BACKGROUND,
() => ({
background: 'var(--uit-color-background-elevation1)',
}),
{ sortOrder: 0 },
),
createLayer(
'interaction',
LayerPlacement.BACKGROUND,
(state, animated) => {
const isFocused = state === VisualState.FOCUSED;
const isPressed = state === VisualState.PRESSED;
return {
background: isPressed
? 'var(--uit-color-interactive-fill-pressed)'
: 'var(--uit-color-background-elevation3)',
opacity: isFocused || isPressed ? 1 : 0,
transition: animated
? createTransition(
'opacity',
AnimationDurations.CONTAINER_STATE_CHANGE,
)
: 'none',
};
},
{ sortOrder: 100 },
),
],
}),
[],
);
return (
<Container material={material} focusable clickable className="status-tile">
{children}
</Container>
);
}
.status-tile {
padding: var(--uit-spacing-medium);
color: var(--uit-color-text-primary);
}
VisualState and an animated flag, so it can attach its own transition only when animation is enabled. A layer created this way uses the CSS path unless you also pass drawCanvas, in which case the canvas path wins and render is ignored.render returns: the shape clipPath, unless the layer sets clipsToShape: false; mixBlendMode, whenever the layer declares a blendMode other than source-over; and pointerEvents: 'none'. Returning any of the three from render has no effect.createLayer for a simple stateless effect. Use a class when the layer owns mutable state, caches, continuous animation, or path-derived resources.createLayer and NoOpContainerMaterialLayer accept the full ContainerMaterialLayerOptions.| Option | Type | Default | Description |
|---|---|---|---|
sortOrder | number | DEFAULT_SORT_ORDER | Z-order inside the placement group. |
alphaForState | (state: VisualState) => number | fully opaque | Per-state target alpha, resolved across the active transition before painting. A resolved alpha of zero skips the paint entirely. |
blendMode | GlobalCompositeOperation | – | Blend against the layer’s backdrop. |
clipsToShape | boolean | true | Whether the layer is clipped to the shape path. |
supportsDiscretePartialFocus | boolean | see below | Whether the layer receives a live partial-focus offset. |
animatesContinuously | boolean | false | Runs the host’s animation-frame loop and supplies an advancing time. |
onStateChanged | (oldState, newState) => void | – | Called when the material’s visual state actually changes. |
drawCanvas | DrawCanvasFn | – | Canvas draw function. Meaningful for createLayer; layer classes bind their own. |
id, placement, sortOrder, and alphaForState; most add blendMode; the layers that can follow focus add supportsDiscretePartialFocus. Subclass ContainerMaterialLayer directly when you need onStateChanged, animatesContinuously, or a clipsToShape a typed class does not offer.supportsDiscretePartialFocus defaults to true on the ContainerMaterialLayer base, but each typed class picks the default that suits its effect: radial gradient fills and glow strokes opt in, while solid fills, gradients on a fixed axis, drop shadows, inner shadows, noise, images, and no-op layers opt out. createLayer defaults to false. Layers that opt out are handed a zero offset, so opting out is inert rather than merely ignored.clipsToShape: false, glow strokes default to MAX_SORT_ORDER and overlay blending, inner shadows default to screen, and noise defaults to overlay. Most classes default to LayerPlacement.BACKGROUND. The blurred radial gradient and nine-patch overlay default to LayerPlacement.FOREGROUND; the nine-patch overlay also takes its own clipToShape flag, which is off by default and drives clipsToShape.id — NoOpContainerMaterialLayer is the exception and takes positional arguments.| Class | Config type | Effect |
|---|---|---|
SolidColorContainerMaterialLayer | SolidColorLayerConfig | Solid fill of the shape path. The color’s own alpha is multiplied by the resolved alpha. |
LinearGradientContainerMaterialLayer | LinearGradientLayerConfig | Linear-gradient fill between configured points, defaulting to top-left to bottom-right. Set fillRect to fill the layer rectangle instead of the shape path, which also opts the layer out of shape clipping. |
AnimatableLinearGradientContainerMaterialLayer | LinearGradientLayerConfig | The same, plus animatedStops and animatedAngleDeg fields that override the config at runtime. resetAnimationProperties() clears them. |
RadialGradientContainerMaterialLayer | RadialGradientLayerConfig | Elliptical radial-gradient fill positioned by sizeMultiplier and placementMultiplier, both fractions of the bounds, optionally using the inset-adjusted height. |
AnimatableRadialGradientContainerMaterialLayer | RadialGradientLayerConfig | The same, plus animatedPositionX, animatedPositionY, and animationScale fields layered on top of the configured multipliers. |
BlurredRadialGradientContainerMaterialLayer | BlurredRadialGradientLayerConfig | Pre-rendered blurred elliptical gradient, clipped to the host shape and placed in the foreground by default. Equivalent configurations share a bounded bitmap cache, so partial-focus updates translate the cached light instead of recomputing its blur. |
DropShadowContainerMaterialLayer | DropShadowLayerConfig | Outer shadow halo behind the shape, from color, blur, offsetX, and offsetY. Never clipped to the shape. |
InnerShadowContainerMaterialLayer | InnerShadowLayerConfig | Inner glow or shadow strongest at the edge, fading inward. screen blend by default. Supports blurRadius, sigmaScale, offsetX, offsetY, spread, and intensity. |
NoiseContainerMaterialLayer | NoiseLayerConfig | Tiled texture fill scaled to tileSize, overlay blend by default. Uses the shared container noise texture unless imageUrl is set. |
ImageContentContainerMaterialLayer | ImageContentLayerConfig | Image clipped to the shape, with fit of cover (the default) or fill and an optional blurPx. Paints nothing until the image decodes. |
NinePatchImageOverlayContainerMaterialLayer | NinePatchImageOverlayLayerConfig | Nine-patch overlay: an image whose one-pixel marker border declares the stretchable runs, so fixed regions keep their size as the host resizes. Falls back to a plain stretched draw when the markers cannot be read. |
BaseGlowStrokeContainerMaterialLayer | BaseGlowStrokeLayerConfig | Abstract base for glowing borders. Strokes the inset path, defaults to MAX_SORT_ORDER and overlay blend, and resolves strokeWidthForState across the transition. Extend it; it cannot be instantiated. |
LinearGradientGlowStrokeContainerMaterialLayer | LinearGradientGlowStrokeLayerConfig | Glow stroke painted with a linear gradient. |
RadialGradientGlowStrokeContainerMaterialLayer | RadialGradientGlowStrokeLayerConfig | Glow stroke painted with a focus-following elliptical radial gradient. A single-stop config degenerates to a solid stroke, which is how a plain border is expressed. |
NoOpContainerMaterialLayer | (id, placement?, options?) | Draws nothing and always resolves to zero alpha. Keeps a semantic layer slot occupied when a derived material should stay transparent in that role. |
| Helper | Purpose |
|---|---|
LayerValue<T> | A config field that is either a constant T or (params) => T, so a material can resolve colors, stops, or image URLs per frame. |
resolveLayerValue(value, params) | Reads a LayerValue<T>. |
RadialMultiplier | { x, y } fractions of width and height, used for radial size and placement. |
RadialGeometry | { cx, cy, radius, scaleX } — the resolved ellipse. |
computeEllipticalRadialGeometry(params, sizeMultiplier, placementMultiplier, useInsetAdjustedHeight, partialFocusScale?) | Produces that geometry, including the partial-focus shift. partialFocusScale defaults to 1; glow strokes pass a half-magnitude scale. |
LinearGradientPoints, LinearGradientPointProvider | The { x0, y0, x1, y1 } endpoints and the (width, height, params) => points function that supplies them. |
BlurredRadialGradientContainerMaterialLayer when focus illumination needs to move independently above stationary fills, texture, and border layers. Its default white-to-transparent ellipse uses radii of 90 / 112 and 57 / 112, a center at { x: 0.5, y: 0 }, and a blur radius of 32 logical pixels. Set alphaForState to expose it only in the intended visual states. Keep underlying layers opted out of partial focus when only the lighting origin should travel. This is an opt-in composition; neither Container nor MaterialLibrary.default() adds the layer automatically.| Factory | Layer |
|---|---|
createIdleMaterialLayer() | Solid idle surface fill, hidden in NONE. |
createFocusedMaterialLayer() | Four-stop focus radial gradient, visible in FOCUSED and PRESSED, tracks partial focus, uses the inset-adjusted height. |
createInnerShadowLayer(color?) | Inner glow with screen blend, visible in FOCUSED and PRESSED. Defaults to the standard tint. |
createNoiseMaterialLayer() | Tiled noise with overlay blend, visible in FOCUSED and PRESSED. |
createPressedMaterialLayer(color?) | Press overlay, visible only in PRESSED. Defaults to the standard tint. |
createGlowStrokeLayer(alphaForState?) | The standard glow stroke. Pass an alpha policy to hide it in chosen states so it fades rather than pops. |
defaultGlowStrokeAlpha(state) and defaultGlowStrokeWidth(state) are the default per-state policies — compose with them rather than replacing them when you only need to suppress certain states — drawDefaultGlowStrokeCanvas(ctx, paintAlpha, strokeWidth, params) paints the stroke on a canvas with the alpha and width already resolved, and renderDefaultGlowStrokeStyles(opacity, strokeWidth, transition?) returns the equivalent CSS properties for a CSS layer.ContainerMaterialLayer for a reusable effect. The base class is a template: it resolves the layer’s alpha across the active transition, skips painting at zero alpha, and calls paint(ctx, params, alpha). It also binds drawCanvas for you, so a subclass plugs into the canvas host with no wiring.import {
ContainerMaterialLayer,
LayerPlacement,
fillSolid,
type CanvasLayerDrawParams,
} from '@wearables-ui-toolkit/mrbd';
export class TintLayer extends ContainerMaterialLayer {
constructor(private readonly color: string) {
super('tint', LayerPlacement.BACKGROUND, { sortOrder: 75 });
}
protected paint(
ctx: CanvasRenderingContext2D,
params: CanvasLayerDrawParams,
alpha: number,
): void {
fillSolid(ctx, params.path, this.color, alpha, this.blendMode);
}
}
paint:alphaForState(state) for intrinsic visibility rules that no caller should be able to override.isVisibleForState(state) so the host can pause its animation-frame loop when a continuously animating layer is invisible. The base implementation reports whether the resolved alpha is above zero.invalidatePathCaches() is the declared hook for dropping path-derived bitmaps. It is a no-op on the base class and nothing in the library calls it, so do not rely on being told when geometry changes: key every cached bitmap on the geometry it was built from, including getShapeProviderId(params.shapeContext.shapeProvider). That is what InnerShadowContainerMaterialLayer does with the shapeKey it hands to drawInnerGlow.clone() when the layer holds mutable state the base copy cannot duplicate. The base clone deep-copies plain objects, arrays, Maps, and Sets the layer owns, and shares anything with a non-plain prototype.CanvasLayerDrawParams is valid only for one draw call and must not be retained. The context is already scaled to the device pixel ratio, with the layer origin at (0, 0) in logical pixels.| Field | Description |
|---|---|
state | Resolved current visual state — the target of any active transition. |
transition | Optional MaterialStateTransition with from, to, and progress. Absent at rest. |
path | Shared outer shape Path2D in logical pixels, origin at the top-left. |
width, height | Layer area in logical pixels. |
shapeContext | MaterialShapeContext carrying the shape provider and the material inset. |
partialFocusPosition | PartialFocusPosition, each axis in the range -0.5 to 0.5. Zero for layers that do not opt in. |
dpr | Device pixel ratio the context is already scaled to; pass it on to primitives that allocate offscreen passes. |
time | Monotonic timestamp in milliseconds, meaningful only for continuously animating layers. |
assets | CanvasMaterialAssets. |
lerpState(valueForState) | Interpolates a per-state numeric property across the active transition, and returns valueForState(state) at rest. |
getInsetStrokePath(strokeWidth) | The inset stroke Path2D, already translated into place. |
CanvasMaterialAssets has two members: noise, the shared container noise texture or null until it decodes, and getImage(url), which lazily loads and caches an image and returns null until it decodes. A layer must handle the null case by drawing nothing; the load triggers a redraw, so the layer paints on a later frame. DrawCanvasFn is the type of the draw call itself, (ctx, params) => void.alpha returns without painting when it is zero, and the stroke helpers do the same for a zero stroke width. drawDropShadow is the exception: it has no alpha parameter, so set globalAlpha on the context yourself if you need to fade it.| Helper | Operation |
|---|---|
fillSolid(ctx, path, color, alpha, blend?) | Solid fill of the path. |
fillLinearGradient(ctx, params) | Linear-gradient fill of the path. |
fillLinearGradientRect(ctx, params) | Linear-gradient fill of an explicit rectangle. |
fillEllipticalRadialGradient(ctx, params) | Elliptical radial-gradient fill, clipped to the path so the outline is never distorted. |
fillNoise(ctx, params) | Repeating, scaled texture fill of the path, using a cached pattern. |
drawImageClipped(ctx, params) | Image clipped to the path, with cover or fill and an optional blur. |
drawWithCanvasBlur(ctx, sigma, draw) | Blur a custom canvas draw. Uses the native canvas filter where available and a software fallback where it is not. Pass the drawing commands to draw, which receives a context with the same transform. Returns false if the fallback must draw without a blur (for example, when a cross-origin image taints the offscreen canvas); do not cache that result as a successful blur. |
drawInnerGlow(ctx, params) | Inner glow strongest at the edge. Pass a shapeKey to engage the shared bitmap cache. |
drawDropShadow(ctx, params) | Shadow halo outside the path; the interior is punched back out. |
strokeSolid(ctx, params) | Solid stroke of an inset path. |
strokeLinearGradient(ctx, params) | Linear-gradient stroke of an inset path. |
strokeEllipticalRadialGradient(ctx, params) | Elliptical radial-gradient stroke of an inset path, rasterized through a pooled offscreen pass. Requires dpr. |
GradientStop is { offset, color }; BlendMode is the canvas composite operation type. Stroke helpers expect the inset path from params.getInsetStrokePath(strokeWidth), never a stroke centered on the outer boundary. drawWithCanvasBlur takes sigma in canvas pixels, so multiply a blur specified in logical pixels by params.dpr. Safari does not support ctx.filter for Canvas 2D; use this helper rather than setting ctx.filter directly in a custom material if the blur must render there. Keep the callback independent of the original context’s fill and clip state because the software fallback can draw onto a separate canvas before compositing into the original clip.getPath2DForPathD(pathD) builds and caches a Path2D from an SVG path string, keyed on the string so identical geometry shares one object, and getInsetStrokePath2D(width, height, strokeWidth, shapeContext) resolves the inset stroke path for a shape context.drawInnerGlow caches its blurred bitmap on geometry, color, blur, offset, spread, and device pixel ratio — never on alpha or intensity — so a focus transition recomputes the blur zero times. That cache only engages when you pass a shapeKey, and the key must include getShapeProviderId(shapeProvider) so changing shapes invalidates it.| Helper | Purpose |
|---|---|
interpolate(from, to, progress) | Linear interpolation between two numbers. |
shiftedX(baseX, width, partialFocusPosition?) | Horizontal origin in pixels, shifted by the normalized focus offset. |
shiftedY(baseY, height, partialFocusPosition?) | Vertical origin in pixels, shifted by the normalized focus offset. |
getInsetAdjustedShaderHeight(height, shapeContext?) | Height reduced by the material’s top and bottom insets, so gradient radii stay consistent under an inset. |
createLayer can use --uit-color-* variables directly.NONE, DEFAULT, FOCUSED, PRESSED, a disabled host, and the partial-focus extremes. Focus and press must stay legible without obscuring content, and a disabled host should use the supported opacity treatment.clone() correctly. Sharing caches or mutable state across instances breaks the single-owner contract, and a layer without clone() makes the ContainerMaterial constructor throw.isVisibleForState accurately for continuously animating layers, or the animation-frame loop keeps running while nothing is visible. The host also pauses the loop while the surface is scrolled out of the viewport, so drive the effect from the supplied time rather than from a frame counter — frames are skipped, and time jumps forward when the loop resumes.id and an explicit sortOrder, so ordering stays predictable.