Async Feedback
Show and announce pending, success and error states on the control that starts the work.
import { AsyncActionOptions, AsyncAnnouncer, AsyncFeedback } from '@youj/motion'
Installation & usage
npm i @youj/motionFor an app already set up with npx @youj/cli init, which installs the peers (@youj/core, @youj/motion, motion and Base UI) and wires the provider. New app? Start at Installation.
Use it when a control starts work that takes a moment and ends in an outcome people need to know: sending, saving, publishing, syncing. The control itself shows the state, so nobody has to look elsewhere for the result. Don't use it for work that finishes instantly, and don't use it for long jobs with progress to report. Show a Progress bar for those. For a finished button, use the UI Button's onAction, which is built on this.
Three pieces make an async control. useAsyncAction runs the work and reports its state, AsyncFeedback is what the control shows, and AsyncAnnouncer says it out loud. Put the feedback inside a useMorphRoot box so the control's width morphs with its label.
import { motion } from 'motion/react'
import { AsyncAnnouncer, AsyncFeedback, useAsyncAction, useMorphRoot } from '@youj/motion'
const labels = { pendingLabel: 'Sending…', successLabel: 'Invited', errorLabel: 'Couldn’t send' }
export function InviteButton({ onInvite }: { onInvite: () => Promise<unknown> }) {
const { state, run } = useAsyncAction(onInvite)
const morph = useMorphRoot(8) // the button's corner radius, in px
return (
<>
<motion.button
type="button"
{...morph}
onClick={run}
aria-busy={state === 'pending' || undefined}
className="relative inline-flex h-8 items-center overflow-hidden bg-fg px-3 text-canvas"
>
<AsyncFeedback state={state} {...labels}>
Send invite
</AsyncFeedback>
</motion.button>
<AsyncAnnouncer state={state} {...labels} />
</>
)
}With a Base UI button, pass the motion element through render: <ButtonPrimitive render={<motion.button {...morph} />} …>.
Description
An invite form with a fail switch, whose action button shows the outcome.
useAsyncAction(onAction) returns the current state and a run function.
- Resolve for success, and reject or throw for an error. A synchronous throw counts as an error, and a plain return value counts as success.
runis ignored while an action is in flight and while a success is showing. The guard is set synchronously, so a double click within one frame can't start two actions. After an error,runretries at once.- It never updates after the component unmounts, and it clears its reset timer.
onStateChangefires on every change, so the rest of the UI can follow: a status dot, a toast, a refetch.
The four states side by side.
idle shows your content. pending swaps it for a spinner. success draws a check beside successLabel and washes the surface in the success color. error draws a cross beside errorLabel over the danger color and shakes once, with a decaying 4, 3 and 2 px amplitude. Press Replay to draw the marks again.
Each swap is a crossfade through MorphLabel: the outgoing state blurs and shrinks a touch while the incoming one resolves, and the box morphs to the new width on the same spring. After resetAfter milliseconds (three deliberate durations by default) the control returns to idle.
A quiet sync control with its own marks and no tint.
Replace any of the three marks with icons, and turn the colored wash off with tint={false} for quieter, secondary controls. The tint needs a relative overflow-hidden parent, so it stays inside the control's shape.
import { CheckCircleIcon, LoaderDotsIcon, RefreshIcon } from '@youj/icons'
<AsyncFeedback
state={state}
tint={false}
icons={{ pending: <LoaderDotsIcon size={16} />, success: <CheckCircleIcon trigger="mount" size={15} /> }}
>
<RefreshIcon size={14} />
Sync
</AsyncFeedback>The default pending mark is a quarter arc on a faint track, turning at a constant rate. For a spinner of your own, use Spinner.
Code
async-feedback-demo.tsx, the Demo story. The component itself comes from the package.
Reference
Propsstate, children, pendingLabel… (11)
Props
AsyncFeedback
| Prop | Type | |
|---|---|---|
stateRequired | AsyncState'idle''pending''success''error' | – |
childrenRequired | ReactNodeWhat the control shows at rest. | – |
pendingLabel | stringShown instead of the spinner when motion is reduced. Default: the | – |
successLabel | stringShown next to the check. Default: the | – |
errorLabel | stringShown next to the cross. Default: the | – |
icons | Partial<Record<'pending' | 'success' | 'error', ReactNode>>Replace the spinner, check or cross. | – |
tint | booleanWash the surface in success / danger while settled. Needs a default true | true |
AsyncAnnouncer
| Prop | Type | |
|---|---|---|
stateRequired | AsyncState'idle''pending''success''error' | – |
pendingLabel | stringDefault: the | – |
successLabel | stringDefault: the | – |
errorLabel | stringDefault: the | – |
useAsyncAction
function useAsyncAction(onAction: () => unknown, options?: AsyncActionOptions): { state: AsyncState; run: () => void }
type AsyncState = 'idle' | 'pending' | 'success' | 'error'| Option | Type | Default | Description |
|---|---|---|---|
resetAfter | number | How long success and error hold before returning to idle, in ms. Defaults to three deliberate durations. | |
onStateChange | (state: AsyncState) => void | Fires on every state change. |
MotionLabel swap, width morph, spinner… · reduced motion
Motion
| Moment | Tokens | ||
|---|---|---|---|
| Label swapcrossfade with a touch of blur and scale | duration.fastease.standard140ms · cubic-bezier(0.2, 0, 0, 1) | 140mscubic-bezier(0.2, 0, 0, 1) | |
Width morphthe useMorphRoot box | spring.snappy0.2s, bounce 0 | 0.2s, bounce 0 | |
| Spinnerone turn | ease.linearcubic-bezier(0, 0, 1, 1) · deliberate × 1.25 | cubic-bezier(0, 0, 1, 1)deliberate × 1.25 | |
| Checkthe stroke draws, then settles | duration.slowease.emphasized340ms · cubic-bezier(0.6, 0, 0.1, 1) | 340mscubic-bezier(0.6, 0, 0.1, 1) | |
Check settlescale, after a base delay | spring.settle0.45s, bounce 0.3 | 0.45s, bounce 0.3 | |
Crosstwo strokes, the second two base steps later | duration.fastease.enter140ms · cubic-bezier(0.16, 1, 0.3, 1) | 140mscubic-bezier(0.16, 1, 0.3, 1) | |
| Error shake4, 3, 2 px and home | duration.slowease.standard340ms · cubic-bezier(0.2, 0, 0, 1) | 340mscubic-bezier(0.2, 0, 0, 1) | |
Tintsuccess or danger wash, opacity | duration.fastease.standard140ms · cubic-bezier(0.2, 0, 0, 1) | 140mscubic-bezier(0.2, 0, 0, 1) | |
Holdsuccess and error, before idle, unless resetAfter is set | no token 3 × deliberate | 3 × deliberate |
Reduced motion
A text label (pendingLabel) replaces the spinner. Swaps and tints are instant, the marks appear already drawn, and errors don't shake.
Success earns the one overshoot in the system: the check settles on the settle spring. Errors stay level. The shake is a short, decaying nudge, never a bounce.
Reduced motion
The control still walks through all four states, and every state still has its words. pendingLabel replaces the spinner, so the pending state is readable without motion, and success and error appear with their marks already drawn. Swaps and tints change instantly and nothing shakes.
AccessibilityKeyboard, ARIA, focus
Accessibility
- Render
AsyncAnnouncernext to the control, never inside it. A button flattens its content into one accessible name, so a live region inside it isn't announced. - The announcer speaks pending and success politely (
role="status") and errors assertively (role="alert"), so a failure interrupts and a success doesn't. - Set
aria-busyon the control while it's pending, as above. The control keeps focus throughout, so keyboard users don't lose their place. - The outgoing label is hidden from assistive technology while it fades, so the control's name is never two labels at once. The spinner, the marks and the tints are
aria-hidden; while pending, the name ispendingLabel("Working…" by default), visually hidden beside the spinner. If you replaceicons.pending, include the label in it. KeepAsyncAnnouncerbeside the control so the state is still spoken.