Browse docs
Browse docs
Trigger an action. Submit a form. Render as anything else with asChild.
import { Button } from '@dashforge/tw';
<Button color="primary">Save changes</Button><Button color="primary" size="md" onClick={handleSave}>
Save changes
</Button>
<Button variant="outline" onClick={handleExport}>
<DownloadIcon /> Export
</Button>
<Button color="danger" variant="link" loading={isDeleting}>
Delete
</Button>State (loading, disabled) and visual (variant, color, size) are all props — never a utility chain on the consumer side.
import { Button, Stack } from '@dashforge/tw';
<Stack direction="row" gap={2} wrap align="center">
<Button variant="solid">Solid</Button>
<Button variant="outline">Outline</Button>
<Button variant="ghost">Ghost</Button>
<Button variant="link">Link</Button>
</Stack><Button variant="solid" color="primary">Solid</Button>
<Button variant="outline" color="primary">Outline</Button>
<Button variant="ghost" color="primary">Ghost</Button>
<Button variant="link" color="primary">Link</Button>Solid for primary actions, outline for secondary, link for tertiary or inline, ghost for hover-revealed surfaces.
import { Button, Stack } from '@dashforge/tw';
<Stack direction="row" gap={2} wrap align="center">
<Button color="primary">Primary</Button>
<Button color="secondary">Secondary</Button>
<Button color="success">Success</Button>
<Button color="warning">Warning</Button>
<Button color="danger">Danger</Button>
</Stack><Button color="primary"> Primary</Button>
<Button color="secondary">Secondary</Button>
<Button color="success"> Success</Button>
<Button color="warning"> Warning</Button>
<Button color="danger"> Danger</Button>Use semantic colors for semantic actions (danger for destructive, success for confirmation, warning for irreversible-but-not-destructive). For visual de-emphasis without losing affordance, pair variant="ghost" with any color instead of picking a "neutral" role.
import { Button, Stack } from '@dashforge/tw';
<Stack gap={3} align="start">
<Stack direction="row" gap={2} align="center">
<Button size="sm">Small</Button>
<Button size="md">Medium</Button>
<Button size="lg">Large</Button>
</Stack>
<Button fullWidth>Full width</Button>
</Stack><Button size="sm">Small</Button>
<Button size="md">Medium</Button>
<Button size="lg">Large</Button>
<Button fullWidth>Full width</Button>const [saving, setSaving] = useState(false);
<Button
color="primary"
loading={saving}
onClick={async () => {
setSaving(true);
await save();
setSaving(false);
}}
>
Save
</Button>While loading is true the button is disabled, the label is replaced by a spinner, the click handler is short-circuited. No flash of double-submits.
<Button> does not have dedicated startIcon / endIcon props. Compose icons inline as children — the button's base styles include inline-flex items-center justify-center gap-2, so an icon + text pair renders correctly out of the box.
<Button color="primary">
<PlusIcon /> Add item
</Button>
<Button variant="outline">
Next <ArrowRightIcon />
</Button>
{/* icon-only — MUST set aria-label */}
<Button aria-label="Add">
<PlusIcon />
</Button>Icon-only buttons MUST set aria-label for screen readers — the linter flags missing labels. For a purpose-built icon-only surface (fixed square shape, ghost-first variant), prefer <IconButton>.
asChild — render as a link, or anythingimport { Link } from 'react-router-dom';
<Button asChild color="primary">
<Link to="/dashboard">Open dashboard</Link>
</Button>asChild merges the Button's styles + behavior onto its single child element via Radix's Slot pattern. The actual DOM element is the child — useful for client-side routing without losing button styling.
<Button color="danger" access={{ requires: 'workspace.delete', when: 'denied:hide' }}>
Delete workspace
</Button>Three modes via the when field: 'denied:hide' (default — disappears), 'denied:disable' (greys out), 'denied:readonly' (clickable label, but the underlying action is short-circuited at the bridge). Shared semantics with the MUI side. See Access Control.
import { DashForm } from '@dashforge/forms';
import { TextField, Button } from '@dashforge/tw';
<DashForm onSubmit={signIn}>
<TextField name="email" required />
<Button type="submit" color="primary">Sign in</Button>
</DashForm>type="submit" integrates with <DashForm> — the button enters loading state automatically while the submit handler is pending.
Configure <Button> variant defaults application-wide through the theme's components field. Consumers who want every button to default to variant="outlined" or color="secondary" don't need to touch each instance — they set it once on the theme.
import { patchTheme } from '@dashforge/tw-theme';
patchTheme({
components: {
Button: {
defaults: {
color: 'primary',
variant: 'solid',
size: 'md',
},
},
},
});After this, <Button>Save</Button> renders with color="primary" variant="solid" size="md" unless a specific instance overrides one of those props.
Configurable axes (ButtonVariantProps):
| Axis | Type | Notes |
|---|---|---|
color | 'primary' | 'secondary' | 'success' | 'warning' | 'danger' | Semantic intent role. |
variant | 'solid' | 'outline' | 'ghost' | 'link' | Visual treatment. |
size | 'sm' | 'md' | 'lg' | Height + padding + font-size. |
fullWidth | boolean | Stretch to container width. |
loading | boolean | Disable + show spinner. Rarely a useful theme default. |
Non-visual axes (access, visibleWhen, event handlers, ARIA) are not theme-configurable — they carry per-instance semantics that don't generalize.
Precedence chain (lowest → highest):
defaultVariants from the internal buttonVariants recipe (fallback).theme.components.Button.defaults (application-wide override).<Button color="danger" />) — wins over theme.sx — wins over the resolved variant class via tailwind-merge.// Theme says color=primary, but this instance overrides to danger.
// The `sx` then forces a specific bg color that beats the variant class.
<Button color="danger" sx="bg-red-500">Force red</Button>TypeScript: theme configuration is fully type-checked. theme.components.Button.defaults autocompletes to the five axes above; passing an unrecognized field is a compile-time error.
Reactivity: useComponentDefaults('Button') inside <Button> subscribes to the theme store, so a patchTheme call at runtime re-renders every mounted button with the new defaults. Live theme-swap use cases (theme picker, tenant-scoped brand switching) work out of the box.
| Prop | Type | Default | Description |
|---|---|---|---|
access | AccessRequirement | — | RBAC access control requirement. Controls button visibility and interaction based on user permissions: - onUnauthorized: 'hide' → button does not render - onUnauthorized: 'disable' → button renders disabled - onUnauthorized: 'readonly' → button renders disabled (buttons do not support true readonly semantics; disabled is used as fallback)Combines with disabled and loading props via OR logic. |
asChild | boolean | — | When true, the immediate child element is rendered with the Button's classes merged in (via @radix-ui/react-slot). The Button DOM element is not emitted.Common pattern: render a router Link with Button styling. |
color | 'primary' | 'secondary' | 'success' | 'warning' | 'danger' | 'primary' | Semantic intent role. Drives both bg/fg and hover/active states. |
fullWidth | boolean | false | Stretch the button to fill its container's width (w-full). |
loading | boolean | false | When true, replace the label with a <Spinner>, short-circuit click handlers, and set aria-busy="true". |
size | 'md' | 'sm' | 'lg' | 'md' | Height + padding + font size density. |
sx | string | — | Tailwind utility classes appended after the variant classes, so they win on any conflict via tailwind-merge. |
variant | 'solid' | 'link' | 'outline' | 'ghost' | 'solid' | Visual treatment. |
visibleWhen | (engine: Engine) => boolean | — | Reactive visibility predicate. Re-evaluated on every engine state change when the button is mounted inside a <DashForm>; outside a form, evaluated as a plain predicate (the consumer captures any external state in the closure).When the predicate returns false, the component renders null.Added in 1.1.0 (Sprint 4.4 alignment) — extends the engine-reactive visibility pattern previously available only on form fields + <Alert> to every interactive component in the Dashforge TW catalog (Button, IconButton, Chip, …).Note: visibleWhen is for state-driven hide. For permission-driven hide, use access with onUnauthorized: 'hide' — it's the semantically correct path and provides better error messages in RBAC dev tools. |
...rest | Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'className' | 'color'> | — | Additional native attributes forwarded via Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'className' | 'color'>. |
<Button> has no slots. It renders a single <button> DOM element with children as the label and an optional loading <Spinner> — there is no anatomy to target separately.
The slotProps API pattern (dedicated { header?: ..., cell?: ..., ... } per-slot injection) only exists on compound components — those with multiple visually distinct regions. Examples: <Menu> (root, trigger, content, item, separator, label), <Accordion> (root, item, header, trigger, content, chevron), <DataGrid> (toolbar, header, row, cell, footer, …).
For <Button>, use sx (documented in the Props table above) for any style override. If you need more surface than that, asChild lets you render the Button's classes onto an arbitrary element you fully control.
The full tailwind-variants definition lives in the package source — every variant + color + size combination is enumerable, useful when extending the catalogue downstream:
import { buttonVariants } from '@dashforge/tw';
// buttonVariants({ color: 'primary', size: 'md', variant: 'solid' }) → className stringfocus-visible (keyboard only), respecting :focus-visible support.ghost and link variants is a subtle background/color shift, not a transform — preserves baseline rhythm.aria-hidden; the button itself sets aria-busy="true".<Button> is <button type="button"> by default — explicitly set type="submit" when needed inside forms.