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
variant | Use it for |
|---|---|
default | The one action you want clicked on this screen |
secondary | A second action of equal weight |
outline | Toolbars, filters, anything sitting on a card |
ghost | Icon buttons and row actions, where a border would be noise |
link | A button that must look like prose |
destructive | Delete, ban, revoke - tinted rather than filled, on purpose |
Sizes
size | Height | Notes |
|---|---|---|
sm | 2rem | |
default | 2.25rem | |
lg | 2.5rem | |
icon-xs | 1.5rem | Square. aria-label required. |
icon-sm | 2rem | Square. aria-label required. |
icon | 2.25rem | Square. aria-label required. |
icon-lg | 2.5rem | Square. 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.
Render as a link
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.