Component authoring

Updated: Sep 24, 2026
Prefer composing the components the toolkit already provides. They own behavior that keeps focus, press feedback, materials, and motion consistent across the application. Create a component when no existing component represents the needed semantic role, and build it on the public foundation rather than raw DOM when that role is interactive.
Start by defining the component’s purpose in the interface, then define its anatomy. Its purpose determines the appropriate base, public props, and accessibility semantics.

Choose a base

Choose the narrowest base that owns the behavior the component needs:
BaseRecommended useToolkit responsibilitiesComponent responsibilities
An interactive component that draws its own visuals without a material.
Focus ownership, press and long-press, activation, disabled behavior, tooltips, partial-focus handoff, polymorphic as.
The complete appearance.
An interactive component that needs the standard focused and pressed material.
Everything from InteractableBase, plus material rendering, shape-driven clipping, and content-scale animation between states.
Layout and content inside the container.
A non-interactive surface that still needs a material.
A material in a fixed visual state, smooth corners, and optional coordinated layout transitions.
Non-interactive layout and content. It has no focus or press behavior.
A quiet informational backdrop rather than a control.
A non-focusable, non-pressable, and non-clickable Container, with reduced-opacity panel material and no content-scale breathing.
Layout and content inside the panel.
A plain, semantically sized box with a named corner radius.
InteractableBase behavior and a background using a SurfaceCornerRadius case.
Content layout.
A plain element
Pure layout or text.
No toolkit behavior, which is appropriate for this case.
All behavior and styling; keep the element focused on one purpose.
Keep one action represented by one focus target. An interactive component must not be wrapped in another focusable element because nested focus targets make directional navigation and activation ambiguous.
Prefer an existing component when it already performs the semantic job, even if the desired content differs. Avoid rebuilding Button, ListItem, or Card from Container only to change its appearance. Likewise, avoid assembling primitives to imitate a component that the toolkit does not provide, because the result will not inherit that component’s expected behavior.
For a non-interactive component that is effectively a div with additional behavior, extend UITCommonProps. It includes standard DOM attributes and typed data-uit-* keys, so the component does not need its own attribute allowlist.

Own intrinsic size

Let the component measure to its content and adapt to its parent. Applications built with the toolkit should not assume a particular viewport size.
  • Prefer natural sizing from text and icons, with constraints supplied by the parent. Container, StaticContainer, and Surface default width and height to automatic sizing; set a dimension only when the design fixes it.
  • Use semantic tokens for spacing, radii, typography, and color, such as var(--uit-spacing-large), so the component follows system-wide visual updates. Avoid literal values for these properties.
  • Size relative to the container rather than the screen. Set min-inline-size: 0 and min-block-size: 0 on flex and grid children so long labels can shrink instead of overflowing.
  • Keep one scroll container per axis. An internal scroller on the same axis as the route scroller creates competing focus-driven scrolling and unclear boundaries.
.stat-tile {
  display: grid;
  gap: var(--uit-spacing-xsmall);
  padding: var(--uit-spacing-medium);
  min-inline-size: 0;
}

Connect a material and shape provider

Start with Container’s standard interactive material. MaterialLibrary.default() builds the same material, and each MaterialLibrary entry returns a fresh instance. Pass material for a different treatment, or use createDefaultContainerMaterial to adjust the standard recipe:
import { useMemo, type ReactNode } from 'react';
import { Container, createDefaultContainerMaterial } from '@wearables-ui-toolkit/mrbd';

export function ShadowedTile({ children }: { children: ReactNode }) {
  const material = useMemo(
    () => createDefaultContainerMaterial({ withDropShadow: true }),
    [],
  );

  return <Container material={material}>{children}</Container>;
}
DefaultContainerMaterialOptions accepts withDropShadow, hideGlowForStates (the visual states that suppress the glow stroke), and a partial config override. Keep the material in useMemo so each mounted component instance owns one. Creating it on every render discards layer caches, and mounting one material instance in two containers at the same time throws. Shape providers are immutable and can be shared.
Geometry remains separate from the material. A ShapeProvider supplies the fill and stroke paths used by the clip and every material layer, keeping the glow, border, and clip aligned:
import { CornerRadius, RoundedRectangleShapeProvider } from '@wearables-ui-toolkit/mrbd';

const tileShape = new RoundedRectangleShapeProvider(CornerRadius.MEDIUM);
RoundedRectangleShapeProvider accepts a CornerRadius case and an optional corner-smoothing factor. TailShapeProvider accepts a ShapeTailDirection, followed by the same two arguments, and creates a tooltip-style shape with a tail. Without shapeProvider, the component uses a rounded rectangle with CornerRadius.LARGE. Use getShapeProviderId when application caching needs a stable provider identity.
For customization beyond the standard recipe, see Custom Materials. Express the visual through a material and shape provider so the toolkit can keep every layer coordinated. Avoid reaching into rendered DOM or generated class names, and avoid adding custom glow or border elements around a toolkit component.

Handle interaction state

Read the toolkit’s explicit interaction state instead of creating a parallel state model.
ExportMembersMeaning
State
DEFAULT, FOCUSED, PRESSED
The user’s current interaction with the control.
VisualState
NONE, DEFAULT, FOCUSED, PRESSED
The material’s rendered state. NONE omits the resting backdrop.
InteractionState
{ state: State; isDisabled: boolean }
The combined value reported by onStateChange.
ContentScaleInsets
per-platform, per-state horizontal insets
The source of content scaling between states.
Platform
'mrbd'
The target used for content-scale calculations.
Use onStateChange only when component content needs to react, such as swapping an icon or changing text emphasis. Let the base own the remaining interaction:
import { State } from '@wearables-ui-toolkit/mrbd';
import type { InteractionState } from '@wearables-ui-toolkit/mrbd';

function handleStateChange(_previous: InteractionState, next: InteractionState) {
  setShowsDetail(next.state === State.FOCUSED);
}
Recommended practices:
  • Prefer disabling a control to unmounting it. disabled keeps the element focusable, preventing holes in directional navigation, and the toolkit applies aria-disabled and disabled opacity.
  • Use the toolkit’s keyboard handling rather than adding another state machine. The base handles Enter, Space, long press, and directional input consistently.
  • Put the component action on onActivate. It fires exactly once per activation and includes the originating keyboard or mouse event. onClick also fires for each real DOM click, including the click dispatched by keyboard activation, so using both handlers for the action invokes it twice.
  • Keep partial-focus handoff and rubber-band feedback enabled by default. Narrow handoff with partialFocusSupportedAxis: PartialFocusSupportedAxis.XY is the default, X and Y restrict it to one axis, and None opts out. Set isRubberbandTranslationEnabled to false when the element itself cannot move. onPartialFocusHandoff and onInvalidFocusDirection let component visuals acknowledge these events; focus movement remains owned by the toolkit.
  • Use Container’s visualStateOverride and materialTransition for driven previews, such as a gallery showing every state. They do not replace real interaction state in an application component.
  • Override visualStateForInteractionStateFn or contentScaleForStateFn only for a component with a genuinely different state mapping, such as a control that omits its resting backdrop until focused.

Expose typed props

Treat the props interface as the component’s public API. Keep it as narrow as the component’s purpose allows.
  • Extend the base props to preserve the standard DOM surface. Use Omit<ContainerProps, 'children'> for an interactive component built on Container, or UITCommonProps for a standalone component.
  • Prefer semantic variants, expressed as named string-union cases, over open-ended styling hooks. For example, a size prop with three cases defines a stable API, while a styleOverrides object exposes implementation details.
  • Accept and merge className and style so the application can position the component without replacing its own styles.
  • Accept ref as a normal prop and forward it to the element callers expect to measure or focus.
  • Keep measurement helpers, layout calculations, and internal state private. Export only the props interface and enums that callers need to name.
  • Document every public prop with a doc comment because callers use the emitted TypeScript declarations as their API reference.
import {
  Container,
  CornerRadius,
  RoundedRectangleShapeProvider,
  TextColor,
  TextStyle,
  TextView,
} from '@wearables-ui-toolkit/mrbd';
import type { ContainerProps } from '@wearables-ui-toolkit/mrbd';
import type { Ref } from 'react';

const statTileShape = new RoundedRectangleShapeProvider(CornerRadius.MEDIUM);

export interface StatTileProps extends Omit<ContainerProps, 'children'> {
  /** Short caption describing the measurement. */
  label: string;
  /** Ref to the tile's root element. */
  ref?: Ref<HTMLDivElement>;
  /** Formatted value to display. */
  value: string;
}

export function StatTile({
  className = '',
  label,
  ref,
  value,
  ...containerProps
}: StatTileProps) {
  return (
    <Container
      {...containerProps}
      ref={ref}
      ariaLabel={`${label}: ${value}`}
      className={`stat-tile ${className}`}
      shapeProvider={statTileShape}
    >
      <TextView textStyle={TextStyle.NUMERAL2}>{value}</TextView>
      <TextView textStyle={TextStyle.META1} textColor={TextColor.SECONDARY}>
        {label}
      </TextView>
    </Container>
  );
}
This component adds two props and forwards the rest. Callers can therefore pass onActivate, disabled, tooltipText, data-uit-*, or any div attribute without additional support from the component.

Accessibility responsibilities

The toolkit supplies interaction mechanics; the component owns its semantics.
InteractableBase and its derived components provide role="button" for clickable div roots, manage the tab index (including removing it when the element is not focusable), derive aria-disabled from disabled, map either the standard prop or ariaLabel alias to aria-label, and pass through aria-valuenow, aria-valuemin, aria-valuemax, and aria-checked.
The component must provide:
  • An accessible name when visible text does not name the control. Derive ariaLabel from the component’s semantic props when possible, as in the example above.
  • The correct role for a control that is not a button, such as a switch, radio, slider, tab, or menu item.
  • Every ARIA state required by that role, synchronized with component state.
  • Text alternatives that distinguish meaningful icons and images from decorative content.
  • A tooltip content description when using rich tooltipContent instead of tooltipText.
Verify the component with Tab and arrow keys. Native tab traversal is preserved across the toolkit by default, and a component must not interrupt it because keyboard and assistive-technology users depend on that path.

Motion and reduced motion

Use exported motion constants so timing stays consistent with the rest of the interface:
ExportUse
AnimationDurations
Named durations, including CONTAINER_STATE_CHANGE, CONTAINER_PRESS_IN, and CONTAINER_PRESS_OUT.
Interpolators
Named cubic-bezier easing strings for those durations.
SpringConfigs
Named spring presets, including SOFT, STANDARD, BOUNCY, and SNAPPY.
createTransition
Builds a CSS transition string from properties, a duration, an interpolator, and an optional delay.
useSpringAnimation
A frame-rate-limited spring controller for imperative animation.
usePrefersReducedMotion
Reads the user’s reduced-motion setting.
reducedMotionDuration
Collapses a duration to zero when reduced motion is preferred.
Avoid raw millisecond values and cubic-bezier strings. Named values keep motion coordinated and make reduced-motion behavior easier to maintain.
import {
  AnimationDurations,
  Interpolators,
  createTransition,
  reducedMotionDuration,
  usePrefersReducedMotion,
} from '@wearables-ui-toolkit/mrbd';

function useDetailTransition() {
  const prefersReducedMotion = usePrefersReducedMotion();
  return createTransition(
    'opacity',
    reducedMotionDuration(
      AnimationDurations.TEXT_OPACITY,
      prefersReducedMotion,
    ),
    Interpolators.TEXT_OPACITY,
  );
}
For CSS-driven animation, add a @media (prefers-reduced-motion: reduce) rule that collapses the animation. useSpringAnimation already snaps to its target when reduced motion is preferred. Its onChange callback runs on the animation hot path, capped at 30 updates per second by default and configurable through frameRate. Write to the DOM or another imperative sink in that callback rather than scheduling React state updates on every frame.

Validation checklist

Validate behavior in the browser, not only the rendered appearance:
  • Exercise default, focused, pressed, disabled, and every variant state.
  • Move directional focus into and out of the component from all four directions, including a boundary that refuses movement.
  • Check partial focus and handoff when the component participates in them.
  • Check scroll boundaries inside a scroller. If the component opens an overlay, also verify dismissal and focus restoration.
  • Confirm keyboard-only operation, an accessible name, and the correct role and state in the accessibility tree.
  • Test reduced motion and a range of available widths and heights, including a space narrower than the content’s natural size.