implement
Button

Button

The button, and the variant table half the registry borrows.

import { Div } from "@implementjs/core";
import { PlusIcon } from "@implementjs/lucide";
import { Button } from "@/lib/components/ui/button";

export default function ButtonDemo() {
	return Div(
		{ class: "flex flex-wrap items-center justify-center gap-2" },
		Button("Default"),
		Button({ variant: "secondary" }, "Secondary"),
		Button({ variant: "outline" }, "Outline"),
		Button({ variant: "ghost" }, "Ghost"),
		Button({ variant: "destructive" }, "Destructive"),
		Button({ variant: "link" }, "Link"),
		Button({ variant: "outline", size: "sm" }, PlusIcon({ "aria-hidden": true }), "New"),
		Button({ size: "icon", "aria-label": "Add" }, PlusIcon({ "aria-hidden": true })),
		Button({ disabled: true }, "Disabled"),
	);
}

Installation

npx jsrepo add @implementjs/ui/button

It installs tailwind-variants and the spinner at the same time — the spinner is what a loading button renders.

Usage

buttonVariants is the part that travels. A dialog trigger, a popover close, the calendar's arrows, a toast action — none of them are Button, but all of them render through this table, which is why one edit here restyles the whole registry.

import { Button, buttonVariants } from "@/lib/components/ui/button";

Button({ variant: "outline", size: "sm" }, "Save");

// the same styles on something that is not a button
A({ href: "/docs", class: buttonVariants({ variant: "link" }) }, "Read the docs");

Icons

An icon in a button is sized and made non-interactive by the base styles, so it needs no classes of its own. Give an icon-only button an aria-label — there is no text to name it:

Button({ size: "icon", "aria-label": "Add" }, PlusIcon({ "aria-hidden": true }));

has-[>svg] trims the horizontal padding when a button holds both an icon and a label, so the pair stays optically centered.

Loading

loading puts a spinner in the button and stops it accepting clicks. It takes a signal, so the state can live wherever the work does:

import { Div, signal } from "@implementjs/core";
import { RefreshCwIcon } from "@implementjs/lucide";
import { Button } from "@/lib/components/ui/button";

function save() {
	return new Promise<void>((resolve) => setTimeout(resolve, 2000));
}

export default function ButtonLoadingDemo() {
	const publishing = signal(false);

	return Div(
		{ class: "flex flex-wrap items-center justify-center gap-2" },
		// the state the button owns: it loads until the promise settles
		Button({ onClickPromise: save }, "Save"),
		// the state you own: flip a signal and the button follows
		Button(
			{
				variant: "outline",
				loading: publishing,
				onClick: () => {
					publishing.set(true);
					void save().finally(() => publishing.set(false));
				},
			},
			"Publish",
		),
		Button(
			{ variant: "outline", size: "icon", "aria-label": "Refresh", onClickPromise: save },
			RefreshCwIcon({ "aria-hidden": true }),
		),
		Button({ loading: true }, "Always loading"),
	);
}
const saving = signal(false);

Button({ loading: saving }, "Save");
Button({ loading: true }, "Saving"); // a fixed state is fine too

A loading button is disabled while it loads, and carries data-loading and aria-busy for anything styling or announcing around it.

An icon button is one square with no room beside its icon, so there the spinner takes the icon's place rather than crowding in next to it. Every other size keeps its label and puts the spinner before it.

Awaiting a click

Most loading states last exactly as long as one async click, so onClickPromise writes that case for you: the button loads until the promise the handler returns settles.

Button({ onClickPromise: () => save(draft) }, "Save");

The promise is not swallowed — a rejection reaches your own catch, or the console, exactly as it would have without the button in the way. Handle failures where you would handle them anyway:

Button(
	{
		onClickPromise: async () => {
			try {
				await save(draft);
			} catch (error) {
				console.error(error);
			}
		},
	},
	"Save",
);

A handler that returns something other than a promise never enters the loading state, onClick still runs first when both are passed, and loading still wins on its own — the two states are ORed, so a button can be loading for a reason that has nothing to do with its last click.

Overriding

class is merged with the variant table, not appended to it, so a utility you pass wins over the one the variant baked in:

Button({ size: "icon", class: "size-20" }); // 20, not 9
Button({ variant: "outline", class: "border-destructive" });

That holds for every component in the registry — see Merging classes.

Variants elsewhere

ButtonVariant and ButtonSize are exported as types. Components that put a button somewhere accept them by those names — DialogTrigger({ variant: "destructive" }) reaches the same table.

API Reference

Button

The button. `buttonVariants` is exported alongside it, which is how the parts of other components that render as buttons — a dialog trigger, a calendar's arrows — borrow the same styles. Renders a Button; extra props are forwarded onto it.

PropTypeDefaultDescription
variant"default" | "destructive" | "outline" | "secondary" | "ghost" | "link""default"Which button style to render. Also set as data-variant.
size"default" | "xs" | "sm" | "lg" | "icon" | "icon-xs" | "icon-sm" | "icon-lg""default"Height and padding. The icon sizes are square and drop the horizontal padding. Also set as data-size.
loadingboolean | Readable<boolean>falseRender a spinner and disable the button. An icon size shows the spinner in place of its icon; every other size puts it before the label.
onClickPromise(event: MouseEvent) => unknown—A click handler that is awaited: the button loads until the promise it returns settles. A non-promise return leaves it alone, and onClick still runs first when both are passed.
Data attributeValue
[data-slot]"button"
[data-variant]"default" | "destructive" | "outline" | "secondary" | "ghost" | "link"
[data-size]"default" | "xs" | "sm" | "lg" | "icon" | "icon-xs" | "icon-sm" | "icon-lg"
[data-loading]Present while the button is loading.