Second-order motion

Updated: Sep 24, 2026
Derive second-order motion from the state that drives the primary motion. This keeps a following caption, anchored overlay, or focus-responsive list chrome in step with the element it depends on.
Use the same progress value, state change, or scroll offset for both elements. Avoid measuring and repositioning the primary element every frame: layout reads in an animation loop force synchronous layout, and independent loops drift by at least one frame.

Content that follows a material scale

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.
The context passed to the callback:
FieldDescription
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.
Each returned animation:
FieldTypeDescription
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.
When 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);
}
When frame accuracy is not required, 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.

An overlay tracking its anchor

Overlays render into the floating portal root, so they are positioned against their anchor rather than laid out with it. That makes anchoring an explicit decision.
  • 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.
  • When the visual anchor is not the whole element — an icon inside a wide row, a badge in a corner — supply the geometry instead of nudging the overlay. 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.
  • After a change that moves the anchor without animating it, call 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>
  );
}
The provider runs when the overlay positions itself, not on a loop, so the measurement cost is paid once per placement.

List content responding to focus movement

Focus movement already emits everything a dependent element needs. Subscribe to it rather than observing the DOM.
APIWhereWhat 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.
Prefer material-origin feedback to translating an element. Moving an element in a list moves its neighbors’ apparent positions too, which is second-order motion nobody asked for.
Scroll-derived chrome is the common case, and it should be expressed as state that a component then animates for you:
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%;
}
The scroll offset becomes a boolean, the boolean becomes an edge, and Vignette owns the fade. Nothing here animates opacity per scroll event.

Keeping dependent motion stable

  • Keep one same-axis scroller per route. A dependent element that lives in a second scroller on the same axis will drift, because two scroll positions now describe its location.
  • Update dependent state at boundaries. The only place per-frame work belongs is inside the primary animation’s own frame callback.
  • Give the dependent motion a defined resting state for every primary state, including interrupted ones. Handle onCancel as well as onComplete.
  • Keep overlays in the floating portal root so they share the application’s coordinate space and theme.
  • Verify the relationship under rapid directional input, not just single steps. Second-order bugs show up when the primary motion is interrupted repeatedly.
  • Check it with reduced motion on. When the primary change snaps, the dependent element must snap with it and land in the right place.
  • Join a container’s transition with stateChangeAnimations when timing must match exactly, so both effects use one clock.
  • Derive dependent values from shared state such as progress, a state change, or a scroll offset.
  • Supply anchor geometry to overlays instead of correcting their position after placement.
  • Feed state to the component that owns the dependent visual and let it animate.

Avoid

  • Derive from shared state instead of measuring a moving element every frame.
  • Join the primary animation’s timeline rather than running a second loop.
  • Prefer in-place material feedback over translating a list element, which also moves its neighbors’ apparent positions.
  • Use public callbacks and geometry providers rather than private DOM or generated class names.