Skip to content

Button

Start an action on the page: save, submit, open a dialog or confirm.

import { Button } from '@youj/react'

Installation & usage

npm i @youj/react

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 a button to save, submit, open a dialog or start another action on the page. For navigation, use a link; buttonVariants gives it the same look. For an option that stays on or off, use a Toggle or a Switch. For work that runs on press and shows its outcome in the button, see Async actions.

import { Button } from '@youj/react'

export function SaveBar() {
  return (
    <div className="flex justify-end gap-2">
      <Button variant="ghost">Cancel</Button>
      <Button type="submit">Save changes</Button>
    </div>
  )
}

It renders a native <button> through Base UI, which defaults to type="button". Set type="submit" on the button that submits a form.

Navigation should stay a link: it opens in a new tab, shows its URL and is announced as a link. Render the link through render, the same prop the triggers take, and it keeps the button's look and press.

import Link from 'next/link'
import { Button } from '@youj/react'

<Button variant="outline" render={<Link href="/deployments" />}>
  View deployments
</Button>

A render element with an href or to (an anchor, Next.js Link, React Router Link) or another tag such as span sets nativeButton={false} for you, so Base UI doesn't expect a <button>. A link keeps its link role. A component without either prop can't be told apart from a button and stays native. If it renders something else, pass nativeButton={false} yourself.

In a server component, or anywhere you'd rather not render a Button, give the anchor the button's look with buttonVariants. It is a plain function that returns class names, with no React in it, so a server component can call it. The same goes for badgeVariants, alertVariants, bannerVariants, buttonGroupVariants, toggleVariants and navigationMenuTriggerStyle.

// app/page.tsx: a server component, no "use client"
import Link from 'next/link'
import { buttonVariants } from '@youj/react'

export default function Page() {
  return (
    <Link href="/deployments" className={buttonVariants({ variant: 'outline' })}>
      View deployments
    </Link>
  )
}

Reference

Propsvariant, size, loading, sound… (4), and Async Button's

Props

Button props
PropType
variantButtonVariant
'default''danger''ghost''outline''link''secondary'

default | secondary | outline | ghost | danger | link.

–
loadingboolean

Swaps the label for a spinner without changing the button's width, sets aria-busy, and blocks presses while keeping keyboard focus on the button. Pass it (even as false) on any button that can load, so its live region exists before the first announcement. Without it, a click handler that returns a promise puts the button in this state until the promise settles; an explicit loading (even false) wins. For a success or error label after the work, use AsyncButton.

–
loadingLabelstring

Announced when loading starts.

default "Loading"

"Loading"
soundboolean

With sound on (YoujProvider’s sound), the button plays tap as it's pressed. false keeps this one silent.

default true

true
size'default' | 'sm' | 'lg' | 'xs' | 'icon' | 'icon-xs' | 'icon-sm' | 'icon-lg'–
Other props pass through: extends ButtonPrimitive.Props, VariantProps<typeof buttonVariants>.

variant and size both default to default. Base UI Button props pass through, such as disabled and focusableWhenDisabled.

Data attributes

The button carries data-variant and data-size, and data-loading while busy. An Async Button also carries data-state, and with hold, data-yj-hold (idle, holding, release or done); its fill reads --ui--hold (0 to 1).

Async Button props

AsyncButton props
PropType
variantButtonVariant
'default''danger''ghost''outline''link''secondary'

default | secondary | outline | ghost | danger | link.

–
size'default' | 'sm' | 'lg' | 'xs' | 'icon' | 'icon-xs' | 'icon-sm' | 'icon-lg'–
soundboolean

With sound on (YoujProvider’s sound), the button plays tap as it's pressed. false keeps this one silent.

default true

true
pendingLabelstring

Shown beside the spinner while the action runs, and announced. Announced as "Working…" when omitted.

–
successLabelstring | ((result: TResult) => string)

Shown beside the check and announced. A function gets what onAction resolved with: successLabel={(user) => Saved ${user.name}}. Under status there is no outcome to read, so pass a string.

default "Done"

"Done"
errorLabelstring | ((error: unknown) => string)

Shown beside the cross and announced assertively. A function gets what onAction rejected or threw with, whatever that is: errorLabel={(error) => error instanceof Error ? error.message : 'Failed'}. Under status, pass a string (errorLabel={mutation.error?.message}).

default "Failed"

"Failed"
resetAfternumber

How long success / error hold before the label returns, in ms. Default: 3 × the deliberate token.

–
onActionStateChange(state: AsyncState) => void

Fires on every state change: idle → pending → success | error → idle.

–
holdboolean | number

Hold to confirm: the action runs only once a press is held (a pointer, or Space or Enter held down) for this long. true takes twice the deliberate motion token; a number is milliseconds. The button fills from its inline start while held and springs back if let go early; a full hold runs the action and the button carries on through pending to success or error. It is described as "Hold to confirm".

–
statusAsyncState
'idle''pending''success''error'

The same pending → success | error → idle lifecycle for work started elsewhere, controlled: pass a mutation's status and the button shows it. The names are TanStack Query's, so status={mutation.status} works as it is; so does any state you map to them. - A pending status shows the spinner and blocks presses. success and error draw their mark, hold for resetAfter, and return to rest even while status stays put (a mutation keeps success until it's reset): the next change of status plays again. A button that mounts already settled is at rest. - Pressing runs onClick, as on any button: start the work there (onClick={() => mutation.mutate(input)}).

–
onActionnever

Async work the button runs on press, with its whole lifecycle shown in place: the label morphs to a spinner (and pendingLabel), then to a drawn check and successLabel, or shakes to a cross and errorLabel, and returns to rest. Resolve for success; reject or throw for error. A second press while it runs (a double click) is ignored.

–

It takes Button's variant, size and sound, and Base UI Button props such as disabled. It has no loading: the lifecycle is its own.

MotionSprings and timings used, reduced motion

Motion

Motion tokens
MomentTokens
Pressscales to 0.97 while held
duration.instantease.standard

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

80mscubic-bezier(0.2, 0, 0, 1)
Release and hoverscale back, background and text colors
duration.fastease.standard

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

140mscubic-bezier(0.2, 0, 0, 1)
Loadingthe label fades out under the spinner
duration.fastease.standard

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

140mscubic-bezier(0.2, 0, 0, 1)
Spinnerone turn at a constant rate
duration.deliberateease.linear

560ms · cubic-bezier(0, 0, 1, 1) · × 1.25 per turn

560mscubic-bezier(0, 0, 1, 1)× 1.25 per turn

Reduced motion

No press scale, and the spinner holds a still quarter arc.

Async Button motion

Motion tokens
MomentTokens
Label swap and tintlabels crossfade with a 0.9 scale and 2px blur
duration.fastease.standard

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

140mscubic-bezier(0.2, 0, 0, 1)
Width morphthe box follows the new label
spring.snappy

0.2s, bounce 0

0.2s, bounce 0
Success checkdraws on, then lands
duration.slowease.emphasized

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

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

0.45s, bounce 0.3

0.45s, bounce 0.3
Error shake4, 3 and 2px, decaying
duration.slowease.standard

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

340mscubic-bezier(0.2, 0, 0, 1)
Holdsuccess or error before returning to rest, unless resetAfter is set
duration.deliberate

560ms · × 3

560ms× 3
Hold-to-confirm fillwith hold, sweeps from the inline start at a constant rate while held
duration.deliberateease.linear

560ms · cubic-bezier(0, 0, 1, 1) · × 2

560mscubic-bezier(0, 0, 1, 1)× 2
Hold released earlythe fill springs back to empty
spring.smooth

0.36s, bounce 0

0.36s, bounce 0
Hold completethe fill fades as the action starts
duration.fastease.standard

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

140mscubic-bezier(0.2, 0, 0, 1)

Reduced motion

The pending label shows as text instead of a spinner, states swap instantly and errors don't shake.

Reduced motion

The press doesn't scale, and the spinner stops turning and holds a quarter arc. In an Async Button the pending state reads as its pendingLabel text, and every change after that is instant, with no shake. With hold, a percentage count stands in the label's place instead of the fill. Success still shows its check and label, and errors still read as errors.

AccessibilityKeyboard, ARIA, focus

Accessibility

  • It's a native <button>: Enter and Space activate it, and it takes part in form submission when type="submit".
  • Name icon-only buttons with aria-label. Youj icons inside are decorative (aria-hidden) unless you label them.
  • An Async Button with hold is described as "Hold to confirm" (the holdToConfirm label), after any aria-describedby of your own. Space and Enter held down fill it from the keyboard; a key's own click, and a click from a press let go early, are ignored. Losing focus cancels a hold.
  • disabled removes the button from the tab order. Keep a disabled button focusable with focusableWhenDisabled when people need to find it and read why it's unavailable.
  • While loading or pending (including an async onClick), the button sets aria-busy and aria-disabled, blocks presses and keeps focus, so a keyboard user isn't dropped onto the page body mid-action. loadingLabel is announced politely.
  • An Async Button is the same native <button>, ignoring presses while pending and during the success hold. Its accessible name is the current label, and the outgoing one is hidden from assistive technology while it fades. While pending, the name is pendingLabel ("Working…" by default), including on icon-only buttons, where it's visually hidden beside the spinner. Pending and success are announced politely, errors assertively, from live regions rendered beside the button, because a button flattens the content inside it.