Skip to content

Button

Button is a native-first action primitive. It owns truthful element selection, safe button type, disabled semantics, attribute forwarding, a stable data-slot, and conflict-aware class composition. Your application owns loading state, navigation decisions, business language, and the visual recipe.

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

Button.vue

Installation

The standard Boring Stack path requires no init, klean-ui.json, alias prompt, or generated cn.js. The installer detects the framework and conventional source paths.

Run one command from a Boring Stack application. Klean detects the framework and conventional destination, then adds the framework-native source and its direct dependencies.

Terminal
npx klean-ui add button

  • No initializer or configuration file
  • No framework, alias, or theme questions
  • No Klean runtime dependency

Usage

Choose a framework. The preference is remembered across Klean component pages, while the command remains the same because the CLI detects the application.

SaveButton.vue
<script setup>
import Button from '@/components/ui/button/Button.vue'
</script>

<template>
  <Button type="submit">Save changes</Button>
</template>

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

Button intentionally has no loading prop. The real request state owns disabled and aria-busy, the visible label says what is happening, and Spinner supplies a decorative mark. A product such as Slipway can pass its own animated mascot through Spinner without changing Button's API.

pending-button.vue

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.
classThe visual API. Caller Tailwind classes merge last.
default slotLabel, 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.

semantic-usage.vue
Pass the Boring Stack Link component for internal navigation.

Product recipes

Klean's neutral default stays motionless and uses tonal feedback. Hagfish deliberately adds an offset-shadow press; Slipway keeps its dense operational controls quiet. Those opinions are explicit Tailwind classes, not Klean variants.

product-buttons.vue
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:

Button.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>

  • 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.