Skip to content

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/motion

For 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} />} …>.

Reference

Propsstate, children, pendingLabel… (11)

Props

AsyncFeedback

AsyncFeedback props
PropType
stateRequiredAsyncState
'idle''pending''success''error'
–
childrenRequiredReactNode

What the control shows at rest.

–
pendingLabelstring

Shown instead of the spinner when motion is reduced. Default: the working label, "Working…".

–
successLabelstring

Shown next to the check. Default: the done label, "Done".

–
errorLabelstring

Shown next to the cross. Default: the failed label, "Failed".

–
iconsPartial<Record<'pending' | 'success' | 'error', ReactNode>>

Replace the spinner, check or cross.

–
tintboolean

Wash the surface in success / danger while settled. Needs a relative overflow-hidden parent.

default true

true

AsyncAnnouncer

AsyncAnnouncer props
PropType
stateRequiredAsyncState
'idle''pending''success''error'
–
pendingLabelstring

Default: the working label, "Working…".

–
successLabelstring

Default: the done label, "Done".

–
errorLabelstring

Default: the failed label, "Failed".

–

useAsyncAction

function useAsyncAction(onAction: () => unknown, options?: AsyncActionOptions): { state: AsyncState; run: () => void }

type AsyncState = 'idle' | 'pending' | 'success' | 'error'
OptionTypeDefaultDescription
resetAfternumberHow long success and error hold before returning to idle, in ms. Defaults to three deliberate durations.
onStateChange(state: AsyncState) => voidFires on every state change.
MotionLabel swap, width morph, spinner… · reduced motion

Motion

Motion tokens
MomentTokens
Label swapcrossfade with a touch of blur and scale
duration.fastease.standard

140ms · cubic-bezier(0.2, 0, 0, 1)

140mscubic-bezier(0.2, 0, 0, 1)
Width morphthe useMorphRoot box
spring.snappy

0.2s, bounce 0

0.2s, bounce 0
Spinnerone turn
ease.linear

cubic-bezier(0, 0, 1, 1) · deliberate × 1.25

cubic-bezier(0, 0, 1, 1)deliberate × 1.25
Checkthe stroke draws, then settles
duration.slowease.emphasized

340ms · cubic-bezier(0.6, 0, 0.1, 1)

340mscubic-bezier(0.6, 0, 0.1, 1)
Check settlescale, after a base delay
spring.settle

0.45s, bounce 0.3

0.45s, bounce 0.3
Crosstwo strokes, the second two base steps later
duration.fastease.enter

140ms · 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.standard

340ms · cubic-bezier(0.2, 0, 0, 1)

340mscubic-bezier(0.2, 0, 0, 1)
Tintsuccess or danger wash, opacity
duration.fastease.standard

140ms · 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 AsyncAnnouncer next 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-busy on 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 is pendingLabel ("Working…" by default), visually hidden beside the spinner. If you replace icons.pending, include the label in it. Keep AsyncAnnouncer beside the control so the state is still spoken.