Skip to content
v2.5.0
English
Русский
Documentation

Button

Button is the primary action component built on top of ButtonBase, with size and full-width behavior for common product actions.
button.tsx · 6 lines
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
Tested in
15 tests

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

  • ButtonProps
  • ButtonSize

Basic Usage

basic-usage.tsx · 17 lines
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 project

Variants

  • 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

  • type has no default: inside a form, Button submits it like a native <button>. With asChild, no type is passed to the child.
  • asChild swaps 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, Link or IconButton may 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.

action-hierarchy.tsx · 12 lines
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.

sizes.tsx · 29 lines
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.

parent-owned-loading-action.tsx · 30 lines
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.

pressed-toggle.tsx · 25 lines
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

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.

form-footer-actions.tsx · 44 lines
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.

submit-outside-form.tsx · 36 lines
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.

dialog-confirmation-actions.tsx · 72 lines
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.

full-width-stacked-actions.tsx · 30 lines
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.

failed-action-with-retry.tsx · 51 lines
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.

blocked-primary-action.tsx · 46 lines
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.

fieldset-disabled-actions.tsx · 22 lines
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

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.

disabled-link-button.tsx · 25 lines
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.

long-label-truncation.tsx · 23 lines
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

  1. Button
  2. optional icon, text, or spinner content

Valid Composition Patterns

basic-usage.tsx · 17 lines
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 project

Invalid Composition Patterns

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.