Skip to content
  1. Buttons and menus
  2. Button

Button

A control that runs an action or opens a page, in solid, outline and plain styles.

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

export default function Example() {
  return <Button>Create invoice</Button>
}
<script setup lang="ts">
import Button from '@/components/button.vue'
</script>

<template>
  <Button>Create invoice</Button>
</template>

Installation

Copy these files into the folder where you keep your components. They import each other with relative paths, so the folder can live anywhere. The packages they rely on are listed on the Installation page.

import * as Headless from '@headlessui/react'
import clsx from 'clsx'
import React, { forwardRef } from 'react'
import { Link } from './link'

type Colors = {
  text: string
  background: string
  border: string
  borderHover?: string
  hoverOverlay: string
  icon: string
  iconHover?: string
}

type ButtonColors = Colors & {
  dark?: Partial<Pick<Colors, 'text' | 'background' | 'hoverOverlay' | 'icon' | 'iconHover'>>
}

const withOpacity = (color: string, percent: number) => `color-mix(in oklab, ${color} ${percent}%, transparent)`

const white = 'var(--color-white)'

function whiteText(background: string, border: string, icon: string, iconHover: string, borderOpacity = 90): ButtonColors {
  return {
    text: white,
    background,
    border: withOpacity(border, borderOpacity),
    hoverOverlay: withOpacity(white, 10),
    icon,
    iconHover,
  }
}

function darkText(text: string, background: string, border: string, icon: string, iconHover?: string): ButtonColors {
  return { text, background, border: withOpacity(border, 80), hoverOverlay: withOpacity(white, 25), icon, iconHover }
}

const whiteIcon = { icon: withOpacity(white, 60), iconHover: withOpacity(white, 80) }

const darkButton = {
  text: white,
  background: 'var(--color-zinc-900)',
  border: withOpacity('var(--color-zinc-950)', 90),
  hoverOverlay: withOpacity(white, 10),
  icon: 'var(--color-zinc-400)',
  iconHover: 'var(--color-zinc-300)',
} satisfies ButtonColors

const lightButton = {
  text: 'var(--color-zinc-950)',
  background: 'white',
  border: withOpacity('var(--color-zinc-950)', 10),
  borderHover: withOpacity('var(--color-zinc-950)', 15),
  hoverOverlay: withOpacity('var(--color-zinc-950)', 2.5),
  icon: 'var(--color-zinc-500)',
  iconHover: 'var(--color-zinc-700)',
} satisfies ButtonColors

const colors = {
  'dark/zinc': { ...darkButton, dark: { background: 'var(--color-zinc-600)', hoverOverlay: withOpacity(white, 5) } },
  light: {
    ...lightButton,
    dark: {
      text: white,
      background: 'var(--color-zinc-800)',
      hoverOverlay: withOpacity(white, 5),
      icon: 'var(--color-zinc-500)',
      iconHover: 'var(--color-zinc-400)',
    },
  },
  'dark/white': {
    ...darkButton,
    dark: {
      text: 'var(--color-zinc-950)',
      background: 'white',
      hoverOverlay: withOpacity('var(--color-zinc-950)', 5),
      icon: 'var(--color-zinc-500)',
      iconHover: 'var(--color-zinc-400)',
    },
  },
  dark: { ...darkButton, dark: { background: 'var(--color-zinc-800)', hoverOverlay: withOpacity(white, 5) } },
  white: {
    ...lightButton,
    icon: 'var(--color-zinc-400)',
    iconHover: 'var(--color-zinc-500)',
    dark: { hoverOverlay: withOpacity('var(--color-zinc-950)', 5) },
  },
  zinc: {
    ...whiteText('var(--color-zinc-600)', 'var(--color-zinc-700)', 'var(--color-zinc-400)', 'var(--color-zinc-300)'),
    dark: { hoverOverlay: withOpacity(white, 5) },
  },
  indigo: whiteText('var(--color-indigo-500)', 'var(--color-indigo-600)', 'var(--color-indigo-300)', 'var(--color-indigo-200)'),
  cyan: darkText('var(--color-cyan-950)', 'var(--color-cyan-300)', 'var(--color-cyan-400)', 'var(--color-cyan-500)'),
  red: whiteText('var(--color-red-600)', 'var(--color-red-700)', 'var(--color-red-300)', 'var(--color-red-200)'),
  orange: whiteText('var(--color-orange-500)', 'var(--color-orange-600)', 'var(--color-orange-300)', 'var(--color-orange-200)'),
  amber: darkText('var(--color-amber-950)', 'var(--color-amber-400)', 'var(--color-amber-500)', 'var(--color-amber-600)'),
  yellow: darkText('var(--color-yellow-950)', 'var(--color-yellow-300)', 'var(--color-yellow-400)', 'var(--color-yellow-600)', 'var(--color-yellow-700)'),
  lime: darkText('var(--color-lime-950)', 'var(--color-lime-300)', 'var(--color-lime-400)', 'var(--color-lime-600)', 'var(--color-lime-700)'),
  green: { ...whiteText('var(--color-green-600)', 'var(--color-green-700)', '', ''), ...whiteIcon },
  emerald: { ...whiteText('var(--color-emerald-600)', 'var(--color-emerald-700)', '', ''), ...whiteIcon },
  teal: { ...whiteText('var(--color-teal-600)', 'var(--color-teal-700)', '', ''), ...whiteIcon },
  sky: { ...whiteText('var(--color-sky-500)', 'var(--color-sky-600)', '', '', 80), ...whiteIcon },
  blue: whiteText('var(--color-blue-600)', 'var(--color-blue-700)', 'var(--color-blue-400)', 'var(--color-blue-300)'),
  accent: whiteText(
    'var(--accent-600,var(--color-blue-600))',
    'var(--accent-700,var(--color-blue-700))',
    'var(--accent-300,var(--color-blue-300))',
    'var(--accent-200,var(--color-blue-200))'
  ),
  violet: whiteText('var(--color-violet-500)', 'var(--color-violet-600)', 'var(--color-violet-300)', 'var(--color-violet-200)'),
  purple: whiteText('var(--color-purple-500)', 'var(--color-purple-600)', 'var(--color-purple-300)', 'var(--color-purple-200)'),
  fuchsia: whiteText('var(--color-fuchsia-500)', 'var(--color-fuchsia-600)', 'var(--color-fuchsia-300)', 'var(--color-fuchsia-200)'),
  pink: whiteText('var(--color-pink-500)', 'var(--color-pink-600)', 'var(--color-pink-300)', 'var(--color-pink-200)'),
  rose: whiteText('var(--color-rose-500)', 'var(--color-rose-600)', 'var(--color-rose-300)', 'var(--color-rose-200)'),
} satisfies Record<string, ButtonColors>

function cssVariables(colors: ButtonColors): React.CSSProperties {
  let variables: Record<string, string | undefined> = {
    '--button-text': colors.text,
    '--button-background': colors.background,
    '--button-border': colors.border,
    '--button-border-hover': colors.borderHover,
    '--button-hover-overlay': colors.hoverOverlay,
    '--button-icon': colors.icon,
    '--button-icon-hover': colors.iconHover,
    '--button-dark-text': colors.dark?.text,
    '--button-dark-background': colors.dark?.background,
    '--button-dark-hover-overlay': colors.dark?.hoverOverlay,
    '--button-dark-icon': colors.dark?.icon,
    '--button-dark-icon-hover': colors.dark?.iconHover,
  }
  return Object.fromEntries(Object.entries(variables).filter(([, value]) => value !== undefined)) as React.CSSProperties
}

const focusRing = 'focus:not-data-focus:outline-hidden data-focus:outline-2 data-focus:outline-offset-2 data-focus:outline-accent-500'

const base = [
  'relative isolate inline-flex justify-center items-baseline gap-x-2',
  'border rounded-lg',
  'font-semibold text-base/6 sm:text-sm/6',
  'py-[calc(--spacing(2.5)-1px)] px-[calc(--spacing(3.5)-1px)]',
  'sm:py-[calc(--spacing(1.5)-1px)] sm:px-[calc(--spacing(3)-1px)]',
  focusRing,
  'data-disabled:opacity-50',
]

const iconSlot = [
  '*:data-[slot=icon]:shrink-0 *:data-[slot=icon]:size-5 sm:*:data-[slot=icon]:size-4',
  '*:data-[slot=icon]:self-center *:data-[slot=icon]:-mx-0.5 *:data-[slot=icon]:my-0.5 sm:*:data-[slot=icon]:my-1',
  '*:data-[slot=icon]:text-(--icon) forced-colors:[--icon:ButtonText] forced-colors:data-hover:[--icon:ButtonText]',
]

const neutralIcon =
  '[--icon:var(--color-zinc-500)] data-hover:[--icon:var(--color-zinc-700)] data-active:[--icon:var(--color-zinc-700)] dark:data-hover:[--icon:var(--color-zinc-400)] dark:data-active:[--icon:var(--color-zinc-400)]'

const solidColors = [
  'text-(--button-text) dark:text-[var(--button-dark-text,var(--button-text))]',
  '[--bg:var(--button-background)] dark:[--bg:var(--button-dark-background,var(--button-background))]',
  '[--border:var(--button-border)] data-hover:[--border:var(--button-border-hover,var(--button-border))] data-active:[--border:var(--button-border-hover,var(--button-border))]',
  '[--overlay:var(--button-hover-overlay)] dark:[--overlay:var(--button-dark-hover-overlay,var(--button-hover-overlay))]',
  '[--icon:var(--button-icon)] data-hover:[--icon:var(--button-icon-hover,var(--button-icon))] data-active:[--icon:var(--button-icon-hover,var(--button-icon))]',
  'dark:[--icon:var(--button-dark-icon,var(--button-icon))] dark:data-hover:[--icon:var(--button-dark-icon-hover,var(--button-icon-hover,var(--button-icon)))] dark:data-active:[--icon:var(--button-dark-icon-hover,var(--button-icon-hover,var(--button-icon)))]',
]

const solidBorder = 'border-transparent bg-(--border) dark:border-white/5 dark:bg-(--bg)'

const solidBackgroundLayer =
  'before:-z-10 before:absolute before:inset-0 before:rounded-[calc(var(--radius-lg)-1px)] before:bg-(--bg) before:shadow-sm data-disabled:before:shadow-none dark:before:hidden'

const solidHighlightLayer = [
  'after:-z-10 after:absolute after:inset-0 dark:after:-inset-px',
  'after:rounded-[calc(var(--radius-lg)-1px)] dark:after:rounded-lg',
  'after:shadow-[inset_0_1px_--theme(--color-white/15%)] data-disabled:after:shadow-none',
  'data-hover:after:bg-(--overlay) data-active:after:bg-(--overlay)',
]

const variants = {
  solid: [solidColors, solidBorder, solidBackgroundLayer, solidHighlightLayer],
  outline: [
    'border-zinc-950/10 text-zinc-950 data-hover:bg-zinc-950/2.5 data-active:bg-zinc-950/2.5',
    'dark:border-white/15 dark:text-white dark:data-hover:bg-white/5 dark:data-active:bg-white/5',
    neutralIcon,
  ],
  plain: [
    'border-transparent text-zinc-950 data-hover:bg-zinc-950/5 data-active:bg-zinc-950/5',
    'dark:text-white dark:data-hover:bg-white/10 dark:data-active:bg-white/10',
    neutralIcon,
  ],
}

type ButtonProps = (
  | { color?: keyof typeof colors; outline?: never; plain?: never }
  | { color?: never; outline: true; plain?: never }
  | { color?: never; outline?: never; plain: true }
) & { className?: string; children: React.ReactNode } & (
    | ({ href?: never } & Omit<Headless.ButtonProps, 'as' | 'className'>)
    | ({ href: string } & Omit<React.ComponentPropsWithoutRef<typeof Link>, 'className'>)
  )

export const Button = forwardRef(function Button(
  { color, outline, plain, className, style, children, ...props }: ButtonProps,
  ref: React.ForwardedRef<HTMLElement>
) {
  let variant: keyof typeof variants = outline ? 'outline' : plain ? 'plain' : 'solid'
  let classes = clsx(className, base, iconSlot, variants[variant])
  let styles = variant === 'solid' ? { ...cssVariables(colors[color ?? 'accent']), ...style } : style

  if (typeof props.href === 'string') {
    return (
      <Link {...props} style={styles} className={classes} ref={ref as React.ForwardedRef<HTMLAnchorElement>}>
        <TouchTarget>{children}</TouchTarget>
      </Link>
    )
  }

  return (
    <Headless.Button {...props} style={styles} className={clsx(classes, 'cursor-default')} ref={ref}>
      <TouchTarget>{children}</TouchTarget>
    </Headless.Button>
  )
})

export function TouchTarget({ children }: { children: React.ReactNode }) {
  return (
    <>
      <span aria-hidden="true" className="absolute top-1/2 left-1/2 -translate-1/2 size-[max(100%,2.75rem)] pointer-fine:hidden" />
      {children}
    </>
  )
}
<script setup lang="ts">
import { computed } from 'vue'
import { vInteractions } from './interactions'
import Link from './link.vue'
import TouchTarget from './touch-target.vue'

type Colors = {
  text: string
  background: string
  border: string
  borderHover?: string
  hoverOverlay: string
  icon: string
  iconHover?: string
}

type ButtonColors = Colors & {
  dark?: Partial<Pick<Colors, 'text' | 'background' | 'hoverOverlay' | 'icon' | 'iconHover'>>
}

const withOpacity = (color: string, percent: number) => `color-mix(in oklab, ${color} ${percent}%, transparent)`

const white = 'var(--color-white)'

function whiteText(background: string, border: string, icon: string, iconHover: string, borderOpacity = 90): ButtonColors {
  return {
    text: white,
    background,
    border: withOpacity(border, borderOpacity),
    hoverOverlay: withOpacity(white, 10),
    icon,
    iconHover,
  }
}

function darkText(text: string, background: string, border: string, icon: string, iconHover?: string): ButtonColors {
  return { text, background, border: withOpacity(border, 80), hoverOverlay: withOpacity(white, 25), icon, iconHover }
}

const whiteIcon = { icon: withOpacity(white, 60), iconHover: withOpacity(white, 80) }

const darkButton = {
  text: white,
  background: 'var(--color-zinc-900)',
  border: withOpacity('var(--color-zinc-950)', 90),
  hoverOverlay: withOpacity(white, 10),
  icon: 'var(--color-zinc-400)',
  iconHover: 'var(--color-zinc-300)',
} satisfies ButtonColors

const lightButton = {
  text: 'var(--color-zinc-950)',
  background: 'white',
  border: withOpacity('var(--color-zinc-950)', 10),
  borderHover: withOpacity('var(--color-zinc-950)', 15),
  hoverOverlay: withOpacity('var(--color-zinc-950)', 2.5),
  icon: 'var(--color-zinc-500)',
  iconHover: 'var(--color-zinc-700)',
} satisfies ButtonColors

const colors = {
  'dark/zinc': { ...darkButton, dark: { background: 'var(--color-zinc-600)', hoverOverlay: withOpacity(white, 5) } },
  light: {
    ...lightButton,
    dark: {
      text: white,
      background: 'var(--color-zinc-800)',
      hoverOverlay: withOpacity(white, 5),
      icon: 'var(--color-zinc-500)',
      iconHover: 'var(--color-zinc-400)',
    },
  },
  'dark/white': {
    ...darkButton,
    dark: {
      text: 'var(--color-zinc-950)',
      background: 'white',
      hoverOverlay: withOpacity('var(--color-zinc-950)', 5),
      icon: 'var(--color-zinc-500)',
      iconHover: 'var(--color-zinc-400)',
    },
  },
  dark: { ...darkButton, dark: { background: 'var(--color-zinc-800)', hoverOverlay: withOpacity(white, 5) } },
  white: {
    ...lightButton,
    icon: 'var(--color-zinc-400)',
    iconHover: 'var(--color-zinc-500)',
    dark: { hoverOverlay: withOpacity('var(--color-zinc-950)', 5) },
  },
  zinc: {
    ...whiteText('var(--color-zinc-600)', 'var(--color-zinc-700)', 'var(--color-zinc-400)', 'var(--color-zinc-300)'),
    dark: { hoverOverlay: withOpacity(white, 5) },
  },
  indigo: whiteText('var(--color-indigo-500)', 'var(--color-indigo-600)', 'var(--color-indigo-300)', 'var(--color-indigo-200)'),
  cyan: darkText('var(--color-cyan-950)', 'var(--color-cyan-300)', 'var(--color-cyan-400)', 'var(--color-cyan-500)'),
  red: whiteText('var(--color-red-600)', 'var(--color-red-700)', 'var(--color-red-300)', 'var(--color-red-200)'),
  orange: whiteText('var(--color-orange-500)', 'var(--color-orange-600)', 'var(--color-orange-300)', 'var(--color-orange-200)'),
  amber: darkText('var(--color-amber-950)', 'var(--color-amber-400)', 'var(--color-amber-500)', 'var(--color-amber-600)'),
  yellow: darkText('var(--color-yellow-950)', 'var(--color-yellow-300)', 'var(--color-yellow-400)', 'var(--color-yellow-600)', 'var(--color-yellow-700)'),
  lime: darkText('var(--color-lime-950)', 'var(--color-lime-300)', 'var(--color-lime-400)', 'var(--color-lime-600)', 'var(--color-lime-700)'),
  green: { ...whiteText('var(--color-green-600)', 'var(--color-green-700)', '', ''), ...whiteIcon },
  emerald: { ...whiteText('var(--color-emerald-600)', 'var(--color-emerald-700)', '', ''), ...whiteIcon },
  teal: { ...whiteText('var(--color-teal-600)', 'var(--color-teal-700)', '', ''), ...whiteIcon },
  sky: { ...whiteText('var(--color-sky-500)', 'var(--color-sky-600)', '', '', 80), ...whiteIcon },
  blue: whiteText('var(--color-blue-600)', 'var(--color-blue-700)', 'var(--color-blue-400)', 'var(--color-blue-300)'),
  accent: whiteText(
    'var(--accent-600,var(--color-blue-600))',
    'var(--accent-700,var(--color-blue-700))',
    'var(--accent-300,var(--color-blue-300))',
    'var(--accent-200,var(--color-blue-200))'
  ),
  violet: whiteText('var(--color-violet-500)', 'var(--color-violet-600)', 'var(--color-violet-300)', 'var(--color-violet-200)'),
  purple: whiteText('var(--color-purple-500)', 'var(--color-purple-600)', 'var(--color-purple-300)', 'var(--color-purple-200)'),
  fuchsia: whiteText('var(--color-fuchsia-500)', 'var(--color-fuchsia-600)', 'var(--color-fuchsia-300)', 'var(--color-fuchsia-200)'),
  pink: whiteText('var(--color-pink-500)', 'var(--color-pink-600)', 'var(--color-pink-300)', 'var(--color-pink-200)'),
  rose: whiteText('var(--color-rose-500)', 'var(--color-rose-600)', 'var(--color-rose-300)', 'var(--color-rose-200)'),
} satisfies Record<string, ButtonColors>

function cssVariables(colors: ButtonColors): Record<string, string> {
  let variables: Record<string, string | undefined> = {
    '--button-text': colors.text,
    '--button-background': colors.background,
    '--button-border': colors.border,
    '--button-border-hover': colors.borderHover,
    '--button-hover-overlay': colors.hoverOverlay,
    '--button-icon': colors.icon,
    '--button-icon-hover': colors.iconHover,
    '--button-dark-text': colors.dark?.text,
    '--button-dark-background': colors.dark?.background,
    '--button-dark-hover-overlay': colors.dark?.hoverOverlay,
    '--button-dark-icon': colors.dark?.icon,
    '--button-dark-icon-hover': colors.dark?.iconHover,
  }
  return Object.fromEntries(Object.entries(variables).filter(([, value]) => value !== undefined)) as Record<string, string>
}

const focusRing = 'focus:not-data-focus:outline-hidden data-focus:outline-2 data-focus:outline-offset-2 data-focus:outline-accent-500'

const base = [
  'relative isolate inline-flex justify-center items-baseline gap-x-2',
  'border rounded-lg',
  'font-semibold text-base/6 sm:text-sm/6',
  'py-[calc(--spacing(2.5)-1px)] px-[calc(--spacing(3.5)-1px)]',
  'sm:py-[calc(--spacing(1.5)-1px)] sm:px-[calc(--spacing(3)-1px)]',
  focusRing,
  'data-disabled:opacity-50',
]

const iconSlot = [
  '*:data-[slot=icon]:shrink-0 *:data-[slot=icon]:size-5 sm:*:data-[slot=icon]:size-4',
  '*:data-[slot=icon]:self-center *:data-[slot=icon]:-mx-0.5 *:data-[slot=icon]:my-0.5 sm:*:data-[slot=icon]:my-1',
  '*:data-[slot=icon]:text-(--icon) forced-colors:[--icon:ButtonText] forced-colors:data-hover:[--icon:ButtonText]',
]

const neutralIcon =
  '[--icon:var(--color-zinc-500)] data-hover:[--icon:var(--color-zinc-700)] data-active:[--icon:var(--color-zinc-700)] dark:data-hover:[--icon:var(--color-zinc-400)] dark:data-active:[--icon:var(--color-zinc-400)]'

const solidColors = [
  'text-(--button-text) dark:text-[var(--button-dark-text,var(--button-text))]',
  '[--bg:var(--button-background)] dark:[--bg:var(--button-dark-background,var(--button-background))]',
  '[--border:var(--button-border)] data-hover:[--border:var(--button-border-hover,var(--button-border))] data-active:[--border:var(--button-border-hover,var(--button-border))]',
  '[--overlay:var(--button-hover-overlay)] dark:[--overlay:var(--button-dark-hover-overlay,var(--button-hover-overlay))]',
  '[--icon:var(--button-icon)] data-hover:[--icon:var(--button-icon-hover,var(--button-icon))] data-active:[--icon:var(--button-icon-hover,var(--button-icon))]',
  'dark:[--icon:var(--button-dark-icon,var(--button-icon))] dark:data-hover:[--icon:var(--button-dark-icon-hover,var(--button-icon-hover,var(--button-icon)))] dark:data-active:[--icon:var(--button-dark-icon-hover,var(--button-icon-hover,var(--button-icon)))]',
]

const solidBorder = 'border-transparent bg-(--border) dark:border-white/5 dark:bg-(--bg)'

const solidBackgroundLayer =
  'before:-z-10 before:absolute before:inset-0 before:rounded-[calc(var(--radius-lg)-1px)] before:bg-(--bg) before:shadow-sm data-disabled:before:shadow-none dark:before:hidden'

const solidHighlightLayer = [
  'after:-z-10 after:absolute after:inset-0 dark:after:-inset-px',
  'after:rounded-[calc(var(--radius-lg)-1px)] dark:after:rounded-lg',
  'after:shadow-[inset_0_1px_--theme(--color-white/15%)] data-disabled:after:shadow-none',
  'data-hover:after:bg-(--overlay) data-active:after:bg-(--overlay)',
]

const variants = {
  solid: [solidColors, solidBorder, solidBackgroundLayer, solidHighlightLayer],
  outline: [
    'border-zinc-950/10 text-zinc-950 data-hover:bg-zinc-950/2.5 data-active:bg-zinc-950/2.5',
    'dark:border-white/15 dark:text-white dark:data-hover:bg-white/5 dark:data-active:bg-white/5',
    neutralIcon,
  ],
  plain: [
    'border-transparent text-zinc-950 data-hover:bg-zinc-950/5 data-active:bg-zinc-950/5',
    'dark:text-white dark:data-hover:bg-white/10 dark:data-active:bg-white/10',
    neutralIcon,
  ],
}

const props = defineProps<{
  color?: keyof typeof colors
  outline?: boolean
  plain?: boolean
  href?: string
  disabled?: boolean
}>()

const variant = computed<keyof typeof variants>(() => (props.outline ? 'outline' : props.plain ? 'plain' : 'solid'))
const classes = computed(() => [base, iconSlot, variants[variant.value]])
const styles = computed(() => (variant.value === 'solid' ? cssVariables(colors[props.color ?? 'accent']) : undefined))
</script>

<template>
  <Link v-if="typeof href === 'string'" :href="href" :style="styles" :class="classes">
    <TouchTarget><slot /></TouchTarget>
  </Link>
  <button
    v-else
    v-interactions
    type="button"
    :style="styles"
    :class="[classes, 'cursor-default']"
    :disabled="disabled"
    :data-disabled="disabled ? '' : undefined"
  >
    <TouchTarget><slot /></TouchTarget>
  </button>
</template>

Component API

PropDefaultDescription
Buttonrenders a <button> element
coloraccentFill of the solid style. `accent` reads the --accent-* theme variables and falls back to blue.
outlinefalseBordered style with a transparent background, for secondary actions.
plainfalseNo border and no background until hover, for the quietest actions.
href-Renders an anchor through the Link component instead of a button.
disabledfalseBlocks interaction and fades the button to half opacity.

Hierarchy

Give each screen one solid button: the action it exists for. Outline suits the actions people take next to it, and plain the ones they can safely ignore. When every button is solid, none of them reads as the next step.

Primary action

With no style prop, the button is solid and filled with the accent color. Name it after the result, not after the gesture:

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

export default function Example() {
  return <Button>Create invoice</Button>
}
<script setup lang="ts">
import Button from '@/components/button.vue'
</script>

<template>
  <Button>Create invoice</Button>
</template>

Secondary action

outline keeps the button visible without competing with the primary one. Exports, filters and settings links usually belong here:

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

export default function Example() {
  return <Button outline>Export CSV</Button>
}
<script setup lang="ts">
import Button from '@/components/button.vue'
</script>

<template>
  <Button outline>Export CSV</Button>
</template>

Low-emphasis action

plain shows no border or fill until hover. Use it for dismissals and optional steps that should not pull the eye:

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

export default function Example() {
  return <Button plain>Skip for now</Button>
}
<script setup lang="ts">
import Button from '@/components/button.vue'
</script>

<template>
  <Button plain>Skip for now</Button>
</template>

The three styles side by side, ordered by weight. The solid button sits last, where the eye ends up after reading the form:

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

export default function Example() {
  return (
    <div className="flex items-center gap-3">
      <Button plain>Discard</Button>
      <Button outline>Save as draft</Button>
      <Button>Send invoice</Button>
    </div>
  )
}
<script setup lang="ts">
import Button from '@/components/button.vue'
</script>

<template>
  <div class="flex items-center gap-3">
    <Button plain>Discard</Button>
    <Button outline>Save as draft</Button>
    <Button>Send invoice</Button>
  </div>
</template>

Color

Color changes the solid style only: outline and plain stay neutral. Keep the accent for the main action and reach for other colors when they carry a meaning.

Destructive action

Red tells people the click removes something. Keep it for the button that actually deletes, and confirm first when the loss is permanent:

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

export default function Example() {
  return <Button color="red">Delete webhook</Button>
}
<script setup lang="ts">
import Button from '@/components/button.vue'
</script>

<template>
  <Button color="red">Delete webhook</Button>
</template>

Neutral primary

On a page already busy with the accent, a near-black primary still stands out. dark/zinc turns a lighter zinc in dark mode so it stays visible:

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

export default function Example() {
  return <Button color="dark/zinc">Upgrade plan</Button>
}
<script setup lang="ts">
import Button from '@/components/button.vue'
</script>

<template>
  <Button color="dark/zinc">Upgrade plan</Button>
</template>

Available colors

Three values adapt to the color scheme and swap their fill in dark mode to keep enough contrast with the page:

The twenty others keep essentially the same fill in both schemes. In dark mode, every solid button trades its drop shadow for a faint white border:

Content

Icons are sized for the 16px solid Heroicons set and scale up to 20px on small screens. Any other icon needs data-slot="icon" to get the same size, spacing and muted color.

Leading icon

An icon before the label helps people spot a creation or an edit action in a crowded toolbar:

import { Button } from '@/components/button'
import { PlusIcon } from '@heroicons/react/16/solid'

export default function Example() {
  return (
    <Button>
      <PlusIcon />
      New customer
    </Button>
  )
}
<script setup lang="ts">
import Button from '@/components/button.vue'
import { PlusIcon } from '@heroicons/vue/16/solid'
</script>

<template>
  <Button>
    <PlusIcon />
    New customer
  </Button>
</template>

Trailing icon

An arrow after the label hints that the button moves people forward, to another page or the next step:

import { Button } from '@/components/button'
import { ArrowRightIcon } from '@heroicons/react/16/solid'

export default function Example() {
  return (
    <Button outline>
      View payouts
      <ArrowRightIcon />
    </Button>
  )
}
<script setup lang="ts">
import Button from '@/components/button.vue'
import { ArrowRightIcon } from '@heroicons/vue/16/solid'
</script>

<template>
  <Button outline>
    View payouts
    <ArrowRightIcon />
  </Button>
</template>

States

Unavailable action

disabled blocks clicks and fades the button. A faded button does not say why, so put the reason next to it:

disabled sets the native attribute, blocks clicks and fades the button. A faded button does not say why, so put the reason next to it:

Add a bank account to retry.

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

export default function Example() {
  return (
    <div className="flex flex-col items-center gap-2">
      <Button disabled>Retry payout</Button>
      <p className="text-sm/6 text-zinc-500 dark:text-zinc-400">Add a bank account to retry.</p>
    </div>
  )
}
<script setup lang="ts">
import Button from '@/components/button.vue'
</script>

<template>
  <div class="flex flex-col items-center gap-2">
    <Button disabled>Retry payout</Button>
    <p class="text-sm/6 text-zinc-500 dark:text-zinc-400">Add a bank account to retry.</p>
  </div>
</template>

A button with an href is a link and has no disabled state. Render a regular button instead while the destination is not available.

Button that opens a page

With href, the button renders an anchor through your Link component. Browsers then treat it as a real link: it can open in a new tab and shows its URL on hover:

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

export default function Example() {
  return (
    <Button outline href="/billing/plans">
      Compare plans
    </Button>
  )
}
<script setup lang="ts">
import Button from '@/components/button.vue'
</script>

<template>
  <Button outline href="/billing/plans">Compare plans</Button>
</template>

Accessibility

The focus outline only shows for keyboard focus, not after a mouse click. On touch screens, an invisible area of at least 44px surrounds each button so small ones stay easy to tap.

Icon without a label

A button with only an icon has no text for screen readers to announce. Name it with aria-label:

import { Button } from '@/components/button'
import { ClipboardDocumentIcon } from '@heroicons/react/16/solid'

export default function Example() {
  return (
    <Button plain aria-label="Copy API key">
      <ClipboardDocumentIcon />
    </Button>
  )
}
<script setup lang="ts">
import Button from '@/components/button.vue'
import { ClipboardDocumentIcon } from '@heroicons/vue/16/solid'
</script>

<template>
  <Button plain aria-label="Copy API key">
    <ClipboardDocumentIcon />
  </Button>
</template>