Button
Button triggers an action. Use it for short, visible labels.
- Use one decorative icon slot at most:
startIconorendIcon. - A loading button shows only a spinner and hides its icons.
- Icon-only buttons need
aria-labeloraria-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
| Prop | Type | Default | Description |
|---|---|---|---|
asChild | boolean | false | Renders the child, such as an |
endIcon | ReactNode | —No default documented | Decorative icon after the label. Use |
loading | boolean | false | 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 |
startIcon | ReactNode | —No default documented | Decorative icon before the label. Use |
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-iconor#end-icon. - A loading button shows only a spinner and hides its icons.
- Icon-only buttons need
aria-labeloraria-labelledby.
<script setup lang="ts">
import { Button } from '@/components/button';
</script>
<template>
<Button>Button</Button>
</template>
Installation
- 1
Install these components first:
- 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
| Prop | Type | Default | Description |
|---|---|---|---|
disabled | boolean | —No default documented | —No description |
loading | boolean | false | 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 |