Shape providers

Updated: Sep 24, 2026
Use a shape provider to supply the outline of a surface. The host measures its own rendered border box, asks the provider for an SVG path, and hands that single path to every material layer and to content clipping. Materials own paint; shape providers own geometry. The two are independent: the same material can be mounted on a pill, a rounded rectangle, or a custom outline without changing a layer.

Shape provider contract

import type {
  ShapePathParams,
  ShapeProvider,
  StrokePathParams,
} from '@wearables-ui-toolkit/mrbd';
MemberSignaturePurpose
getShapePath
(params: ShapePathParams) => string
The outer fill outline, as an SVG path d string.
getStrokePath
(params: StrokePathParams) => string
The border outline, already inset so a stroke of the given width sits inside the fill.
getCssBorderRadius?
() => string
Optional CSS border-radius fallback used before measured geometry is available.
ShapePathParams is { width, height }; StrokePathParams adds strokeWidth. All three values are logical (CSS) pixels for the current rendered size.
Requirements for an implementation:
  • Return a closed path (Z) in the layer’s own coordinate space, with the fill outline anchored at the origin. Layers draw against this path directly; nothing re-centers it for you.
  • In getStrokePath, inset the outline by half the stroke width on every side and reduce corner rounding by the same amount. A stroke centered on the outer boundary spills half its width outside the fill and reads as a fringe.
  • Clamp defensively. width - strokeWidth can go negative on a small surface, and a corner radius larger than half the short side has to collapse.
  • Be immutable. Paths are cached per provider instance, so a provider whose configuration mutates will keep serving stale geometry. Construct a new provider when the shape changes.
  • Be size-agnostic. Surfaces size themselves from available space, so a provider is re-asked for a path on every measured size change and must produce a sensible outline at any aspect ratio.

Built-in providers

ProviderConstructorNotes
RoundedRectangleShapeProvider
(cornerRadius = CornerRadius.LARGE, smoothing = 0.75)
Smooth (squircle) rounded rectangle. Exposes readonly cornerRadius and smoothing, and implements getCssBorderRadius.
TailShapeProvider
(direction = 'right', cornerRadius = CornerRadius.MEDIUM, smoothing = 0.75)
Rounded rectangle with a speech-bubble tail. direction is a ShapeTailDirection: 'none', 'left', or 'right'; 'none' degenerates to the plain rounded rectangle. Exposes readonly direction, cornerRadius, and smoothing. Does not implement getCssBorderRadius, so the pre-measurement frame has square corners.
smoothing controls how far the corner curve extends along each edge before it becomes a circular arc: 0 is exactly a CSS border-radius corner, 1 is maximally smoothed, and 0.75 is the system default. See Smooth Corners.

Corner radius tokens

CornerRadius is a branded numeric token type, not a free number. Use a named token so surfaces stay on the shared radius scale:
CornerRadius.XXSMALL, XSMALL, SMALL, MEDIUM, LARGE, XLARGE, XXLARGE, XXXLARGE, and FULL.
FULL renders as a pill: the outline collapses to a stadium (or a true circle on a square surface) regardless of the requested smoothing. See Corner Radius for which token belongs to which surface role.

Attaching a provider to a host

Container, StaticContainer, and IconWithContainerMaterial all take a shapeProvider prop. Components built on Container — Button, Card, Panel, ListItem, and the rest — inherit the same prop and supply the provider their role calls for, so overriding it is a deliberate act. When the prop is omitted, the host falls back to a smooth rounded rectangle with the large corner-radius token.
A provider is an object with caches attached to its identity, so memoize it exactly like a material:
import { useMemo } from 'react';
import {
  Container,
  CornerRadius,
  MaterialLibrary,
  RoundedRectangleShapeProvider,
} from '@wearables-ui-toolkit/mrbd';

export function CompactTile({ label }: { label: string }) {
  const shapeProvider = useMemo(
    () => new RoundedRectangleShapeProvider(CornerRadius.SMALL),
    [],
  );
  const material = useMemo(() => MaterialLibrary.default(), []);

  return (
    <Container
      material={material}
      shapeProvider={shapeProvider}
      focusable
      clickable
      className="compact-tile"
    >
      <span className="uit-text-body1">{label}</span>
    </Container>
  );
}
.compact-tile {
  padding: var(--uit-spacing-medium);
  color: var(--uit-color-text-primary);
}

Behavior

One provider feeds five things at once:
  • Fill paths. The host builds a Path2D from getShapePath and passes it to every layer as params.path. Fills, gradients, textures, and images all paint through it.
  • Layer clipping. Canvas layers are clipped to that path unless the layer sets clipsToShape: false. CSS layers are clipped to the same outline under the same rule.
  • Stroke paths. Stroke layers call params.getInsetStrokePath(strokeWidth), which resolves through getStrokePath and returns a Path2D already translated into place. Stroke it directly; do not apply your own transform.
  • Content clipping. When the host clips its content (the default on Container and StaticContainer), it clips to the same fill path, so content and material can never disagree about the edge.
  • The pre-measurement fallback. Until a size has been measured, the host has no path and applies getCssBorderRadius() as a plain CSS border-radius. A provider that omits the method falls back to square corners for that first frame, which is the right answer for a shape CSS cannot express and the wrong one for a shape it can.
The provider also reaches layers directly: params.shapeContext is a MaterialShapeContext carrying the active shapeProvider plus the material’s resolved inset, which is what getInsetAdjustedShaderHeight uses to keep gradient radii consistent under an inset.

Caching and provider identity

Fill paths are cached per provider instance keyed by size; stroke paths are cached by size plus stroke width. Each provider keeps a small bounded cache, so a handful of instances shared across many surfaces is far cheaper than a new instance per render. The generated path string is then converted to a Path2D through a second shared cache, keyed on the string itself, so identical geometry across components shares one Path2D.
getShapeProviderId(provider) returns a stable number for a provider instance, assigned lazily on first call and held weakly. Layers that cache path-derived work — a blurred inner-shadow bitmap, for example — mix it into their cache key so switching shapes invalidates the cached bitmap while a pure alpha change does not. Include it in the key of any custom layer that caches by geometry.

A custom provider

Implement ShapeProvider when the outline itself is the point — not to imitate another component’s look. Below, a chamfered rectangle: corners cut at 45 degrees instead of rounded, with a correctly inset stroke path and an honest CSS fallback (CSS cannot express a chamfer, so the fallback is square).
import {
  CornerRadius,
  type ShapePathParams,
  type ShapeProvider,
  type StrokePathParams,
} from '@wearables-ui-toolkit/mrbd';

/** Rectangle whose corners are cut at 45 degrees. Immutable by contract. */
export class ChamferShapeProvider implements ShapeProvider {
  constructor(private readonly cut: number = CornerRadius.SMALL) {}

  getShapePath({ width, height }: ShapePathParams): string {
    return this.buildPath(0, 0, width, height, this.cut);
  }

  getStrokePath({ width, height, strokeWidth }: StrokePathParams): string {
    const half = strokeWidth / 2;
    return this.buildPath(
      half,
      half,
      Math.max(0, width - strokeWidth),
      Math.max(0, height - strokeWidth),
      Math.max(0, this.cut - half),
    );
  }

  getCssBorderRadius(): string {
    return '0';
  }

  private buildPath(
    x: number,
    y: number,
    width: number,
    height: number,
    cut: number,
  ): string {
    const c = Math.max(0, Math.min(cut, Math.min(width, height) / 2));
    return [
      `M ${x + c},${y}`,
      `L ${x + width - c},${y}`,
      `L ${x + width},${y + c}`,
      `L ${x + width},${y + height - c}`,
      `L ${x + width - c},${y + height}`,
      `L ${x + c},${y + height}`,
      `L ${x},${y + height - c}`,
      `L ${x},${y + c}`,
      'Z',
    ].join(' ');
  }
}
Mount it exactly like a built-in provider:
import { useMemo, type ReactNode } from 'react';
import { Container, MaterialLibrary } from '@wearables-ui-toolkit/mrbd';
import { ChamferShapeProvider } from './ChamferShapeProvider';

export function ChamferedTile({ children }: { children: ReactNode }) {
  const shapeProvider = useMemo(() => new ChamferShapeProvider(), []);
  const material = useMemo(() => MaterialLibrary.default(), []);

  return (
    <Container
      material={material}
      shapeProvider={shapeProvider}
      focusable
      clickable
      className="chamfered-tile"
    >
      {children}
    </Container>
  );
}
Every layer of the standard material now fills, strokes, and clips to the chamfer, and so does the container’s content, with no change to the material.
If you only need smooth rounded geometry with different parameters, build the path with getCachedSmoothRoundedRectPath instead of writing path commands by hand; it returns the same cached strings the built-in providers use.
  • Pick a named CornerRadius token so the surface stays on the shared radius scale.
  • Memoize one provider instance per host, or hoist a stateless provider to module scope and share it, so path caches remain effective.
  • Inset the stroke path by half the stroke width and shrink the corner rounding to match, keeping the stroke inside the fill.
  • Verify a custom shape at small, wide, tall, and near-square sizes, in every visual state, over the additive-light display.

Avoid

  • Do not mutate a provider when its configuration changes. Providers are immutable because their identity owns path caches; create a new provider.
  • Do not allocate providers during render; inline allocation invalidates geometry caches on every pass.
  • Do not use custom shapes to imitate unsupported components or bypass public variants. Reserve them for genuinely new geometry.
  • Do not assume fixed dimensions. Derive all geometry from the supplied width and height so surfaces remain responsive.