Container animates its content scale and its material’s visual-state transition from a single timeline. An app-owned element that has to move with that change should join the same timeline rather than watching the container.stateChangeAnimations is that entry point. It is called with the state change that is about to run and returns animations to run alongside the container’s own. They start together, they are driven by the same clock, and they are cancelled together when a new state change interrupts them.| Field | Description |
|---|---|
prevInteraction / newInteraction | The interaction state before and after the change. |
animated | Whether this change is animating at all. It is false when the user prefers reduced motion, while the page is entering, and during fast scrolling. |
transition | The duration and interpolator the container itself is using. Reuse the duration to stay in step. |
containerSize | The measured width and height of the container, so you do not have to measure it yourself. |
| Field | Type | Description |
|---|---|---|
duration | number | Duration in milliseconds. Required. |
onFrame | (elapsedMs: number, linearProgress: number) => void | Required. Called per frame with the elapsed time and linear progress from zero to one. |
startDelay | number | Offset before this animation begins, while the rest of the transition is already running. |
onStart | () => void | Called when the transition starts. |
onComplete | () => void | Called when this animation finishes. |
onCancel | () => void | Called when the transition is interrupted. Restore a defined resting state here. |
animated is false the returned animations never run — no onStart, no onFrame, no onComplete. Apply the dependent element’s end state directly from the callback so it lands with the container instead of being left behind.transition.duration is the timing of the container’s material transition, and it is also the timing of the content scale for every state change except a press. A press-in or press-out drives the scale with a spring instead, run to the spring’s own settle time, so on those changes reuse the duration to stay in step with the material rather than with the scale.import {useMemo, useRef} from 'react';
import {Container, MaterialLibrary} from '@wearables-ui-toolkit/mrbd';
export function StatusTile({title, caption}: {title: string; caption: string}) {
const material = useMemo(() => MaterialLibrary.default(), []);
const captionRef = useRef<HTMLSpanElement>(null);
return (
<Container
material={material}
focusable
clickable
className="status-tile"
stateChangeAnimations={({transition}) => [
{
duration: transition.duration,
onFrame: (_elapsedMs, linearProgress) => {
captionRef.current?.style.setProperty(
'--caption-reveal',
String(linearProgress),
);
},
},
]}
>
<span className="uit-text-body1">{title}</span>
<span ref={captionRef} className="status-tile-caption uit-text-meta1">
{caption}
</span>
</Container>
);
}
.status-tile {
display: flex;
flex-direction: column;
gap: var(--uit-spacing-xsmall);
padding: var(--uit-spacing-medium);
color: var(--uit-color-text-primary);
}
.status-tile-caption {
opacity: var(--caption-reveal, 0);
}
onScaleChange is the lighter option. It reports the container’s target content scale at each state change — at the boundary, not per frame — which is enough to update state that only needs to know where the container is heading.Tooltip takes an anchorRef and positions itself. By default it positions against the anchor’s unscaled box, so a tooltip does not drift while the anchor plays its focus or press scale.tracksAnchorScale opts into the opposite: the overlay follows the anchor’s live, animating box. Use it when the anchor’s movement is large enough that a fixed position would visibly detach. useTooltip accepts the same option in its show options.tooltipCenterPositionProvider and tooltipTargetRectProvider on any interactable, and centerPositionProvider and targetRectProvider on Tooltip, receive the anchor element and return a point or a rectangle in the anchor’s own coordinate space.refreshPosition() from useTooltip once, at the boundary. Do not poll.import {useCallback, useMemo, useRef} from 'react';
import {Container, MaterialLibrary, TooltipMode} from '@wearables-ui-toolkit/mrbd';
export function BadgedRow({
label,
badge,
badgeDescription,
}: {
label: string;
badge: string;
badgeDescription: string;
}) {
const material = useMemo(() => MaterialLibrary.default(), []);
const badgeRef = useRef<HTMLSpanElement>(null);
const tooltipTargetRectProvider = useCallback((anchor: HTMLElement) => {
const badgeElement = badgeRef.current;
if (badgeElement == null) {
return null;
}
const anchorRect = anchor.getBoundingClientRect();
const badgeRect = badgeElement.getBoundingClientRect();
return {
left: badgeRect.left - anchorRect.left,
top: badgeRect.top - anchorRect.top,
right: badgeRect.right - anchorRect.left,
bottom: badgeRect.bottom - anchorRect.top,
width: badgeRect.width,
height: badgeRect.height,
};
}, []);
return (
<Container
material={material}
focusable
clickable
tooltipText={badgeDescription}
tooltipMode={TooltipMode.FOCUSED}
tooltipTargetRectProvider={tooltipTargetRectProvider}
>
<span className="uit-text-body1">{label}</span>
<span ref={badgeRef} className="uit-text-meta1">
{badge}
</span>
</Container>
);
}
| API | Where | What it reports |
|---|---|---|
onStateChange | Any interactable | The previous and new interaction state, including focus and press. |
onPartialFocusHandoff | Any interactable | Focus moving between neighbors, so a component can render the handoff effect. |
onInvalidFocusDirection | Any interactable | Focus input that could not move in the requested direction. |
partialFocusSupportedAxis | Any interactable | Which axes carry partial-focus feedback: XY, X, Y, or None. |
isRubberbandTranslationEnabled | Any interactable | Whether rubber-band feedback translates the element, or only moves the material’s origin. |
onScroll | ScrollView | The current scroll offset. |
import {useState} from 'react';
import type {ReactNode} from 'react';
import {ScrollView, Vignette, VignetteEdge} from '@wearables-ui-toolkit/mrbd';
export function FadingList({children}: {children: ReactNode}) {
const [scrollTop, setScrollTop] = useState(0);
return (
<div className="fading-list">
<ScrollView onScroll={setScrollTop}>{children}</ScrollView>
<Vignette animate enabledEdges={{[VignetteEdge.TOP]: scrollTop > 0}} />
</div>
);
}
.fading-list {
position: relative;
block-size: 100%;
}
Vignette owns the fade. Nothing here animates opacity per scroll event.onCancel as well as onComplete.stateChangeAnimations when timing must match exactly, so both effects use one clock.