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
<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
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.
npx klean-ui add buttonpnpm dlx klean-ui add buttonyarn dlx klean-ui add buttonbunx klean-ui add button- No initializer or configuration file
- No framework, alias, or theme questions
- No Klean runtime dependency
Usage
<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
<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
| Input | Default | Purpose |
|---|---|---|
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. |
disabled | false | Native 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
Linkfor internal Inertia navigation.
Do not wrap a Button inside an anchor. Render Button as the anchor or Link.
semantic-usage.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>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
<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>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.
Related components
- 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.