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.
<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>View source 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
Run from your application’s directory. Klean detects your framework and copies the matching source.
npx klean-ui add buttonpnpm dlx klean-ui add buttonyarn dlx klean-ui add buttonbunx klean-ui add buttonUsage
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.
View source 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.
View source 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
Add an offset shadow for an expressive action or reduce padding for a compact toolbar. These treatments use ordinary Tailwind classes.
View source 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.