Custom materials

Updated: Oct 9, 2026
Start with the narrowest supported customization that expresses the surface. When no library factory fits, compose a 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.
Work through this list and stop at the first step that fits.
  1. Use the component’s own material.
  2. Choose a MaterialLibrary factory.
  3. Recolor the standard stack with MaterialLibrary.themedPrimary(theme).
  4. Start from the default material and replace or add a small number of layers.
  5. Build a new ContainerMaterial only when the visual behavior genuinely needs a different layer model.
Prefer MaterialLibrary.themedPrimary(theme) for color changes. A new layer system adds state and performance work without changing the surface role.

An end-to-end custom material

This is the whole shape of the job: one custom canvas layer that declares its per-state alpha and reads partial focus, composed with the standard idle, press, and glow-stroke layers, memoized once per host.
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);
}
Let the base class own transition timing. The layer declares its target alpha for each state, and the base class resolves the active transition before calling paint; the layer does not read the transition, start a timer, or touch the DOM.

A closure-based CSS layer

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);
}
The render closure receives the resolved 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.
The host owns three properties on the layer element and applies them over whatever 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.
Use createLayer for a simple stateless effect. Use a class when the layer owns mutable state, caches, continuous animation, or path-derived resources.

Layer options

createLayer and NoOpContainerMaterialLayer accept the full ContainerMaterialLayerOptions.
OptionTypeDefaultDescription
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.
Every other layer class takes its own typed config instead, exposing the subset of these options that makes sense for the effect and fixing the rest. All of them take 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.
Some classes also force an option: drop shadows and glow strokes always set 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.

Layer classes

The typed layer classes are exported from the package root. Each renders one effect and takes a single typed config object whose first field is the layer id — NoOpContainerMaterialLayer is the exception and takes positional arguments.
ClassConfig typeEffect
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.
Alongside the classes, the same entry point exports the shared helpers they are built from:
HelperPurpose
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.
Use 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.

Default-material layer factories

These return the individual layers of the standard material, ready to mix into a custom stack. Each call allocates a fresh layer.
FactoryLayer
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.
The glow-stroke building blocks are exported too: 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.

Writing a layer class

Extend 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);
  }
}
Override points beyond 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.

Draw parameters

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

Canvas primitives

Each helper performs one paint operation on a context that is already scaled to the device pixel ratio and expressed in logical pixels. Every helper that takes an 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.
HelperOperation
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.
Two path helpers back that geometry: 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.

Geometry helpers

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

Colors and gradients

Canvas layers cannot resolve CSS custom properties, so their colors must be resolved strings. Keep them in one application palette module and pass them into material configuration; CSS layers created with createLayer can use --uit-color-* variables directly.
A useful gradient has deliberate stops:
  • start and end with roles that describe the visual purpose;
  • add intermediate stops only to control a meaningful transition;
  • keep stop ordering monotonic and avoid tightly clustered stops that band;
  • build the ramp perceptually by lowering lightness and usually chroma, keeping hue stable, rather than varying alpha on one value;
  • make the deepest steps merge into the surrounding surface so no ring appears;
  • preserve foreground contrast in every interaction state, and check it over the additive-light display against bright and dark real-world backgrounds.
Use real media for media content. Avoid gradients that imitate an image, an unsupported component, or a missing material; they obscure the intended role and do not preserve media behavior.

State coverage

Define a coherent result for every state the host can reach: 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.
A custom material must not change intrinsic dimensions, content alignment, focus bounds, or accessibility semantics.

Performance and correctness

  • Implement clone() correctly. Sharing caches or mutable state across instances breaks the single-owner contract, and a layer without clone() makes the ContainerMaterial constructor throw.
  • Set 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.
  • Reuse cached paths, decoded images, and blur results; do not allocate per frame. A draw call for a continuously animating layer runs on every frame.
  • Do not set React state from a draw callback.
  • Keep layer count and blur sizes modest, and prefer a single canvas surface over opt-in blending with live content or backdrop; each opt-in adds a compositing surface. Some browsers render material blurs in software, so large blurs can also increase CPU work.
  • Re-check the material after a resize or a font settle; the shape path must track the rendered border box.
  • Keep one effect per layer, with a stable id and an explicit sortOrder, so ordering stays predictable.
  • Declare per-state targets and let the host drive one transition so effects stay synchronized.
  • Opt into partial focus only where moving the origin communicates focus.
  • Validate small, wide, and tall bounds, custom shapes, rapid focus and press interruption, reduced motion, and multiple concurrent hosts.

Avoid

  • Do not mutate layers owned by an attached material. Derive a new material to preserve the single-owner contract.
  • Do not accumulate partial-focus offsets frame to frame. Derive the position from the supplied offset.
  • Do not reach into another component’s layers. Use public factories and layer APIs.
  • Do not approve a custom material from one static focused screenshot. Validate the full state set.