Button

The button every VitNode action goes through - six variants, a built-in loading spinner, and a render prop that turns it into a router link.

A Base UI button with VitNode's variants painted on top. It handles the two things a hand-rolled <button> always gets wrong: a pending state that does not resize the button, and being something other than a <button> when it needs to be a link.

Preview

Quick start

import { Button } from '@vitnode/core/components/ui/button'
import { HomeIcon } from 'lucide-react'

;<Button>
  <HomeIcon />
  Default
</Button>

Icons are children, not a prop. The button spaces them, sizes them to match its own size, and marks them pointer-events-none so a click always lands on the button.

Variants

variantUse it for
defaultThe one action you want clicked on this screen
secondaryA second action of equal weight
outlineToolbars, filters, anything sitting on a card
ghostIcon buttons and row actions, where a border would be noise
linkA button that must look like prose
destructiveDelete, ban, revoke - tinted rather than filled, on purpose

Sizes

sizeHeightNotes
sm2rem
default2.25rem
lg2.5rem
icon-xs1.5remSquare. aria-label required.
icon-sm2remSquare. aria-label required.
icon2.25remSquare. aria-label required.
icon-lg2.5remSquare. aria-label required.
<Button aria-label="Delete" size="icon" variant="destructive">
  <Trash2Icon />
</Button>

Loading

Pass isLoading and the label fades out while a spinner springs in over it. The button is disabled for the duration and keeps its width, so nothing around it moves:

const [isLoading, setIsLoading] = React.useState(false)

;<Button isLoading={isLoading} onClick={save}>
  Save changes
</Button>

While it is true and the button has no aria-label of its own, the accessible name becomes the translated "loading" string - so a spinner in place of a label still announces itself. An aria-label you pass is spread on last and keeps winning, in both states.

A button that navigates should be an <a>, not a <button> with an onClick. Use render to swap the element and nativeButton={false} to tell Base UI what it is now:

import { Link } from '@tanstack/react-router'
import { Button } from '@vitnode/core/components/ui/button'

;<Button nativeButton={false} render={<Link to="/settings" />}>
  Settings
</Button>

Inside a shared VitNode view that takes a LinkComponent, pass that instead - it is the same thing with href in place of to, and it is what lets one component render correctly in a host that mounts VitNode under a path prefix:

<Button nativeButton={false} render={<LinkComponent href="/settings" />}>
  Settings
</Button>

Props

Everything Base UI's Button accepts, plus:

Prop

Type

Gotchas

An icon-only button will not compile without a label

The props type is a union: choosing any of the four icon* sizes makes aria-label required. That is deliberate - an icon button has no text node, so without it the accessible name is empty - and it is tsc that stops you, not a lint rule you can suppress.

`xs` is a stylesheet variant, not a prop

buttonVariants defines an xs size, but the component's props type does not accept it: <Button size="xs"> is a type error. Use sm, or call buttonVariants({ size: 'xs' }) yourself if you need that geometry on something else.

`render` without `nativeButton={false}`

Base UI keeps applying native-button semantics - so an anchor gets attributes a <button> would want and behaves subtly wrong for keyboard users. If the rendered element is not a <button>, say so.

Next