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.
Copy the file below to src/lib/components/ui/button.ts. It imports cn and the spinner, so copy utils.ts to src/lib/utils.ts and spinner.ts to src/lib/components/ui/spinner.ts too. Then, on top of @implementjs/core and @implementjs/primitives:
npm install tailwind-variants @implementjs/lucide
import {
derived,
If,
isReadable,
signal,
Button as ButtonPrimitive,
type Bindable,
type Child,
type ElementProps,
type Mountable,
type Readable,
} from "@implementjs/core";
import { createComponent } from "@implementjs/primitives";
import { tv, type VariantProps } from "tailwind-variants";
import { Spinner } from "./spinner";
import { cn } from "@/lib/utils";
export const buttonVariants = tv({
base: "inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
});
export type ButtonVariant = VariantProps<typeof buttonVariants>["variant"];
export type ButtonSize = VariantProps<typeof buttonVariants>["size"];
/** The click handler an element takes, with `this` and the event typed for a button. */
type ButtonClickHandler = Extract<
NonNullable<ElementProps<"button">["onClick"]>,
(...args: never[]) => unknown
>;
/**
* A click handler whose returned promise drives the button's loading state.
* Return anything else and the button never enters it.
*/
export type ButtonClickPromiseHandler = (
this: ThisParameterType<ButtonClickHandler>,
event: Parameters<ButtonClickHandler>[0],
) => unknown;
export type ButtonProps = ElementProps<"button"> &
VariantProps<typeof buttonVariants> & {
/**
* Show a spinner and stop accepting clicks. Pass a signal to drive it
* from outside, or leave it to `onClickPromise`.
*/
loading?: Bindable<boolean>;
/**
* Click handler that is awaited: the button loads until the promise it
* returns settles. `onClick` still runs first when both are passed.
*/
onClickPromise?: ButtonClickPromiseHandler;
};
/** True while any of its inputs is — a plain boolean when none of them is reactive. */
function anyTrue(...values: Array<Bindable<boolean | undefined>>): boolean | Readable<boolean> {
const readables: Array<Readable<unknown>> = [];
for (const value of values) {
// A fixed `true` settles it, whatever the others do later.
if (!isReadable(value)) {
if (value) return true;
continue;
}
readables.push(value);
}
// Nothing reactive left, so the answer is a plain boolean the element
// writes once instead of subscribing to.
if (readables.length === 0) return false;
return derived(readables, (...resolved) => resolved.some(Boolean));
}
/** `true` while the value holds and `undefined` otherwise, so the attribute is absent when it does not. */
function flag(value: boolean | Readable<boolean>): Bindable<true | undefined> {
if (!isReadable(value)) return value || undefined;
return derived([value], (current) => current || undefined);
}
function isThenable(value: unknown): value is PromiseLike<unknown> {
return (
typeof value === "object" &&
value !== null &&
"then" in value &&
typeof value.then === "function"
);
}
export const Button = createComponent(function Button(
{
variant = "default",
size = "default",
class: className,
type = "button",
loading = false,
disabled = false,
onClick,
onClickPromise,
...props
}: ButtonProps,
...children: Child[]
): Mountable {
// Only the promise handler can flip a state of its own; without one the
// `loading` prop is the whole story and there is nothing to allocate.
const pending = onClickPromise ? signal(false) : undefined;
const isLoading = pending ? anyTrue(loading, pending) : anyTrue(loading);
const handleClick: Bindable<ButtonClickHandler> | undefined = onClickPromise
? function (this: ThisParameterType<ButtonClickHandler>, event) {
// `onClick` is a plain function almost always; a bound one is read
// at click time so the latest value is the one that runs.
const click = isReadable<ButtonClickHandler | undefined>(onClick) ? onClick.get() : onClick;
if (typeof click === "function") click.call(this, event);
// A click that lands while one is already in flight — the button is
// disabled by then, but a programmatic `.click()` still gets here.
if (pending?.get()) return;
const result: unknown = onClickPromise.call(this, event);
if (!isThenable(result)) return;
pending?.set(true);
// `finally` leaves a rejection to reach the caller's own handling,
// or the console, exactly as an unawaited promise would.
void Promise.resolve(result).finally(() => pending?.set(false));
}
: onClick;
// An icon button is one square with no room beside its icon, so the spinner
// takes the icon's place rather than crowding in next to it.
const iconOnly = size?.startsWith("icon") ?? false;
return ButtonPrimitive(
{
type,
...props,
onClick: handleClick,
disabled: anyTrue(disabled, isLoading),
"data-slot": "button",
"data-variant": variant,
"data-size": size,
"data-loading": flag(isLoading),
"aria-busy": flag(isLoading),
class: cn(buttonVariants({ variant, size }), className),
},
...(iconOnly
? [If(isLoading, Spinner()).Else(...children)]
: [If(isLoading, Spinner()), ...children]),
);
});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.
| Prop | Type | Default | Description |
|---|---|---|---|
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. |
loading | boolean | Readable<boolean> | false | Render 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 attribute | Value |
|---|---|
[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. |