import { Button } from '@vega-ui/react';
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<Button variant='secondary' appearance='ghost'>Cancel</Button>
<Button>Save</Button>
</div>- Browser support
- Newly available 2024
- Chrome 125
- Edge 125
- Firefox 128
- Safari 18
- Safari on iOS 18
- Bundle size
Summary
Button is the primary action component built on top of ButtonBase, with size and full-width behavior for common product actions. It is the default choice when the UI needs a visible action surface with text.
Imports
import { Button, type ButtonProps, type ButtonSize } from '@vega-ui/react';Exported Types
ButtonPropsButtonSize
Basic Usage
import { Button, Icon } from '@vega-ui/react';
import { PlusIcon } from '@vega-ui/icons';
import { useState } from 'react';
export default function BasicUsage() {
const [count, setCount] = useState(0);
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-8)', width: '100%' }}>
<Button type='button' onClick={() => setCount(count + 1)}>
<Icon size='sm'><PlusIcon /></Icon>
New projectVariants
- Sizes:
xs,sm,md,lg,xl - Variants:
primary,secondary - Appearances:
fill,outline,ghost,transparent - Layout: regular width or
fullWidth - Composition: default button or
asChild
Edge Cases
typehas no default: inside a form,Buttonsubmits it like a native<button>. WithasChild, notypeis passed to the child.asChildswaps the underlying element while preserving VegaUI button styling.- Custom size, variant, or appearance strings require matching CSS support.
- If the interaction is primarily navigation or the meaning is already icon-recognizable,
LinkorIconButtonmay communicate intent better.
Common Mistakes
- Assuming submit behavior without setting
type='submit'. - Using button styling for pure navigation when a semantic link is better.
- Passing unsupported custom variant strings without matching CSS.
Examples
Basic
Basic: action hierarchy
One screen usually has one primary action. Secondary actions step down through outline, ghost, and transparent so the eye lands on the main action first.
import { Button } from '@vega-ui/react';
export default function ActionHierarchy() {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-4)', width: '100%' }}>
<Button type='button' fullWidth>Publish article</Button>
<Button type='button' fullWidth variant='secondary' appearance='ghost'>Save draft</Button>
<Button type='button' fullWidth variant='secondary' appearance='ghost'>Preview</Button>
<Button type='button' fullWidth variant='secondary' appearance='transparent'>Discard</Button>
</div>
);
}Basic: sizes with matching icons
size changes height, padding, and font size. The icon does not follow automatically, so pick an Icon size that matches each button size.
import { Button, Icon } from '@vega-ui/react';
import { DownloadIcon } from '@vega-ui/icons';
export default function Sizes() {
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 'var(--spacing-4)' }}>
<Button type='button' size='xs'>
<Icon size='2xs'><DownloadIcon /></Icon>
Export
</Button>
<Button type='button' size='sm'>
<Icon size='xs'><DownloadIcon /></Icon>Controlled/Stateful
Controlled/Stateful: parent-owned loading action
Button has no loading prop: the parent owns the pending state, disables the button, sets aria-busy, and swaps the label. The status next to it shows the result once the request finishes.
import { Button, Spinner } from '@vega-ui/react';
import { useEffect, useState } from 'react';
export default function ParentOwnedLoadingAction() {
const [saving, setSaving] = useState(false);
const [savedCount, setSavedCount] = useState(0);
useEffect(() => {
if (!saving) return;
const timer = setTimeout(() => {
setSaving(false);Controlled/Stateful: toggle button with aria-pressed
A two-state action keeps the same label and exposes its state with aria-pressed, so screen readers announce it as a toggle. The appearance change is only the visual echo of that state.
import { Button, Icon } from '@vega-ui/react';
import { BellIcon, BellOffIcon } from '@vega-ui/icons';
import { useState } from 'react';
export default function PressedToggle() {
const [watching, setWatching] = useState(false);
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 'var(--spacing-4)', width: '100%' }}>
<Button
type='button'
variant={watching ? 'primary' : 'secondary'}Form/Integration
Form/Integration: submit and reset in a form footer
Button keeps the native type: without it a button inside a form submits. Here type='submit' triggers native validation and the values are read with FormData, while type='reset' restores the default values.
import { Button, Label, TextField, TextFieldInput } from '@vega-ui/react';
import { FormEvent, useState } from 'react';
export default function FormFooterActions() {
const [submitted, setSubmitted] = useState('');
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const data = new FormData(event.currentTarget);
setSubmitted(`${data.get('workspace')} (${data.get('slug')})`);
};
Form/Integration: submit button outside the form
When the action bar lives outside the <form> (a sticky footer, a dialog footer), connect the button with the native form attribute. Validation and submit still work as if the button were inside.
import { Button, Label, TextField, TextFieldInput } from '@vega-ui/react';
import { FormEvent, useState } from 'react';
export default function SubmitOutsideForm() {
const [email, setEmail] = useState('');
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setEmail(String(new FormData(event.currentTarget).get('email')));
};
return (Layout/Overlay
Layout/Overlay: dialog confirmation actions
Both actions are wrapped in DialogClose, so either one closes the dialog, and the destructive one also updates the page. The safe action gets autoFocus, so an accidental Enter keeps the file.
import type { CSSProperties } from 'react';
import {
Button,
Dialog,
DialogClose,
DialogContent,
DialogTrigger,
Heading,
Icon,
IconButton,
Text,
} from '@vega-ui/react';Layout/Overlay: full-width actions in a narrow card
fullWidth stretches the button to the container, which is the usual layout for cards, sheets, and mobile screens with stacked actions.
import { Button, Heading, Text } from '@vega-ui/react';
export default function FullWidthStackedActions() {
return (
<section
aria-labelledby='upgrade-title'
style={{
display: 'flex',
flexDirection: 'column',
gap: 'var(--spacing-12)',
width: '100%',
padding: 'var(--spacing-12)',Error
Error: failed action with retry
When the action fails, the button switches to a retry label and points to the error text with aria-describedby. The first attempt fails on purpose; the retry succeeds.
import { Button, HelperText, Icon, Text } from '@vega-ui/react';
import { RotateCwIcon } from '@vega-ui/icons';
import { useState } from 'react';
type Status = 'idle' | 'failed' | 'done';
export default function FailedActionWithRetry() {
const [status, setStatus] = useState<Status>('idle');
const [attempts, setAttempts] = useState(0);
const syncCalendar = () => {
const nextAttempt = attempts + 1;Disabled
Disabled: blocked until confirmation matches
A disabled button cannot receive focus, so the reason it is blocked lives in visible helper text next to the input, not on the button.
import type { CSSProperties } from 'react';
import { Button, HelperText, Label, Text, TextField, TextFieldInput } from '@vega-ui/react';
import { useState } from 'react';
const destructive = {
'--button-background-color': 'var(--color-red-accent-500)',
'--button-background-color-hover': 'var(--color-red-accent-600)',
'--button-background-color-active': 'var(--color-red-accent-700)',
} as CSSProperties;
export default function BlockedPrimaryAction() {
const [confirmation, setConfirmation] = useState('');Disabled: inherited from a fieldset
Buttons inside <Fieldset disabled> are disabled natively and pick up the disabled styles without their own disabled prop.
import { Button, Fieldset, FieldsetLegend } from '@vega-ui/react';
import { useState } from 'react';
export default function FieldsetDisabledActions() {
const [locked, setLocked] = useState(true);
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: 'var(--spacing-8)', width: '100%' }}>
<Fieldset disabled={locked} appearance='outlined' style={{ width: '100%', boxSizing: 'border-box' }}>
<FieldsetLegend>Billing actions</FieldsetLegend>
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-4)' }}>
<Button type='button' fullWidth>Change plan</Button>Edge
Edge: link with button styling that is not ready yet
With asChild, disabled is not forwarded: the child owns its state. An anchor without href and with aria-disabled='true' gets the disabled look and cannot be followed until the export is ready.
import { Button, Icon } from '@vega-ui/react';
import { DownloadIcon } from '@vega-ui/icons';
import { useState } from 'react';
export default function DisabledLinkButton() {
const [ready, setReady] = useState(false);
return (
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 'var(--spacing-4)' }}>
<Button type='button' variant='secondary' appearance='ghost' disabled={ready} onClick={() => setReady(true)}>
Generate CSV
</Button>Edge: long and translated labels
The button has a fixed height, so long labels must not wrap. Put the text in a shrinkable span with an ellipsis and keep the full text in title.
import { Button, Icon } from '@vega-ui/react';
import { SendIcon } from '@vega-ui/icons';
const labels = [
'Send',
'An alle Teilnehmer senden',
'Отправить приглашение всем участникам проекта',
];
export default function LongLabelTruncation() {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-4)', width: 220 }}>Anatomy
Overview
Button is a single action surface built on ButtonBase. Its anatomy is simple: one clickable root with optional text, icon, spinner, or polymorphic child content.
Required Parts
Button
Required. Owns size, width behavior, and button-base visual props such as variant and appearance.
Optional Parts
Button does not expose public child parts, but consumers often compose it with Spinner, Icon, or asChild content.
Composition Order
Button- optional icon, text, or spinner content
Valid Composition Patterns
import { Button, Icon } from '@vega-ui/react';
import { PlusIcon } from '@vega-ui/icons';
import { useState } from 'react';
export default function BasicUsage() {
const [count, setCount] = useState(0);
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-8)', width: '100%' }}>
<Button type='button' onClick={() => setCount(count + 1)}>
<Icon size='sm'><PlusIcon /></Icon>
New projectInvalid Composition Patterns
Navigation-only action implemented as a button
If the primary job is navigation, prefer Link or Button asChild with a real anchor.
Polymorphic child that does not support the forwarded props
asChild only works well when the child can accept the expected button-like props.