Buttons · variants · sizes · loading · groups
Seven variants in four tones and three sizes — with icons, badges, keyboard shortcuts, async loading, toggles and attached groups.
Variants
Tones
Sizes & shapes
Icons, badges & shortcuts
Loading
Groups & toggles
bold on · italic off · underline off · view: list
Usage
import { signal } from "./index.js";import { Button, ButtonGroup } from "./components/button.js"; // variant: primary · secondary · soft · outline · ghost · plain · linkButton({ label: "Save", onClick: save }) // variant defaults to "primary"Button({ variant: "outline", tone: "danger", label: "Remove" })Button({ variant: "danger", icon: trashSvg, label: "Delete" }) // a tone as variant = solid // size: sm · md · lg — pill, block; an icon with no label is a square buttonButton({ size: "sm", pill: true, label: "Tag" })Button({ variant: "secondary", icon: moreSvg, ariaLabel: "More" }) // icons, a live badge, a keyboard shortcut (⌘S on a Mac, Ctrl+S elsewhere)Button({ icon: bellSvg, label: "Inbox", badge: () => unread() || null })Button({ label: "Save", shortcut: "mod+s", onClick: save })Button({ variant: "link", label: "Docs", href: "/docs", target: "_blank" }) // loading: an async onClick spins until it settles — or drive it yourselfButton({ label: "Save changes", onClick: async () => { await api.save(); } })Button({ label: "Export", loadingText: "Exporting…", onClick: exportCsv })Button({ label: "Manual", loading: busy }) // busy = signal(false) // toggles and groups: a signal as `pressed` is flipped on clickconst bold = signal(false);ButtonGroup({ attached: true, children: [ Button({ variant: "secondary", icon: boldSvg, ariaLabel: "Bold", pressed: bold }), Button({ variant: "secondary", icon: italicSvg, ariaLabel: "Italic", pressed: italic }),] })// ZapButton.jsx — the zap Button inside React. Zap builds the button ONCE into a// host element; React props that change later are pushed into zap signals, so// only the spinner / label / disabled state updates — React never re-creates it.import { useEffect, useRef } from "react";import { mount, signal } from "@barisakin/zap";import { Button } from "@barisakin/zap/components/button.js"; export function ZapButton({ label, badge, loading = false, disabled = false, pressed, onClick, ...rest }) { const host = useRef(null); const s = useRef(null); s.current ??= { label: signal(label), badge: signal(badge), loading: signal(loading), disabled: signal(disabled), pressed: signal(pressed) }; s.current.onClick = onClick; // always call the latest handler useEffect(() => { s.current.label.set(label); }, [label]); useEffect(() => { s.current.badge.set(badge); }, [badge]); useEffect(() => { s.current.loading.set(loading); }, [loading]); useEffect(() => { s.current.disabled.set(disabled); }, [disabled]); useEffect(() => { s.current.pressed.set(pressed); }, [pressed]); useEffect(() => { const el = host.current, z = s.current; const dispose = mount(Button, el, { ...rest, // variant, tone, size, icon, pill, shortcut, href … (read once) label: z.label, badge: badge === undefined ? undefined : z.badge, loading: z.loading, disabled: z.disabled, pressed: pressed === undefined ? undefined : z.pressed, onClick: (e) => z.onClick?.(e), // a returned promise still shows the spinner }); return () => { dispose(); el.replaceChildren(); }; }, []); return <span ref={host} style={{ display: "contents" }} />;} // main.jsx — the theme's tokens live on .zap, so the app sits inside oneimport "@barisakin/zap/theme.css";createRoot(document.getElementById("root")).render(<div className="zap"><App /></div>); // App.jsximport { useState } from "react";import { ZapButton } from "./ZapButton.jsx"; export default function Toolbar() { const [bold, setBold] = useState(false); const [count, setCount] = useState(0); return ( <div className="toolbar"> <ZapButton label="Save" icon="💾" shortcut="mod+s" onClick={async () => { await api.save(); }} /> {/* spins until it resolves */} <ZapButton variant="outline" tone="danger" label="Delete" onClick={() => remove()} /> <ZapButton variant="secondary" label={`Clicked ${count}×`} badge={count || null} onClick={() => setCount((c) => c + 1)} /> <ZapButton variant="secondary" label="Bold" pressed={bold} onClick={() => setBold((b) => !b)} /> </div> );}// zap-button.component.ts — a standalone Angular (17+) wrapper. Zap renders the// button once into the host element; Angular signal inputs are forwarded into// zap signals by effect(), so a changed [label] / [loading] updates in place.import { AfterViewInit, Component, ElementRef, OnDestroy, effect, inject, input, output } from "@angular/core";import { mount, signal as zapSignal } from "@barisakin/zap";import { Button } from "@barisakin/zap/components/button.js"; @Component({ selector: "zap-button", standalone: true, template: "", host: { style: "display: contents" },})export class ZapButtonComponent implements AfterViewInit, OnDestroy { label = input(""); variant = input<"primary" | "secondary" | "soft" | "outline" | "ghost" | "plain" | "link">("primary"); tone = input<"danger" | "success" | "warning" | "neutral">(); size = input<"sm" | "md" | "lg">("md"); icon = input<string>(); shortcut = input<string>(); loading = input(false); disabled = input(false); /** may return a Promise — the button spins until it settles */ action = input<(e: MouseEvent) => unknown>(); clicked = output<MouseEvent>(); private host = inject<ElementRef<HTMLElement>>(ElementRef); private z = { label: zapSignal(""), loading: zapSignal(false), disabled: zapSignal(false) }; private dispose?: () => void; constructor() { effect(() => this.z.label.set(this.label())); effect(() => this.z.loading.set(this.loading())); effect(() => this.z.disabled.set(this.disabled())); } ngAfterViewInit() { this.dispose = mount(Button, this.host.nativeElement, { variant: this.variant(), tone: this.tone(), size: this.size(), icon: this.icon(), shortcut: this.shortcut(), label: this.z.label, loading: this.z.loading, disabled: this.z.disabled, onClick: (e: MouseEvent) => { this.clicked.emit(e); return this.action()?.(e); }, }); } ngOnDestroy() { this.dispose?.(); this.host.nativeElement.replaceChildren(); }} // angular.json → "styles": ["node_modules/@barisakin/zap/theme.css"],// and the theme's tokens live on .zap: <body class="zap"> in index.html // toolbar.component.tsimport { Component, signal } from "@angular/core";import { ZapButtonComponent } from "./zap-button.component"; @Component({ selector: "app-toolbar", standalone: true, imports: [ZapButtonComponent], template: ` <zap-button label="Save" icon="💾" shortcut="mod+s" [action]="save" /> <zap-button variant="outline" tone="danger" label="Delete" (clicked)="remove()" /> <zap-button variant="secondary" [label]="'Clicked ' + count() + '×'" (clicked)="count.update(c => c + 1)" /> <zap-button variant="soft" label="Sync" [loading]="syncing()" (clicked)="sync()" /> `,})export class ToolbarComponent { count = signal(0); syncing = signal(false); // an arrow function keeps `this` when handed to [action] save = async () => { await this.api.save(); }; remove() { /* … */ } async sync() { this.syncing.set(true); try { await this.api.sync(); } finally { this.syncing.set(false); } }}<!-- ZapButton.vue — the zap Button inside Vue 3. Zap builds the button once in onMounted; watch() forwards prop changes into zap signals, so only the bits that changed update. Declaring onClick as a prop means @click="fn" arrives as a function whose returned promise drives the spinner. --><script setup>import { ref, watch, onMounted, onBeforeUnmount } from "vue";import { mount, signal } from "@barisakin/zap";import { Button } from "@barisakin/zap/components/button.js"; const props = defineProps({ label: String, variant: { type: String, default: "primary" }, // primary · secondary · soft · outline · ghost · plain · link tone: String, // danger · success · warning · neutral size: { type: String, default: "md" }, // sm · md · lg icon: String, iconRight: String, pill: Boolean, shortcut: String, badge: [Number, String], loading: Boolean, disabled: Boolean, onClick: Function,}); const host = ref(null);const z = { label: signal(props.label), badge: signal(props.badge), loading: signal(props.loading), disabled: signal(props.disabled),};watch(() => props.label, (v) => z.label.set(v));watch(() => props.badge, (v) => z.badge.set(v));watch(() => props.loading, (v) => z.loading.set(v));watch(() => props.disabled, (v) => z.disabled.set(v)); let dispose;onMounted(() => { dispose = mount(Button, host.value, { variant: props.variant, tone: props.tone, size: props.size, pill: props.pill, icon: props.icon, iconRight: props.iconRight, shortcut: props.shortcut, label: z.label, badge: props.badge === undefined ? undefined : z.badge, loading: z.loading, disabled: z.disabled, onClick: (e) => props.onClick?.(e), });});onBeforeUnmount(() => { dispose?.(); host.value?.replaceChildren(); });</script> <template> <span ref="host" style="display: contents" /></template> <!-- main.js — the theme's tokens live on .zap: <div id="app" class="zap"> in index.html import "@barisakin/zap/theme.css"; createApp(App).mount("#app"); --> <!-- Toolbar.vue --><script setup>import { ref } from "vue";import ZapButton from "./ZapButton.vue"; const unread = ref(3);const syncing = ref(false);const save = async () => { await api.save(); }; // spins until it resolves</script> <template> <div class="toolbar"> <ZapButton label="Save" icon="💾" shortcut="mod+s" @click="save" /> <ZapButton variant="outline" tone="danger" label="Delete" @click="remove" /> <ZapButton variant="secondary" icon="🔔" label="Inbox" :badge="unread || null" @click="unread = 0" /> <ZapButton variant="soft" label="Sync" :loading="syncing" @click="syncing = !syncing" /> </div></template>