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
Choose a framework. The preference is remembered across Klean component pages, while the command remains the same because the CLI detects the application.
<script setup>
import Button from '@/components/ui/button/Button.vue'
</script>
<template>
<Button type="submit">Save changes</Button>
</template>
import Button from '@/components/ui/button/Button.jsx'
export default function SaveButton() {
return <Button type="submit">Save changes</Button>
}
<script>
import Button from '@/components/ui/button/Button.svelte'
</script>
<Button type="submit">Save changes</Button>
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.
Complete framework source
The live preview demonstrates the shared semantic contract. Copy the complete framework-native source that belongs in your application:
<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>
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
<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}
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.