Skip to main content
Nexus Design System
GitHub

Button

Button triggers an action. Use it for short, visible labels.

  • Use one decorative icon slot at most: startIcon or endIcon.
  • A loading button shows only a spinner and hides its icons.
  • Icon-only buttons need aria-label or aria-labelledby.
'use client';

import { Button } from '@/components/nxds/components/button/button';

export default function ButtonDefault() {
  return <Button>Button</Button>;
}

Installation

npx nxds-cli@latest add button

Run npx nxds-cli@latest init first if nxds isn’t set up in your app yet. See Install.

Usage

import { Button } from '@/components/nxds/components/button/button';

Examples

Icon only sizes

Show the three icon-only sizes, each named with aria-label.

'use client';

import { StarIcon } from '@phosphor-icons/react/ssr';

import { Button } from '@/components/nxds/components/button/button';

export default function ButtonIconOnlySizes() {
  return (
    <div className="nx:flex nx:items-center nx:gap-2">
      <Button size="icon-sm" aria-label="Small icon">
        <StarIcon />
      </Button>
      <Button size="icon" aria-label="Default icon">
        <StarIcon />
      </Button>
      <Button size="icon-lg" aria-label="Large icon">
        <StarIcon />
      </Button>
    </div>
  );
}

Start icon slot

Add a decorative icon before the label with startIcon.

'use client';

import { RocketIcon } from '@phosphor-icons/react/ssr';

import { Button } from '@/components/nxds/components/button/button';

export default function ButtonStartIconSlot() {
  return <Button startIcon={<RocketIcon />}>Launch</Button>;
}

End icon slot

Add a decorative icon after the label with endIcon.

'use client';

import { ArrowRightIcon } from '@phosphor-icons/react/ssr';

import { Button } from '@/components/nxds/components/button/button';

export default function ButtonEndIconSlot() {
  return <Button endIcon={<ArrowRightIcon />}>Continue</Button>;
}

All variants

Compare every variant, size, icon slot, disabled and loading state.

Variants

Sizes

Icon slots

Disabled

Loading

'use client';

import {
  ArrowRightIcon,
  RocketIcon,
  StarIcon,
} from '@phosphor-icons/react/ssr';

import { Button } from '@/components/nxds/components/button/button';

export default function ButtonAllVariants() {
  return (
    <div className="nx:flex nx:flex-col nx:gap-6">
      <div>
        <h3 className="nx:text-foreground nx:mb-2 nx:typography-label-default">
          Variants
        </h3>
        <div className="nx:flex nx:flex-wrap nx:items-center nx:gap-2">
          <Button variant="default">Default</Button>
          <Button variant="secondary">Secondary</Button>
          <Button variant="error">Error</Button>
          <Button variant="destructive">Destructive</Button>
          <Button variant="outline">Outline</Button>
          <Button variant="dashed">Dashed</Button>
          <Button variant="ghost">Ghost</Button>
          <Button variant="link">Link</Button>
        </div>
      </div>
      <div>
        <h3 className="nx:text-foreground nx:mb-2 nx:typography-label-default">
          Sizes
        </h3>
        <div className="nx:flex nx:items-center nx:gap-2">
          <Button size="sm">Small</Button>
          <Button size="default">Default</Button>
          <Button size="lg">Large</Button>
          <Button size="icon-sm" aria-label="Small icon">
            <StarIcon />
          </Button>
          <Button size="icon" aria-label="Icon">
            <StarIcon />
          </Button>
          <Button size="icon-lg" aria-label="Large icon">
            <StarIcon />
          </Button>
        </div>
      </div>
      <div>
        <h3 className="nx:text-foreground nx:mb-2 nx:typography-label-default">
          Icon slots
        </h3>
        <div className="nx:flex nx:flex-wrap nx:items-center nx:gap-2">
          <Button startIcon={<RocketIcon />}>Start icon</Button>
          <Button endIcon={<ArrowRightIcon />}>End icon</Button>
        </div>
      </div>
      <div>
        <h3 className="nx:text-foreground nx:mb-2 nx:typography-label-default">
          Disabled
        </h3>
        <div className="nx:flex nx:flex-wrap nx:items-center nx:gap-2">
          <Button variant="default" disabled>
            Default
          </Button>
          <Button variant="secondary" disabled>
            Secondary
          </Button>
          <Button variant="error" disabled>
            Error
          </Button>
          <Button variant="destructive" disabled>
            Destructive
          </Button>
          <Button variant="outline" disabled>
            Outline
          </Button>
          <Button variant="dashed" disabled>
            Dashed
          </Button>
          <Button variant="ghost" disabled>
            Ghost
          </Button>
          <Button variant="link" disabled>
            Link
          </Button>
        </div>
      </div>
      <div>
        <h3 className="nx:text-foreground nx:mb-2 nx:typography-label-default">
          Loading
        </h3>
        <div className="nx:flex nx:flex-wrap nx:items-center nx:gap-2">
          <Button variant="default" loading>
            Default
          </Button>
          <Button variant="secondary" loading>
            Secondary
          </Button>
          <Button variant="error" loading>
            Error
          </Button>
          <Button variant="destructive" loading>
            Destructive
          </Button>
          <Button variant="outline" loading>
            Outline
          </Button>
          <Button variant="dashed" loading>
            Dashed
          </Button>
        </div>
      </div>
    </div>
  );
}

Props

Button

Button props
PropTypeDefaultDescription
asChildbooleanfalse

Renders the child, such as an <a>, with button styles. The loading spinner and icon slots then don't render.

endIconReactNodeNo default documented

Decorative icon after the label. Use endIcon or startIcon, not both.

loadingbooleanfalse

Disables the button and shows a spinner in place of its content.

size"default" | "sm" | "lg" | "icon-sm" | "icon" | "icon-lg" | null"default"No description
startIconReactNodeNo default documented

Decorative icon before the label. Use startIcon or endIcon, not both.

variant"link" | "default" | "error" | "destructive" | "outline" | "dashed" | "secondary" | "ghost" | null"default"No description

Button triggers an action. Use it for short, visible labels.

  • Use one decorative icon slot at most: #start-icon or #end-icon.
  • A loading button shows only a spinner and hides its icons.
  • Icon-only buttons need aria-label or aria-labelledby.
<script setup lang="ts">
import { Button } from '@/components/button';
</script>

<template>
  <Button>Button</Button>
</template>

Installation

  1. Install these components first:

    ButtonGroup, Spinner.

  2. Copy and paste the following code into your project.

    components/button/Button.vue
    <script setup lang="ts">
    import { Primitive, type PrimitiveProps } from 'reka-ui';
    import { computed, type HTMLAttributes, inject, type VNode } from 'vue';
    
    import { renderedNodes } from '../../lib/slots';
    import { cn } from '../../lib/utils';
    import { ButtonGroupKey } from '../button-group/button-group-context';
    import Spinner from '../spinner/Spinner.vue';
    
    import { type ButtonVariants, buttonVariants } from '.';
    
    export interface ButtonProps extends PrimitiveProps {
      variant?: ButtonVariants['variant'];
      size?: ButtonVariants['size'];
      /** Disables the button and shows a spinner in place of its content. */
      loading?: boolean;
      disabled?: boolean;
      type?: 'button' | 'submit' | 'reset';
      class?: HTMLAttributes['class'];
    }
    
    const props = withDefaults(defineProps<ButtonProps>(), {
      as: 'button',
      variant: 'default',
      loading: false,
      type: 'button',
    });
    
    const slots = defineSlots<{
      default?: () => VNode[];
      'start-icon'?: () => VNode[];
      'end-icon'?: () => VNode[];
    }>();
    
    type ButtonSize = NonNullable<ButtonVariants['size']>;
    
    function isIconButtonSize(size: ButtonSize) {
      return size === 'icon-sm' || size === 'icon' || size === 'icon-lg';
    }
    
    const group = inject(ButtonGroupKey, undefined);
    const size = computed(() => props.size ?? group?.size ?? 'default');
    const isDisabled = computed(() => props.disabled || props.loading);
    const iconOnly = computed(() => isIconButtonSize(size.value));
    // `type` and `disabled` mean nothing on another tag, which also stays focusable.
    const isNativeButton = computed(() => props.as === 'button');
    const sharedAttrs = computed(() => ({
      'data-slot': 'button',
      'data-variant': props.variant,
      'data-size': size.value,
      'data-icon-only': iconOnly.value || undefined,
      'data-loading': props.loading || undefined,
      class: cn(
        buttonVariants({ variant: props.variant, size: size.value }),
        props.class
      ),
      'aria-busy': props.loading || undefined,
      'aria-disabled': isDisabled.value || undefined,
    }));
    
    // `asChild` clones onto a single child element; any other child keeps the
    // native `<button>`, as React's Slot does.
    function rendersAsChild() {
      if (!props.asChild) return false;
      const nodes = renderedNodes(slots.default?.());
      return nodes.length === 1 && typeof nodes[0]?.type !== 'symbol';
    }
    </script>
    
    <template>
      <Primitive
        v-if="rendersAsChild()"
        v-bind="sharedAttrs"
        as-child
        :tabindex="isDisabled ? -1 : undefined"
      >
        <slot />
      </Primitive>
      <Primitive
        v-else
        v-bind="sharedAttrs"
        :as="as"
        :type="isNativeButton ? type : undefined"
        :disabled="isNativeButton ? isDisabled : undefined"
        :tabindex="!isNativeButton && isDisabled ? -1 : undefined"
      >
        <span
          v-if="loading"
          data-slot="button-loading-content"
          class="nx:relative nx:inline-flex nx:items-center nx:justify-center"
        >
          <span data-slot="button-loading-label" class="nx:opacity-0"
            ><slot
          /></span>
          <Spinner
            aria-hidden="true"
            class="nx:absolute nx:left-1/2 nx:top-1/2 nx:-translate-x-1/2 nx:-translate-y-1/2"
          />
        </span>
        <template v-else>
          <span
            v-if="slots['start-icon']"
            aria-hidden="true"
            data-slot="button-start-icon"
            class="nx:inline-flex nx:shrink-0"
          >
            <slot name="start-icon" />
          </span>
          <slot />
          <span
            v-if="slots['end-icon']"
            aria-hidden="true"
            data-slot="button-end-icon"
            class="nx:inline-flex nx:shrink-0"
          >
            <slot name="end-icon" />
          </span>
        </template>
      </Primitive>
    </template>
    
    components/button/index.ts
    import { cva, type VariantProps } from 'class-variance-authority';
    
    export { default as Button, type ButtonProps } from './Button.vue';
    
    export const buttonVariants = cva(
      'nx:inline-flex nx:box-border nx:cursor-pointer nx:items-center nx:bg-transparent nx:py-0 nx:no-underline nx:justify-center nx:rounded-base nx:border-default nx:border-transparent nx:whitespace-nowrap nx:transition-[color,background-color,border-color,scale] nx:active:scale-[0.96] nx:focus-visible:outline-2 nx:focus-visible:outline-focus-default nx:focus-visible:outline-offset-2 nx:disabled:pointer-events-none nx:disabled:cursor-default nx:disabled:opacity-100 nx:aria-disabled:pointer-events-none nx:aria-disabled:cursor-default nx:aria-disabled:opacity-100 nx:[&_svg]:pointer-events-none nx:[&_svg]:size-3.5 nx:[&_svg]:shrink-0',
      {
        variants: {
          variant: {
            default:
              'nx:bg-primary-background nx:text-primary-foreground nx:hover:bg-primary-background-hover nx:active:bg-primary-background-active nx:disabled:bg-primary-disabled nx:aria-disabled:bg-primary-disabled',
            error:
              'nx:text-error-subtle-foreground nx:hover:bg-error-subtle-hover nx:active:bg-error-subtle-active nx:disabled:text-disabled-foreground nx:aria-disabled:text-disabled-foreground',
            destructive:
              'nx:bg-error-background nx:text-error-foreground nx:hover:bg-error-background-hover nx:active:bg-error-background-active nx:disabled:bg-error-disabled nx:aria-disabled:bg-error-disabled',
            outline:
              'nx:border-default nx:border-border-default nx:bg-container nx:text-foreground nx:hover:bg-container-hover nx:active:bg-container-active nx:disabled:border-border-disabled nx:disabled:bg-secondary-disabled nx:disabled:text-disabled-foreground nx:aria-disabled:border-border-disabled nx:aria-disabled:bg-secondary-disabled nx:aria-disabled:text-disabled-foreground',
            dashed:
              'nx:border-default nx:border-dashed nx:border-border-default nx:bg-container nx:text-foreground nx:hover:bg-container-hover nx:active:bg-container-active nx:disabled:border-border-disabled nx:disabled:bg-disabled nx:disabled:text-disabled-foreground nx:aria-disabled:border-border-disabled nx:aria-disabled:bg-disabled nx:aria-disabled:text-disabled-foreground',
            secondary:
              'nx:bg-secondary-background nx:text-secondary-foreground nx:hover:bg-secondary-background-hover nx:active:bg-secondary-background-active nx:disabled:bg-secondary-disabled nx:disabled:text-disabled-foreground nx:aria-disabled:bg-secondary-disabled nx:aria-disabled:text-disabled-foreground',
            ghost:
              'nx:text-foreground nx:hover:bg-container-hover nx:active:bg-container-active nx:disabled:text-disabled-foreground nx:aria-disabled:text-disabled-foreground',
            link: 'nx:border-0 nx:text-primary-subtle-foreground nx:underline-offset-4 nx:hover:underline nx:active:scale-100 nx:disabled:text-disabled-foreground nx:aria-disabled:text-disabled-foreground',
          },
          size: {
            sm: 'nx:h-8 nx:px-2.5 nx:gap-2 nx:typography-label-default',
            default: 'nx:h-10 nx:px-3 nx:gap-2 nx:typography-label-default',
            lg: 'nx:h-12 nx:px-3.5 nx:gap-2 nx:typography-label-default',
            'icon-sm': 'nx:size-8 nx:gap-0 nx:p-0',
            icon: 'nx:size-10 nx:gap-0 nx:p-0',
            'icon-lg': 'nx:size-12 nx:gap-0 nx:p-0',
          },
        },
        compoundVariants: [
          {
            variant: 'link',
            size: ['sm', 'default', 'lg'],
            className: 'nx:h-auto nx:p-0!',
          },
        ],
        defaultVariants: {
          variant: 'default',
          size: 'default',
        },
      }
    );
    
    export type ButtonVariants = VariantProps<typeof buttonVariants>;
    
    lib/slots.ts
    import { Comment, Fragment, Text, type VNode } from 'vue';
    
    /** The nodes a slot renders, without comments and whitespace-only text. */
    export function renderedNodes(nodes: VNode[] | undefined) {
      return (nodes ?? []).filter(
        (node) =>
          node.type !== Comment &&
          !(node.type === Text && !String(node.children).trim())
      );
    }
    
    /** {@link renderedNodes} with each `v-for` or `<template>` fragment unwrapped into its children. */
    export function flattenedNodes(nodes: VNode[] | undefined): VNode[] {
      return renderedNodes(nodes).flatMap((node) =>
        node.type === Fragment && Array.isArray(node.children)
          ? flattenedNodes(node.children as VNode[])
          : [node]
      );
    }
    
  3. Keep each file at the path shown above it.

    Paths are relative to packages/vue/src/. Keep the same folders under your @/ root, and the imports between files work without edits.

Usage

import { Button } from '@/components/button';

Examples

Icon only sizes

Show the three icon-only sizes, each named with aria-label.

<script setup lang="ts">
import { PhStar } from '@phosphor-icons/vue';

import { Button } from '@/components/button';
</script>

<template>
  <div class="nx:flex nx:items-center nx:gap-2">
    <Button size="icon-sm" aria-label="Small icon"><PhStar /></Button>
    <Button size="icon" aria-label="Default icon"><PhStar /></Button>
    <Button size="icon-lg" aria-label="Large icon"><PhStar /></Button>
  </div>
</template>

Start icon slot

Add a decorative icon before the label with the #start-icon slot.

<script setup lang="ts">
import { PhRocket } from '@phosphor-icons/vue';

import { Button } from '@/components/button';
</script>

<template>
  <Button>
    <template #start-icon><PhRocket data-testid="start-icon" /></template>
    Launch
  </Button>
</template>

End icon slot

Add a decorative icon after the label with the #end-icon slot.

<script setup lang="ts">
import { PhArrowRight } from '@phosphor-icons/vue';

import { Button } from '@/components/button';
</script>

<template>
  <Button>
    Continue
    <template #end-icon><PhArrowRight data-testid="end-icon" /></template>
  </Button>
</template>

All variants

Compare every variant, size, icon slot, disabled and loading state.

<script setup lang="ts">
import { PhArrowRight, PhRocket, PhStar } from '@phosphor-icons/vue';

import { Button } from '@/components/button';
</script>

<template>
  <div class="nx:flex nx:flex-col nx:gap-6">
    <div>
      <h3 class="nx:text-foreground nx:mb-2 nx:typography-label-default">
        Variants
      </h3>
      <div class="nx:flex nx:flex-wrap nx:items-center nx:gap-2">
        <Button variant="default">Default</Button>
        <Button variant="secondary">Secondary</Button>
        <Button variant="error">Error</Button>
        <Button variant="destructive">Destructive</Button>
        <Button variant="outline">Outline</Button>
        <Button variant="dashed">Dashed</Button>
        <Button variant="ghost">Ghost</Button>
        <Button variant="link">Link</Button>
      </div>
    </div>
    <div>
      <h3 class="nx:text-foreground nx:mb-2 nx:typography-label-default">
        Sizes
      </h3>
      <div class="nx:flex nx:items-center nx:gap-2">
        <Button size="sm">Small</Button>
        <Button size="default">Default</Button>
        <Button size="lg">Large</Button>
        <Button size="icon-sm" aria-label="Small icon"><PhStar /></Button>
        <Button size="icon" aria-label="Icon"><PhStar /></Button>
        <Button size="icon-lg" aria-label="Large icon"><PhStar /></Button>
      </div>
    </div>
    <div>
      <h3 class="nx:text-foreground nx:mb-2 nx:typography-label-default">
        Icon slots
      </h3>
      <div class="nx:flex nx:flex-wrap nx:items-center nx:gap-2">
        <Button
          ><template #start-icon><PhRocket /></template>Start icon</Button
        >
        <Button
          >End icon<template #end-icon><PhArrowRight /></template
        ></Button>
      </div>
    </div>
    <div>
      <h3 class="nx:text-foreground nx:mb-2 nx:typography-label-default">
        Disabled
      </h3>
      <div class="nx:flex nx:flex-wrap nx:items-center nx:gap-2">
        <Button variant="default" disabled>Default</Button>
        <Button variant="secondary" disabled>Secondary</Button>
        <Button variant="error" disabled>Error</Button>
        <Button variant="destructive" disabled>Destructive</Button>
        <Button variant="outline" disabled>Outline</Button>
        <Button variant="dashed" disabled>Dashed</Button>
        <Button variant="ghost" disabled>Ghost</Button>
        <Button variant="link" disabled>Link</Button>
      </div>
    </div>
    <div>
      <h3 class="nx:text-foreground nx:mb-2 nx:typography-label-default">
        Loading
      </h3>
      <div class="nx:flex nx:flex-wrap nx:items-center nx:gap-2">
        <Button variant="default" loading>Default</Button>
        <Button variant="secondary" loading>Secondary</Button>
        <Button variant="error" loading>Error</Button>
        <Button variant="destructive" loading>Destructive</Button>
        <Button variant="outline" loading>Outline</Button>
        <Button variant="dashed" loading>Dashed</Button>
      </div>
    </div>
  </div>
</template>

Props

Button

Button props
PropTypeDefaultDescription
disabledbooleanNo default documentedNo description
loadingbooleanfalse

Disables the button and shows a spinner in place of its content.

size"default" | "sm" | "lg" | "icon-sm" | "icon" | "icon-lg" | null"default"No description
type"button" | "submit" | "reset""button"No description
variant"default" | "destructive" | "error" | "outline" | "dashed" | "secondary" | "ghost" | "link" | null"default"No description