Button

A clickable interactive element with the lily click-scale feel.

Installation

npx lily-svelte@latest add button

Installs the style saved in lily.json. For a new Diamond project, run lily-svelte init --style diamond first.

Usage

<script lang="ts">
	import { Button } from '$lib/components/ui/button';
</script>
 
<Button>Click me</Button>

Variants

solid, soft and ghost are three weights of the same button — loudest to quietest. Give a screen one solid button and let everything else step down from there. destructive is a meaning, not a weight, so it sits outside the ladder.

<Button>Solid</Button>
<Button variant="soft">Soft</Button>
<Button variant="ghost">Ghost</Button>
<Button variant="destructive">Destructive</Button>

ghost is transparent until hovered, which is what a row of icon buttons needs — with a resting tint, a toolbar of eight reads as eight filled chips.

Sizes

<Button size="sm">Small</Button>
<Button>Default</Button>
<Button size="lg">Large</Button>

Icon-only buttons are square and come in the same three steps, so they line up with the text button beside them.

<Button size="icon-sm" variant="ghost" aria-label="Add">…</Button>
<Button size="icon" variant="ghost" aria-label="Add">…</Button>
<Button size="icon-lg" variant="ghost" aria-label="Add">…</Button>

Loading

loading shows a spinner and swallows clicks while an action is in flight.

<script lang="ts">
	let saving = $state(false);
 
	async function save() {
		saving = true;
		await fetch('/api/save', { method: 'POST' });
		saving = false;
	}
</script>
 
<Button loading={saving} onclick={save}>Save changes</Button>

Three things it handles that a hand-rolled disabled={saving} does not:

  • The button does not resize. The label keeps its box and simply stops painting, so the spinner sits on top without the width jumping.
  • Focus is kept. A disabled button loses focus the moment the action starts, stranding a keyboard user at the top of the page. loading uses aria-disabled + aria-busy instead and swallows the click, so focus stays put and screen readers announce the busy state.
  • Fast actions do not flash. The spinner waits loadingDelay (150ms) before appearing, so anything that resolves quickly shows nothing at all.
<!-- for an action you know is slow, show it immediately -->
<Button loading={uploading} loadingDelay={0}>Upload</Button>

A Button renders as an <a> when you pass an href.

<Button href="/docs">Documentation</Button>

buttonVariants

To make something that is not a Button look like one, use buttonVariants().

<script lang="ts">
	import { buttonVariants } from '$lib/components/ui/button';
</script>
 
<a href="/docs" class={buttonVariants({ variant: 'ghost', size: 'sm' })}>Docs</a>
 
<Dialog.Trigger class={buttonVariants({ variant: 'soft' })}>Open</Dialog.Trigger>

It takes the same variant and size values as the component, plus a class that is merged last.

Built by levish. The source code is available on GitHub.

Quiet by design.