Button
A trigger for actions and events, with variants, color schemes, sizes, decorators, and a built-in loading state.Import
import { Button } from "@hope-ui/components/button";Usage
<Button>Button</Button>Variants
The variant prop sets the visual style. default is neutral, color-independent chrome (it
ignores colorScheme); solid, soft, outline, ghost, and link are the colored variants.
inverted is a filled variant for solid surfaces — shown below.
<Button variant="default">Default</Button>
<Button variant="solid">Solid</Button>
<Button variant="soft">Soft</Button>
<Button variant="outline">Outline</Button>
<Button variant="ghost">Ghost</Button>
<Button variant="link">Link</Button>On solid surfaces
inverted is the swap of solid — a light fill with role-colored text — so it stays legible on a
solid, colored, or dark surface (a toolbar, a banner) where a solid button would disappear. It
paints its own dedicated {role}-inverted tokens, so it is a first-class variant with its own
hover/press states, not a fragile on-the-fly swap. warning renders as a dark chip with amber text —
the honest, symmetric result. On the plain page background its light fill is near-invisible by design.
<div class="bg-surface-inverse p-6">
<Button variant="inverted" colorScheme="primary">
Primary
</Button>
<Button variant="inverted" colorScheme="success">
Success
</Button>
</div>Color schemes
Pair a colored variant with a colorScheme — one of the six semantic roles. The default
variant is color-independent and ignores it.
<Button variant="solid" colorScheme="primary">Primary</Button>
<Button variant="soft" colorScheme="success">Success</Button>
<Button variant="outline" colorScheme="info">Info</Button>
<Button variant="ghost" colorScheme="warning">Warning</Button>
<Button variant="link" colorScheme="danger">Danger</Button>Sizes
Five sizes, from xs to xl (heights step from 24px to 40px). The recipe scales padding, text,
radius, and any decorator icon to match.
<Button variant="solid" size="xs">Extra small</Button>
<Button variant="solid" size="sm">Small</Button>
<Button variant="solid" size="md">Medium</Button>
<Button variant="solid" size="lg">Large</Button>
<Button variant="solid" size="xl">Extra large</Button>Full width
fullWidth stretches the button to fill its container — useful for forms and mobile layouts.
<Button variant="solid" fullWidth>
Continue
</Button>Icons & decorators
Put content before or after the label with startDecorator and endDecorator. Both accept any
JSX — an inline <svg>, or an icon from your icon set. The recipe sizes decorator SVGs to the
button's size for you.
<Button variant="solid" startDecorator={<CheckIcon />}>
Save
</Button>
<Button variant="default" endDecorator={<ArrowRightIcon />}>
Continue
</Button>Icon-only
Set iconOnly and pass a single icon as the children to get a square button: the recipe locks
the width to the size's height, drops the horizontal padding, and sizes the icon for you (the same
per-size scale the decorators use). Without iconOnly, an icon in the children is laid out like a
text label — a wide rectangle.
An icon-only button has no visible text, so it needs an accessible name: always pass an aria-label
(or aria-labelledby). In development, an icon-only button with neither logs a warning; the check
is client-only and compiles out of production builds.
<Button iconOnly aria-label="Add">
<PlusIcon />
</Button>
<Button iconOnly variant="soft" colorScheme="success" aria-label="Confirm">
<CheckIcon />
</Button>iconOnly is meant for the chrome variants (default, solid, soft, outline, ghost).
Combining it with fullWidth or variant="link" is unsupported — those fight the square shape.
Loading
loading shows a loader, sets aria-busy, and blocks activation — while keeping the button in
the tab order (it does not use the native disabled attribute). By default the loader overlays
and hides the label (loaderPlacement="center"), preserving the button's width. Pass
loadingText to swap the label for a message instead — this keeps the text visible with an inline
loader. Use loaderPlacement (start / end) to place an inline loader yourself.
<Button variant="solid" loading>
Save
</Button>
<Button variant="soft" colorScheme="neutral" loading loadingText="Uploading…">
Upload
</Button>
<Button variant="outline" colorScheme="danger" loading loaderPlacement="end">
Delete
</Button>Disabled
disabled blocks the button and dims it via the opacity-disabled token, keeping its own
variant color rather than turning grey. A native <button> uses the disabled attribute (and
drops out of the tab order); a non-native button (see Polymorphism) instead
gets aria-disabled with blocked handlers.
<Button disabled>Default</Button>Polymorphism
Render Button as a different element or component with the render prop — a function that
receives Button's fully-computed props and spreads them onto your element. This is how you make a
link that looks and behaves like a button. There is no as prop; render is the single
polymorphism API.
<Button
variant="solid"
colorScheme="primary"
nativeButton={false}
render={(props) => <a href="#" {...props} />}
>
Anchor that acts as a button
</Button>It also works with a component target — for example your router's Link. Button merges its
internal ref with the rendered element's into a single function ref, which a router Link honours,
so you get hope-ui's styling and press behavior with real client-side navigation.
import type { ComponentProps } from "solid-js";
import { Link } from "@tanstack/solid-router";
import { Button } from "@hope-ui/components/button";
<Button
variant="solid"
colorScheme="primary"
nativeButton={false}
render={(props) => (
<Link {...(props as unknown as ComponentProps<typeof Link>)} to="/" />
)}
>
Back to Home
</Button>;button, set nativeButtonto false. Button then applies role="button", manages the tab order, and synthesizes keyboard activation (Space and Enter) — the behavior a real button has for free. The as unknown as cast on the spread is only needed to satisfy TypeScript when switching to a different element type; the runtime behavior is unaffected.Theming
Button's look comes from the active preset's recipe. You can override it at three levels, applied
in order (later wins a Tailwind conflict): recipe base → preset slotClasses → instance
slotClasses → class.
Parts
Every part carries a data-slot attribute you can target, and each is addressable by name through
slotClasses.
| Slot | data-slot | Description |
|---|---|---|
root | button | The rendered element (a native button by default). |
label | button-label | Wraps the children — or the loadingText while loading. |
startDecorator | button-start-decorator | Leading decorator wrapper. Rendered only when startDecorator is set. |
endDecorator | button-end-decorator | Trailing decorator wrapper. Rendered only when endDecorator is set. |
loader | button-loader | Loader wrapper. Rendered only while loading; marked aria-hidden. |
Overriding one button
class merges onto the root slot (your utilities win conflicts). slotClasses targets any slot
by name. Use literal class strings so your Tailwind build can see them.
<Button variant="solid" slotClasses={{ root: "rounded-full px-6" }}>
Pill
</Button>
<Button
variant="soft"
colorScheme="success"
slotClasses={{
label: "uppercase tracking-wide"
}}
>
deploy!
</Button>App-wide defaults & overrides
Set defaults and global part classes for every Button through the theme with definePreset,
then pass the derived preset to ThemeProvider. defaultProps are resolved at instance ??
preset ?? builtin precedence, so an explicit prop on a single button always wins.
import { definePreset } from "@hope-ui/theming";
import { hope } from "@hope-ui/presets/hope";
export const myPreset = definePreset(hope, {
components: {
button: {
// Every recipe variant is defaultable.
defaultProps: { size: "lg" },
// Global part classes, folded in before per-instance slotClasses.
slotClasses: { root: "rounded-full" },
},
},
});import { ThemeProvider } from "@hope-ui/theming";
import { myPreset } from "./theme";
<ThemeProvider preset={myPreset}>
{/* every Button below is large & pill-shaped */}
</ThemeProvider>;Defaultable through defaultProps: variant, colorScheme, size, fullWidth,
loaderPlacement, and the chrome content loader / loadingText (as () => JSX.Element
factories, so each instance gets a fresh node).
Transient state (loading, disabled) and per-usage behavior (nativeButton, type)
are intentionally not defaultable app-wide.
API
Props
Button accepts every native <button> attribute in addition to the props below.
| Prop | Default | Type |
|---|---|---|
variant | "default" | ButtonVariantVisual style: default, solid, inverted, soft, outline, ghost, link. default is neutral chrome and ignores colorScheme; inverted is the swap of solid for solid surfaces. |
colorScheme | "primary" | ButtonColorSchemeSemantic role color: primary, neutral, success, info, warning, danger. Applied by the colored variants; ignored by default. |
size | "md" | ButtonSizeDensity scale: xs, sm, md, lg, xl (heights 24–40px). |
fullWidth | false | booleanStretches the button to fill its container's width. |
loading | false | booleanShows a loader and sets aria-busy. Blocks activation but keeps the button in the tab order — it does not use the disabled attribute. |
loadingText | — | JSX.Element | (() => JSX.Element)Replaces the label while loading, kept visible by an inline start loader. A factory is reuse-safe as an app-wide default. |
loader | — | JSX.Element | (() => JSX.Element)Custom loader content. Defaults to hope's spinner. A factory is reuse-safe as an app-wide default. |
loaderPlacement | "center" | "center" | "start" | "end"Where the loader sits while loading. center overlays it and hides the label, preserving width. |
startDecorator | — | JSX.ElementLeading content before the label — typically an icon. |
endDecorator | — | JSX.ElementTrailing content after the label — typically an icon. |
iconOnly | false | booleanRenders a square, icon-only button (icon passed as children, sized and centered per size). Needs an aria-label for an accessible name. Not for use with fullWidth or the link variant. |
disabled | false | booleanDisables the button. Native buttons use the disabled attribute; a non-native element uses aria-disabled and blocked handlers. Keeps its variant color, dimmed. |
nativeButton | true | booleanWhether the rendered element is a real button. Set false when rendering another element so Button supplies role, tabIndex, and keyboard activation. |
render | — | (props) => JSX.ElementRender Button as another element or component. Receives Button's computed props. The only polymorphism mechanism — there is no as prop. |
class | — | stringExtra classes merged onto the root slot; your utilities win conflicts via tailwind-merge. |
slotClasses | — | SlotClasses<"button">Per-slot class overrides, keyed by root, label, startDecorator, endDecorator, loader. |
type | "button" | "button" | "submit" | "reset"Native button type. |
children | — | JSX.ElementThe button label. |
Accessibility
Button renders a real <button> by default, so it is keyboard- and screen-reader-accessible
out of the box. When you render it as another element with nativeButton={false}, it supplies
the missing semantics itself: role="button", tab-order management, and synthesized keyboard
activation.
| Key | Action |
|---|---|
Enter | Activates the button (native and rendered-as elements). |
Space | Activates the button (synthesized for rendered-as elements; page scroll is prevented). |
Tab | Moves focus. A disabled native button is skipped; a loading button keeps its place. |
- Loading sets
aria-busy="true"and blocks activation while keeping focus and tab position, so a screen reader announces the busy state without the control vanishing. - Disabled uses the native
disabledattribute on a<button>(no redundantaria-disabled), andaria-disabledon a non-native button. - Icon-only buttons have no visible text — give them an
aria-label(oraria-labelledby) so they have an accessible name.