useSpringAnimation drives values imperatively. It never re-renders the calling component per frame; you receive each value in a callback and write it straight into the DOM.import {useEffect, useRef} from 'react';
import {SpringConfigs, useSpringAnimation} from '@wearables-ui-toolkit/mrbd';
export function CompassNeedle({heading}: {heading: number}) {
const needleRef = useRef<HTMLDivElement>(null);
const spring = useSpringAnimation({
config: SpringConfigs.SOFT,
initialValue: heading,
onChange: value => {
needleRef.current?.style.setProperty('--compass-heading', String(value));
},
});
useEffect(() => {
spring.setTarget(heading);
}, [heading, spring]);
return <div ref={needleRef} className="compass-needle" aria-hidden="true" />;
}
.compass-needle {
inline-size: var(--uit-spacing-xsmall);
block-size: var(--uit-spacing-3xl);
border-radius: var(--uit-corner-radius-full);
background: var(--uit-color-icon-primary);
transform-origin: bottom center;
rotate: calc(var(--compass-heading, 0) * 1deg);
}
heading can change every time a new reading arrives. Each setTarget reuses the spring’s current position and velocity, so the needle never jumps and never restarts.useSpringAnimation(options)onChange once with the current value when it mounts, so the element you are driving is correct on first paint.UseSpringAnimationOptions| Field | Type | Default | Description |
|---|---|---|---|
config | SpringConfig | — | Spring physics. Required. Validated when the controller is created and again whenever the values change. |
initialValue | number | — | Starting value. Required, and must be finite. Read only when the controller is created; to move the spring later, call setTarget. |
onChange | (value: number) => void | — | Required. Receives the initial value and every subsequent animated value. An imperative hot path: write to the DOM, a CSS custom property, a canvas, or another imperative sink. Calling a React state setter here reintroduces a render per frame. |
onRest | (value: number) => void | — | Called once after the spring reaches its target or snaps to it. A snap is silent when the value is already at the target. |
frameRate | number | 30 | Maximum number of value updates per second. Must be finite and positive. |
SpringAnimationController| Member | Signature | Description |
|---|---|---|
getValue | () => number | The most recently emitted value. |
isAnimating | () => boolean | Whether a frame is currently scheduled. false once the spring has settled, and while it is paused or stopped. |
setTarget | (targetValue: number) => void | Retargets the spring, preserving its current velocity. Clears a previous stop(). Throws if the value is not finite. |
stop | () => void | Cancels scheduled frames, holds the current value, and clears velocity. The value stays held — including through config changes — until the next setTarget. |
SpringConfig| Field | Type | Description |
|---|---|---|
stiffness | number | Strength of the pull toward the target. Must be positive. Higher covers the distance faster. |
damping | number | Resistance to motion. Must be zero or greater. Lower relative to stiffness means more overshoot. |
mass | number | Inertia of the animated value. Must be positive. Higher makes the response heavier and slower. |
SpringConfig also declares one optional field, equilibriumMode, which tunes how aggressively the spring is treated as settled. The four general-purpose presets below set it to its most aggressive value: fixed velocity and position thresholds, rather than the default’s thresholds that scale with the values the spring moves between. Over a small range — an opacity, a normalized progress value — that cuts the imperceptible tail short; over a large range, such as an angle in degrees, the fixed position threshold is the stricter one, so the spring runs a little longer. The role-specific configurations omit the field and get the default. Configs you write should supply the three numeric fields and leave it out.SpringConfigs| Preset | Character |
|---|---|
SOFT | Gentle. The least overshoot of the four; good for large or ambient movement. |
STANDARD | The general-purpose choice. Clear overshoot, quick settle. |
BOUNCY | Pronounced overshoot at a moderate speed. Use it when the bounce itself is the message. |
SNAPPY | The same energetic character at much higher stiffness, so it covers the distance and settles fastest. |
| Config | Role |
|---|---|
PROGRESS_VALUE | Determinate progress values. Settles onto the target with a slight overshoot. |
CONTAINER_SCALE_PRESS | Content scale as a container enters the pressed state. Stiff and underdamped, so the scale passes the target and springs back. Container applies this itself. |
CONTAINER_SCALE_RELEASE | Content scale as a container leaves the pressed state. A little looser than the press spring. |
PARTIAL_FOCUS_HANDOFF | The partial-focus handoff between neighboring interactables as focus moves. Nearly critically damped, so the handoff reads as a lean rather than a bounce. |
CHIP_LOADING_SWAP | Chip swapping into and out of its loading presentation. |
CONTROL_TILE_ICON_SWAP | ControlTile swapping its icon. Looser damping, so the incoming icon settles with a visible bounce. |
frameRate times per second, so a display running faster than the spring’s update rate does not multiply work. If a frame is delivered late, the step is clamped to a single interval, so a stalled tab or a dropped frame cannot make the spring jump ahead of where it should be.onChange emits that exact value, and onRest fires once. After that isAnimating() is false and no frames are scheduled until the next setTarget.setTarget snaps straight to the target: onChange emits the target and onRest fires, with no frames scheduled. If the preference turns on while a spring is in flight, the spring snaps immediately. You do not need to branch on usePrefersReducedMotion() for a spring — see reduced motion.requestAnimationFrame is unavailable, the controller snaps to the target instead of failing.NaN frames: mass and stiffness must be positive, damping must be zero or greater, frameRate must be finite and positive, and initialValue and every setTarget value must be finite.SpringConfigs member so related motion stays coherent.onChange to avoid per-frame React renders.setTarget from an event handler or effect whenever the logical target changes, including while another animation is running.stop() when an interaction takes manual control, then setTarget to hand the value back to the spring.onChange; a React state setter there re-renders the subtree on every emitted frame.