Button
Six variants, four sizes, icon-only sizes, and a link mode — with a spring press-scale action that collapses under reduced-motion.
Install
import { Button } from '$lib/components/ui/button'; Variants
button-variants.svelte
<script lang="ts">
import { Button } from '$lib/components/ui/button';
</script>
<Button>Label</Button>
<Button variant="outline">Outline</Button>
<Button variant="secondary">Secondary</Button>
<Button variant="ghost">Ghost</Button>
<Button variant="destructive">Destructive</Button>
<Button variant="link">Link</Button>States
default
outline
secondary
ghost
destructive
link
button.svelte
<script lang="ts" module>
import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from 'svelte/elements';
import { type VariantProps, tv } from 'tailwind-variants';
export const buttonVariants = tv({
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 border border-transparent bg-clip-padding text-sm font-medium focus-visible:ring-(length:--ring-width) aria-invalid:ring-(length:--ring-width) [&_svg:not([class*='size-'])]:size-4 group/button inline-flex shrink-0 items-center justify-center whitespace-nowrap transition-[color,background-color,border-color,box-shadow,opacity] duration-(--motion-duration-fast) ease-out outline-none select-none disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/80',
outline:
'border-border bg-transparent hover:bg-substrate-hover hover:text-foreground aria-expanded:bg-substrate-active aria-expanded:text-foreground',
secondary:
'bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground',
ghost:
'text-muted-foreground hover:bg-substrate-hover hover:text-foreground aria-expanded:bg-substrate-active aria-expanded:text-foreground',
'brand-ghost':
'text-primary hover:bg-primary/10 hover:text-primary aria-expanded:bg-primary/15',
destructive:
'bg-destructive/10 hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/20 text-destructive focus-visible:border-destructive/40 dark:hover:bg-destructive/30',
link: "text-primary underline-offset-4 hover:underline transition-[color,font-variation-settings] duration-(--motion-duration-fast) ease-(--motion-ease-out) [font-variation-settings:'wght'_500] hover:[font-variation-settings:'wght'_600]"
},
size: {
default:
'h-9 gap-1.5 px-3 has-data-[icon=inline-end]:pr-2.5 has-data-[icon=inline-start]:pl-2.5',
xs: "h-6 gap-1 px-2.5 text-xs has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2 [&_svg:not([class*='size-'])]:size-3",
sm: 'h-8 gap-1 px-3 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
lg: 'h-10 gap-1.5 px-4 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3',
icon: 'size-9',
'icon-xs': "size-6 [&_svg:not([class*='size-'])]:size-3",
'icon-sm': 'size-8',
'icon-lg': 'size-10'
},
radius: {
default: 'rounded-(--radius)',
pill: 'rounded-(--radius-control)'
}
},
defaultVariants: {
variant: 'default',
size: 'default',
radius: 'pill'
}
});
export type ButtonVariant = VariantProps<typeof buttonVariants>['variant'];
export type ButtonSize = VariantProps<typeof buttonVariants>['size'];
export type ButtonRadius = VariantProps<typeof buttonVariants>['radius'];
export type ButtonProps = WithElementRef<HTMLButtonAttributes> &
WithElementRef<HTMLAnchorAttributes> & {
variant?: ButtonVariant;
size?: ButtonSize;
radius?: ButtonRadius;
};
</script>
<script lang="ts">
import { pressScale } from '$lib/motion';
let {
class: className,
variant = 'default',
size = 'default',
radius = 'pill',
ref = $bindable(null),
href = undefined,
type = 'button',
disabled,
children,
...restProps
}: ButtonProps = $props();
</script>
{#if href}
<a
bind:this={ref}
data-slot="button"
class={cn(buttonVariants({ variant, size, radius }), className)}
href={disabled ? undefined : href}
aria-disabled={disabled}
role={disabled ? 'link' : undefined}
tabindex={disabled ? -1 : undefined}
use:pressScale
{...restProps}
>
{@render children?.()}
</a>
{:else}
<button
bind:this={ref}
data-slot="button"
class={cn(buttonVariants({ variant, size, radius }), className)}
{type}
{disabled}
use:pressScale
{...restProps}
>
{@render children?.()}
</button>
{/if}
Sizes
button.svelte
<script lang="ts" module>
import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from 'svelte/elements';
import { type VariantProps, tv } from 'tailwind-variants';
export const buttonVariants = tv({
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 border border-transparent bg-clip-padding text-sm font-medium focus-visible:ring-(length:--ring-width) aria-invalid:ring-(length:--ring-width) [&_svg:not([class*='size-'])]:size-4 group/button inline-flex shrink-0 items-center justify-center whitespace-nowrap transition-[color,background-color,border-color,box-shadow,opacity] duration-(--motion-duration-fast) ease-out outline-none select-none disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/80',
outline:
'border-border bg-transparent hover:bg-substrate-hover hover:text-foreground aria-expanded:bg-substrate-active aria-expanded:text-foreground',
secondary:
'bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground',
ghost:
'text-muted-foreground hover:bg-substrate-hover hover:text-foreground aria-expanded:bg-substrate-active aria-expanded:text-foreground',
'brand-ghost':
'text-primary hover:bg-primary/10 hover:text-primary aria-expanded:bg-primary/15',
destructive:
'bg-destructive/10 hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/20 text-destructive focus-visible:border-destructive/40 dark:hover:bg-destructive/30',
link: "text-primary underline-offset-4 hover:underline transition-[color,font-variation-settings] duration-(--motion-duration-fast) ease-(--motion-ease-out) [font-variation-settings:'wght'_500] hover:[font-variation-settings:'wght'_600]"
},
size: {
default:
'h-9 gap-1.5 px-3 has-data-[icon=inline-end]:pr-2.5 has-data-[icon=inline-start]:pl-2.5',
xs: "h-6 gap-1 px-2.5 text-xs has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2 [&_svg:not([class*='size-'])]:size-3",
sm: 'h-8 gap-1 px-3 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
lg: 'h-10 gap-1.5 px-4 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3',
icon: 'size-9',
'icon-xs': "size-6 [&_svg:not([class*='size-'])]:size-3",
'icon-sm': 'size-8',
'icon-lg': 'size-10'
},
radius: {
default: 'rounded-(--radius)',
pill: 'rounded-(--radius-control)'
}
},
defaultVariants: {
variant: 'default',
size: 'default',
radius: 'pill'
}
});
export type ButtonVariant = VariantProps<typeof buttonVariants>['variant'];
export type ButtonSize = VariantProps<typeof buttonVariants>['size'];
export type ButtonRadius = VariantProps<typeof buttonVariants>['radius'];
export type ButtonProps = WithElementRef<HTMLButtonAttributes> &
WithElementRef<HTMLAnchorAttributes> & {
variant?: ButtonVariant;
size?: ButtonSize;
radius?: ButtonRadius;
};
</script>
<script lang="ts">
import { pressScale } from '$lib/motion';
let {
class: className,
variant = 'default',
size = 'default',
radius = 'pill',
ref = $bindable(null),
href = undefined,
type = 'button',
disabled,
children,
...restProps
}: ButtonProps = $props();
</script>
{#if href}
<a
bind:this={ref}
data-slot="button"
class={cn(buttonVariants({ variant, size, radius }), className)}
href={disabled ? undefined : href}
aria-disabled={disabled}
role={disabled ? 'link' : undefined}
tabindex={disabled ? -1 : undefined}
use:pressScale
{...restProps}
>
{@render children?.()}
</a>
{:else}
<button
bind:this={ref}
data-slot="button"
class={cn(buttonVariants({ variant, size, radius }), className)}
{type}
{disabled}
use:pressScale
{...restProps}
>
{@render children?.()}
</button>
{/if}
With icons
button.svelte
<script lang="ts" module>
import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from 'svelte/elements';
import { type VariantProps, tv } from 'tailwind-variants';
export const buttonVariants = tv({
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 border border-transparent bg-clip-padding text-sm font-medium focus-visible:ring-(length:--ring-width) aria-invalid:ring-(length:--ring-width) [&_svg:not([class*='size-'])]:size-4 group/button inline-flex shrink-0 items-center justify-center whitespace-nowrap transition-[color,background-color,border-color,box-shadow,opacity] duration-(--motion-duration-fast) ease-out outline-none select-none disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/80',
outline:
'border-border bg-transparent hover:bg-substrate-hover hover:text-foreground aria-expanded:bg-substrate-active aria-expanded:text-foreground',
secondary:
'bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground',
ghost:
'text-muted-foreground hover:bg-substrate-hover hover:text-foreground aria-expanded:bg-substrate-active aria-expanded:text-foreground',
'brand-ghost':
'text-primary hover:bg-primary/10 hover:text-primary aria-expanded:bg-primary/15',
destructive:
'bg-destructive/10 hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/20 text-destructive focus-visible:border-destructive/40 dark:hover:bg-destructive/30',
link: "text-primary underline-offset-4 hover:underline transition-[color,font-variation-settings] duration-(--motion-duration-fast) ease-(--motion-ease-out) [font-variation-settings:'wght'_500] hover:[font-variation-settings:'wght'_600]"
},
size: {
default:
'h-9 gap-1.5 px-3 has-data-[icon=inline-end]:pr-2.5 has-data-[icon=inline-start]:pl-2.5',
xs: "h-6 gap-1 px-2.5 text-xs has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2 [&_svg:not([class*='size-'])]:size-3",
sm: 'h-8 gap-1 px-3 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
lg: 'h-10 gap-1.5 px-4 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3',
icon: 'size-9',
'icon-xs': "size-6 [&_svg:not([class*='size-'])]:size-3",
'icon-sm': 'size-8',
'icon-lg': 'size-10'
},
radius: {
default: 'rounded-(--radius)',
pill: 'rounded-(--radius-control)'
}
},
defaultVariants: {
variant: 'default',
size: 'default',
radius: 'pill'
}
});
export type ButtonVariant = VariantProps<typeof buttonVariants>['variant'];
export type ButtonSize = VariantProps<typeof buttonVariants>['size'];
export type ButtonRadius = VariantProps<typeof buttonVariants>['radius'];
export type ButtonProps = WithElementRef<HTMLButtonAttributes> &
WithElementRef<HTMLAnchorAttributes> & {
variant?: ButtonVariant;
size?: ButtonSize;
radius?: ButtonRadius;
};
</script>
<script lang="ts">
import { pressScale } from '$lib/motion';
let {
class: className,
variant = 'default',
size = 'default',
radius = 'pill',
ref = $bindable(null),
href = undefined,
type = 'button',
disabled,
children,
...restProps
}: ButtonProps = $props();
</script>
{#if href}
<a
bind:this={ref}
data-slot="button"
class={cn(buttonVariants({ variant, size, radius }), className)}
href={disabled ? undefined : href}
aria-disabled={disabled}
role={disabled ? 'link' : undefined}
tabindex={disabled ? -1 : undefined}
use:pressScale
{...restProps}
>
{@render children?.()}
</a>
{:else}
<button
bind:this={ref}
data-slot="button"
class={cn(buttonVariants({ variant, size, radius }), className)}
{type}
{disabled}
use:pressScale
{...restProps}
>
{@render children?.()}
</button>
{/if}
As link
button.svelte
<script lang="ts" module>
import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from 'svelte/elements';
import { type VariantProps, tv } from 'tailwind-variants';
export const buttonVariants = tv({
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 border border-transparent bg-clip-padding text-sm font-medium focus-visible:ring-(length:--ring-width) aria-invalid:ring-(length:--ring-width) [&_svg:not([class*='size-'])]:size-4 group/button inline-flex shrink-0 items-center justify-center whitespace-nowrap transition-[color,background-color,border-color,box-shadow,opacity] duration-(--motion-duration-fast) ease-out outline-none select-none disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/80',
outline:
'border-border bg-transparent hover:bg-substrate-hover hover:text-foreground aria-expanded:bg-substrate-active aria-expanded:text-foreground',
secondary:
'bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground',
ghost:
'text-muted-foreground hover:bg-substrate-hover hover:text-foreground aria-expanded:bg-substrate-active aria-expanded:text-foreground',
'brand-ghost':
'text-primary hover:bg-primary/10 hover:text-primary aria-expanded:bg-primary/15',
destructive:
'bg-destructive/10 hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/20 text-destructive focus-visible:border-destructive/40 dark:hover:bg-destructive/30',
link: "text-primary underline-offset-4 hover:underline transition-[color,font-variation-settings] duration-(--motion-duration-fast) ease-(--motion-ease-out) [font-variation-settings:'wght'_500] hover:[font-variation-settings:'wght'_600]"
},
size: {
default:
'h-9 gap-1.5 px-3 has-data-[icon=inline-end]:pr-2.5 has-data-[icon=inline-start]:pl-2.5',
xs: "h-6 gap-1 px-2.5 text-xs has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2 [&_svg:not([class*='size-'])]:size-3",
sm: 'h-8 gap-1 px-3 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
lg: 'h-10 gap-1.5 px-4 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3',
icon: 'size-9',
'icon-xs': "size-6 [&_svg:not([class*='size-'])]:size-3",
'icon-sm': 'size-8',
'icon-lg': 'size-10'
},
radius: {
default: 'rounded-(--radius)',
pill: 'rounded-(--radius-control)'
}
},
defaultVariants: {
variant: 'default',
size: 'default',
radius: 'pill'
}
});
export type ButtonVariant = VariantProps<typeof buttonVariants>['variant'];
export type ButtonSize = VariantProps<typeof buttonVariants>['size'];
export type ButtonRadius = VariantProps<typeof buttonVariants>['radius'];
export type ButtonProps = WithElementRef<HTMLButtonAttributes> &
WithElementRef<HTMLAnchorAttributes> & {
variant?: ButtonVariant;
size?: ButtonSize;
radius?: ButtonRadius;
};
</script>
<script lang="ts">
import { pressScale } from '$lib/motion';
let {
class: className,
variant = 'default',
size = 'default',
radius = 'pill',
ref = $bindable(null),
href = undefined,
type = 'button',
disabled,
children,
...restProps
}: ButtonProps = $props();
</script>
{#if href}
<a
bind:this={ref}
data-slot="button"
class={cn(buttonVariants({ variant, size, radius }), className)}
href={disabled ? undefined : href}
aria-disabled={disabled}
role={disabled ? 'link' : undefined}
tabindex={disabled ? -1 : undefined}
use:pressScale
{...restProps}
>
{@render children?.()}
</a>
{:else}
<button
bind:this={ref}
data-slot="button"
class={cn(buttonVariants({ variant, size, radius }), className)}
{type}
{disabled}
use:pressScale
{...restProps}
>
{@render children?.()}
</button>
{/if}
Loading
Compose Spinner inline and set disabled while the action is in-flight. Click to simulate
a 2 s load.
button-loading.svelte
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import { Spinner } from '$lib/components/ui/spinner';
let loading = $state(false);
function simulateLoad() {
loading = true;
setTimeout(() => { loading = false; }, 2000);
}
</script>
<Button onclick={simulateLoad} disabled={loading}>
{#if loading}<Spinner />{/if}
{loading ? 'Saving…' : 'Save changes'}
</Button>
<Button variant="outline" onclick={simulateLoad} disabled={loading}>
{#if loading}<Spinner />{/if}
{loading ? 'Uploading…' : 'Upload file'}
</Button>Radius
Pill is the default — the signature primary CTA and FAB shape. Pass radius="default" to opt into a rounded square via --radius for tabular or
utility surfaces.
button-radius.svelte
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import { Icon } from '$lib/components/ui/icon';
</script>
<Button>Pill (default)</Button>
<Button variant="outline">Pill outline</Button>
<Button size="icon" aria-label="Download">
<Icon name="download-line" />
</Button>
<Button radius="default">Rounded square</Button>Button Group
Fuse adjacent buttons into a single control with shared borders and flush corners — see the Button Group page for full examples.
API reference
Inherits every native HTML button or anchor attribute via spread. Switches to <a> when href is set.
| Prop | Type | Default | Description |
|---|---|---|---|
'default' | 'outline' | 'secondary' | 'ghost' | 'destructive' | 'link' | 'default' | Visual style. Link variant collapses to an underlined text affordance. | |
'default' | 'xs' | 'sm' | 'lg' | 'icon' | 'icon-xs' | 'icon-sm' | 'icon-lg' | 'default' | Controls height and horizontal padding. Icon sizes render as a square. | |
'default' | 'pill' | 'pill' | Corner shape. Pill (default) uses --radius-control (9999px) — same tier as inputs and selects. Pass radius="default" for a rounded square via --radius on tabular or utility surfaces. | |
string | undefined | — | When set, the button renders as an anchor and receives link semantics. | |
boolean | false | Disables the control. Anchors receive aria-disabled and tabindex=-1 instead of a native disabled attribute. | |
'button' | 'submit' | 'reset' | 'button' | Passed through when rendering as a <button>. Ignored when href is set. | |
HTMLButtonElement | HTMLAnchorElement | null | null | Two-way-bindable element reference. | |
string | — | Merged onto the root via tailwind-merge. Useful for spacing or width overrides. |