Skip to content

Button ​

Use Button for actions and links. It renders the appropriate native element, handles disabled behavior, and lets your Tailwind classes take precedence.

There are intentionally no variant, size, color, tone, radius, elevated, or loading props.

Preview Live · Vue
Usage
vue
<script setup>
import Button from '@/components/ui/button/Button.vue'
</script>

<template>
  <Button type="submit">Save changes</Button>
</template>
jsx
import Button from '@/components/ui/button/Button.jsx'

export default function SaveButton() {
  return <Button type="submit">Save changes</Button>
}
svelte
<script>
  import Button from '@/components/ui/button/Button.svelte'
</script>

<Button type="submit">Save changes</Button>
View source Button.vue
Button.vue
vue
<script setup>
import { computed, useAttrs } from 'vue'
import { twMerge } from 'tailwind-merge'

defineOptions({
  inheritAttrs: false
})

const props = defineProps({
  /**
   * The rendered element. Pass an Inertia Link component directly when the
   * destination should retain navigation semantics.
   */
  as: {
    type: [String, Object, Function],
    default: 'button',
    validator: (value) =>
      typeof value !== 'string' || ['button', 'a'].includes(value)
  },
  /** Native button type. Ignored when `as` does not render a button. */
  type: {
    type: String,
    default: 'button',
    validator: (value) => ['button', 'submit', 'reset'].includes(value)
  },
  /**
   * Uses the native disabled attribute for buttons and accessible disabled
   * link semantics for anchors or component links.
   */
  disabled: {
    type: Boolean,
    default: false
  }
})

const attrs = useAttrs()

const baseClasses = [
  'inline-flex min-h-11 min-w-11 cursor-pointer select-none items-center justify-center gap-2 rounded-md no-underline',
  'bg-gray-950 px-4 py-2 text-sm font-medium text-nowrap text-white',
  'transition-colors duration-150 ease-out',
  'hover:bg-gray-800 active:bg-gray-700',
  'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-500 dark:focus-visible:outline-gray-400',
  'disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50',
  'aria-disabled:pointer-events-none aria-disabled:cursor-not-allowed aria-disabled:opacity-50',
  'dark:bg-white dark:text-gray-950 dark:hover:bg-gray-100 dark:active:bg-gray-200',
  'motion-reduce:transition-none'
]

const isNativeButton = computed(() => props.as === 'button')

const forwardedAttrs = computed(() => {
  const {
    class: _class,
    tabindex: _tabindex,
    'aria-disabled': _ariaDisabled,
    'data-slot': _dataSlot,
    ...rest
  } = attrs

  return rest
})

const buttonClasses = computed(() => twMerge(baseClasses, attrs.class))

const managedAriaDisabled = computed(() => {
  if (isNativeButton.value) return undefined
  if (props.disabled) return 'true'
  return attrs['aria-disabled']
})

const managedTabindex = computed(() => {
  if (!isNativeButton.value && props.disabled) return -1
  return attrs.tabindex
})

function guardDisabledClick(event) {
  if (!props.disabled || isNativeButton.value) return

  event.preventDefault()
  event.stopImmediatePropagation()
}

function guardDisabledKeydown(event) {
  if (!['Enter', ' '].includes(event.key)) return

  guardDisabledClick(event)
}
</script>

<template>
  <component
    :is="as"
    v-bind="forwardedAttrs"
    :type="isNativeButton ? type : undefined"
    :disabled="isNativeButton ? disabled : undefined"
    :aria-disabled="managedAriaDisabled"
    :tabindex="managedTabindex"
    :data-disabled="disabled ? '' : undefined"
    data-slot="button"
    :class="buttonClasses"
    @click.capture="guardDisabledClick"
    @keydown.capture="guardDisabledKeydown"
  >
    <slot />
  </component>
</template>

Installation ​

Run from your application’s directory. Klean detects your framework and copies the matching source.

Terminal
npx klean-ui add button

Source you own. Edit the installed files to make them yours.

Usage ​

Copy the example above for your framework.

Visual styling stays in the framework's ordinary class API. If the same product treatment repeats, create an application-owned component such as PrimaryButton.vue using Button as its semantic base.

Pending actions ​

Bind disabled and aria-busy to the request state, describe the work in the visible label, and add a decorative Spinner. You can pass your own loading mark to Spinner.

Preview Live · Vue
View source pending-button.vue
pending-button.vue
vue
<script setup>
import Button from '@/components/ui/button/Button.vue'
import Spinner from '@/components/ui/spinner/Spinner.vue'
import SlippyLoader from '@/components/SlippyLoader.vue'
</script>

<template>
  <Button
    type="submit"
    :disabled="form.processing"
    :aria-busy="form.processing"
  >
    <Spinner v-if="form.processing" class="size-4">
      <SlippyLoader />
    </Spinner>
    {{ form.processing ? 'Deploying service…' : 'Deploy service' }}
  </Button>
</template>

API ​

InputDefaultPurpose
as'button'Render a native button, native a, or framework component such as Inertia Link.
type'button'Native button behavior: button, submit, or reset. Ignored for non-buttons.
disabledfalseNative disabled behavior for buttons and accessible disabled semantics for links.
class—The visual API. Caller Tailwind classes merge last.
default slot—Label, decorative icon, spinner, or other accessible content.

Semantic elements ​

Appearance does not decide semantics. Use one truthful interactive element:

  • <button> for actions, local state, dialogs, and form submission;
  • <a> for external navigation, downloads, OAuth, or full-page requests;
  • the Boring Stack Link for internal Inertia navigation.

Do not wrap a Button inside an anchor. Render Button as the anchor or Link.

Preview Live · Vue
View source semantic-usage.vue
semantic-usage.vue
vue
<script setup>
import { Link } from '@inertiajs/vue3'
import Button from '@/components/ui/button/Button.vue'
</script>

<template>
  <Button type="button">Open dialog</Button>
  <Button type="submit">Save changes</Button>
  <Button as="a" href="https://sailsjs.com">Read Sails docs</Button>
  <Button :as="Link" href="/projects">View projects</Button>
</template>

Pass the Boring Stack Link component for internal navigation.

Product recipes ​

Add an offset shadow for an expressive action or reduce padding for a compact toolbar. These treatments use ordinary Tailwind classes.

Preview Live · Vue
View source product-buttons.vue
product-buttons.vue
vue
<script setup>
import Button from '@/components/ui/button/Button.vue'
</script>

<template>
  <!-- Hagfish owns this expressive motion. -->
  <Button
    class="border-2 border-black bg-black px-6 text-white hover:bg-white hover:text-black hover:shadow-[4px_4px_0_0_#000] active:translate-x-1 active:translate-y-1 active:shadow-none"
  >
    Send invoice
  </Button>

  <!-- Slipway stays compact and motionless. -->
  <Button class="min-h-9 min-w-0 rounded-md px-3 py-1.5 text-sm">
    Deploy
  </Button>
</template>

Every class shown here is built into Tailwind or written as an explicit arbitrary value. There are no hidden Klean theme utilities.

Accessibility contract ​

  • The default is a real <button type="button">.
  • Submit and reset behavior remain native.
  • Keyboard focus is visible without relying on color alone.
  • Icon-only usage needs an accessible name such as aria-label.
  • Disabled links leave the tab order and cannot activate.
  • Processing indicators are decorative when the visible label already describes the state.
  • Base interaction is motionless, and transitions are removed for reduced-motion preferences.

Complete framework source ​

The live preview demonstrates the shared semantic contract. Copy the complete framework-native source that belongs in your application:

vue
<script setup>
import { computed, useAttrs } from 'vue'
import { twMerge } from 'tailwind-merge'

defineOptions({
  inheritAttrs: false
})

const props = defineProps({
  /**
   * The rendered element. Pass an Inertia Link component directly when the
   * destination should retain navigation semantics.
   */
  as: {
    type: [String, Object, Function],
    default: 'button',
    validator: (value) =>
      typeof value !== 'string' || ['button', 'a'].includes(value)
  },
  /** Native button type. Ignored when `as` does not render a button. */
  type: {
    type: String,
    default: 'button',
    validator: (value) => ['button', 'submit', 'reset'].includes(value)
  },
  /**
   * Uses the native disabled attribute for buttons and accessible disabled
   * link semantics for anchors or component links.
   */
  disabled: {
    type: Boolean,
    default: false
  }
})

const attrs = useAttrs()

const baseClasses = [
  'inline-flex min-h-11 min-w-11 cursor-pointer select-none items-center justify-center gap-2 rounded-md no-underline',
  'bg-gray-950 px-4 py-2 text-sm font-medium text-nowrap text-white',
  'transition-colors duration-150 ease-out',
  'hover:bg-gray-800 active:bg-gray-700',
  'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-500 dark:focus-visible:outline-gray-400',
  'disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50',
  'aria-disabled:pointer-events-none aria-disabled:cursor-not-allowed aria-disabled:opacity-50',
  'dark:bg-white dark:text-gray-950 dark:hover:bg-gray-100 dark:active:bg-gray-200',
  'motion-reduce:transition-none'
]

const isNativeButton = computed(() => props.as === 'button')

const forwardedAttrs = computed(() => {
  const {
    class: _class,
    tabindex: _tabindex,
    'aria-disabled': _ariaDisabled,
    'data-slot': _dataSlot,
    ...rest
  } = attrs

  return rest
})

const buttonClasses = computed(() => twMerge(baseClasses, attrs.class))

const managedAriaDisabled = computed(() => {
  if (isNativeButton.value) return undefined
  if (props.disabled) return 'true'
  return attrs['aria-disabled']
})

const managedTabindex = computed(() => {
  if (!isNativeButton.value && props.disabled) return -1
  return attrs.tabindex
})

function guardDisabledClick(event) {
  if (!props.disabled || isNativeButton.value) return

  event.preventDefault()
  event.stopImmediatePropagation()
}

function guardDisabledKeydown(event) {
  if (!['Enter', ' '].includes(event.key)) return

  guardDisabledClick(event)
}
</script>

<template>
  <component
    :is="as"
    v-bind="forwardedAttrs"
    :type="isNativeButton ? type : undefined"
    :disabled="isNativeButton ? disabled : undefined"
    :aria-disabled="managedAriaDisabled"
    :tabindex="managedTabindex"
    :data-disabled="disabled ? '' : undefined"
    data-slot="button"
    :class="buttonClasses"
    @click.capture="guardDisabledClick"
    @keydown.capture="guardDisabledKeydown"
  >
    <slot />
  </component>
</template>
jsx
import { forwardRef } from 'react'
import { twMerge } from 'tailwind-merge'

const BASE_CLASSES = [
  'inline-flex min-h-11 min-w-11 cursor-pointer select-none items-center justify-center gap-2 rounded-md no-underline',
  'bg-gray-950 px-4 py-2 text-sm font-medium text-nowrap text-white',
  'transition-colors duration-150 ease-out',
  'hover:bg-gray-800 active:bg-gray-700',
  'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-500 dark:focus-visible:outline-gray-400',
  'disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50',
  'aria-disabled:pointer-events-none aria-disabled:cursor-not-allowed aria-disabled:opacity-50',
  'dark:bg-white dark:text-gray-950 dark:hover:bg-gray-100 dark:active:bg-gray-200',
  'motion-reduce:transition-none'
]

const Button = forwardRef(function Button(
  {
    as: Component = 'button',
    type = 'button',
    disabled = false,
    className,
    onClick,
    onKeyDown,
    tabIndex,
    'aria-disabled': ariaDisabled,
    children,
    ...props
  },
  ref
) {
  const isNativeButton = Component === 'button'

  function handleClick(event) {
    if (disabled && !isNativeButton) {
      event.preventDefault()
      event.stopPropagation()
      return
    }

    onClick?.(event)
  }

  function handleKeyDown(event) {
    if (disabled && !isNativeButton && ['Enter', ' '].includes(event.key)) {
      event.preventDefault()
      event.stopPropagation()
      return
    }

    onKeyDown?.(event)
  }

  return (
    <Component
      {...props}
      ref={ref}
      type={isNativeButton ? type : undefined}
      disabled={isNativeButton ? disabled : undefined}
      aria-disabled={
        isNativeButton ? undefined : disabled ? true : ariaDisabled
      }
      tabIndex={!isNativeButton && disabled ? -1 : tabIndex}
      data-disabled={disabled ? '' : undefined}
      data-slot="button"
      className={twMerge(BASE_CLASSES, className)}
      onClick={handleClick}
      onKeyDown={handleKeyDown}
    >
      {children}
    </Component>
  )
})

export default Button
svelte
<script>
  import { twMerge } from "tailwind-merge";

  const BASE_CLASSES = [
    "inline-flex min-h-11 min-w-11 cursor-pointer select-none items-center justify-center gap-2 rounded-md no-underline",
    "bg-gray-950 px-4 py-2 text-sm font-medium text-nowrap text-white",
    "transition-colors duration-150 ease-out",
    "hover:bg-gray-800 active:bg-gray-700",
    "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-500 dark:focus-visible:outline-gray-400",
    "disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
    "aria-disabled:pointer-events-none aria-disabled:cursor-not-allowed aria-disabled:opacity-50",
    "dark:bg-white dark:text-gray-950 dark:hover:bg-gray-100 dark:active:bg-gray-200",
    "motion-reduce:transition-none",
  ];

  let {
    as = "button",
    type = "button",
    disabled = false,
    class: className,
    onclick,
    onkeydown,
    tabindex,
    "aria-disabled": ariaDisabled,
    children,
    ...props
  } = $props();

  let isNativeButton = $derived(as === "button");

  function handleClick(event) {
    if (disabled && !isNativeButton) {
      event.preventDefault();
      event.stopImmediatePropagation();
      return;
    }

    onclick?.(event);
  }

  function handleKeydown(event) {
    if (disabled && !isNativeButton && ["Enter", " "].includes(event.key)) {
      event.preventDefault();
      event.stopImmediatePropagation();
      return;
    }

    onkeydown?.(event);
  }
</script>

{#if typeof as === "string"}
  <svelte:element
    this={as}
    {...props}
    type={isNativeButton ? type : undefined}
    disabled={isNativeButton ? disabled : undefined}
    aria-disabled={isNativeButton
      ? undefined
      : disabled
        ? "true"
        : ariaDisabled}
    tabindex={!isNativeButton && disabled ? -1 : tabindex}
    data-disabled={disabled ? "" : undefined}
    data-slot="button"
    class={twMerge(BASE_CLASSES, className)}
    onclick={handleClick}
    onkeydown={handleKeydown}
  >
    {@render children?.()}
  </svelte:element>
{:else}
  {@const Component = as}
  <Component
    {...props}
    type={undefined}
    disabled={undefined}
    aria-disabled={disabled ? "true" : ariaDisabled}
    tabindex={disabled ? -1 : tabindex}
    data-disabled={disabled ? "" : undefined}
    data-slot="button"
    class={twMerge(BASE_CLASSES, className)}
    onclick={handleClick}
    onkeydown={handleKeydown}
  >
    {@render children?.()}
  </Component>
{/if}
  • Tooltip — adds short supplementary text without changing the button or link semantics.
  • Spinner — a decorative pending mark inside a truthfully labelled busy button.
  • Slide — higher-friction confirmation for consequential actions.
  • Menu — a compact list of actions and destinations.
  • Popover — a non-modal surface invoked by a button.
  • Dialog — a modal task or confirmation invoked by a native command.
  • Input — native form input with caller-owned labels and errors.

All open source projects are released under the MIT License.