Button

Disabled, loading, a shortcut and a cooldown that survives a reload, collapsed into one `available` the renderer reads.

<Button onClick={save}>Save</Button>

That is the whole call site. onPress is the same thing under the name the vanilla core uses, so either reads fine. Disabled while the work runs, aria-busy and data-loading while it does, and a cooldown afterwards if you ask for one.

Three reasons a button cannot be pressed - it is disabled, it is busy, it is cooling down - are one thing to whoever renders it. They collapse into available, so nothing has to combine three flags correctly at every call site.

An href makes it a link - next/link in a Next app, a plain <a> anywhere else. Nothing to pass, nothing to configure.

Customize

Turn a prop on and press the thing. The preview is the real component, and the code is generated from the same values that drove it - so what you copy is what you just used.

Usage

import { Button } from "@enigmax/primitives/react";

// The short form.
<Button onPress={save}>Save</Button>

// A label that follows the state, for the cases that need one.
<Button onPress={resend} cooldown={30_000} shortcut="r" pending="Sending...">
    {({ cooldown }) => (cooldown > 0 ? `Wait ${Math.ceil(cooldown / 1000)}s` : "Resend code")}
</Button>

Installation

Adds the package and prints the import to use. Append --copy to write the source into your project instead.

enigma add button

Installs with your project's own package manager - npm, pnpm, yarn or bun - read from its packageManager field or its lockfile.

In Next, import from @enigmax/primitives/next and an href is next/link:

import { Button } from "@enigmax/primitives/next";

<Button href="/settings">Settings</Button>

The import path is the whole configuration - the same components, the same props, and the entry is only ever resolved by a project that has Next. That is why the main entry cannot just import next/link itself: that import fails to resolve for every consumer on Vite, Astro, Remix or plain React, which is most of them.

React Router has an entry too, and it translates its to for you - so the call site is the same href whatever is underneath:

import { Button } from "@enigmax/primitives/react-router";

<Button href="/settings">Settings</Button>

Everywhere else an href is a plain <a>, which is correct and only means a full page load under a router. For one that has no entry here yet, hand over its link once:

import { setLinkComponent } from "@enigmax/primitives/react";

setLinkComponent(MyRouterLink);

That is the same shape as React Aria’s RouterProvider, which takes the router’s navigate instead of its link: one mechanism for every router, at the cost of a line of setup. The entries above exist so the two routers most people are on do not need it.

as overrides everything, for the one link in the app that needs something else.

The four decisions

The cooldown starts when the work finishes, not when it was asked for. Starting it at request time lets a slow call eat its own cooldown, and the button is free again the instant the request returns - which is the moment it most needs not to be.

A cooldown with a key survives a reload. Without one, refreshing the page is a free retry, and preventing exactly that is the whole reason a cooldown exists. A stored time in the past is finished rather than pending.

useButton({ cooldown: { ms: 60_000, key: "resend-verification", storage: "local" } });

A shortcut is refused while the visitor is typing - in an input, a textarea, a select or anything contenteditable - and whenever any modifier is held, so it never types into a field or steals a real shortcut.

An anchor cannot be disabled. Only aria-disabled, which is advisory: the browser still follows the link. So the press is refused in the handler rather than trusted to the attribute.

State

available Not disabled, not loading, not cooling down
element "a" when there is an href, else "button"
loading Set it yourself, or let an async onPress manage it
cooldown Milliseconds left, 0 when there is none
shortcut The key, for rendering a <kbd>

When the markup is not a button

useButton is the same behaviour without the element - for a card, a table row, anything whose markup is nothing like a button. It returns props ready to spread, carrying aria-disabled, aria-busy, data-loading and data-cooldown.

const { element: Tag, props, loading, cooldown } = useButton({ onPress: save, cooldown: 3000 });

return <Tag {...props}>{loading ? "Saving..." : "Save"}</Tag>;

Reaching for it to render an ordinary button means writing those six lines at every call site, which is how they drift apart. That is what <Button> is for.

onClick or onPress

Both, and they are one handler rather than two. onClick is the name React already uses; onPress is the one the framework-agnostic core uses, so a vanilla call site and a React one read alike.

It has to be an alias because the component owns the element’s real onClick - that is where a press is refused while the button is loading, disabled or cooling down. A handler passed straight through to the element would replace that and take the behaviour with it, silently.

Props

onClick / onPress The same handler under two names. Sync or async; an async one drives loading for its duration
href Renders an anchor. A link cannot be disabled, so the press is refused in the handler
as Your router’s Link, or any element
cooldown Milliseconds, or { ms, key, storage } to survive a reload
shortcut One key. Refused while typing and with any modifier held
pending Replaces the label while async work runs
children A node, or a function of the state
type "button" by default, so it never submits the form it lives in