Button
A control that runs an action. Use it for anything the user deliberately triggers: submitting a form, saving, deleting.
import { Button } from 'neba';
<Button onClick={save}>Save</Button>;Props
| Prop | Type | Default | Description |
|---|---|---|---|
| variantshared | 'solid' | 'outline' | 'text' | 'solid' | Weight of the surface: filled, hairline, or none |
| sizeshared | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'md' | Height and type scale. xs 22px · sm 26px · md 32px · lg 40px · xl 48px |
| colorshared | 'primary' | 'secondary' | 'success' | 'warning' | 'danger' | 'info' | 'primary' | Semantic colour role. Arbitrary colour values are not accepted |
| densityshared | 'default' | 'compact' | 'default' | Padding only: never the height, never the type scale |
| elevationshared | 0 | 1 | 2 | 3 | 0 | Drop shadow depth. 0 is flat; hover adds a level and pressing removes one |
| startIcon | ReactNode | — | Content before the label. Sized in em, so it tracks the label |
| endIcon | ReactNode | — | Content after the label |
| loading | boolean | false | Spinner in place of startIcon; stops activation but keeps focus |
| readOnly | boolean | false | Inert but not dimmed: the action exists, it just is not available here |
| disabled | boolean | false | Unavailable. Drops the colour family for neutral grey and leaves the tab order |
| fullWidth | boolean | false | Stretches to the width of the container |
| render | useRender.RenderProp | — | Renders something other than a button (an <a href>, a router Link). A link stays a link, so crawlers and screen readers still see one |
| children | ReactNode | — | The label. Omit it and the button goes square for an icon |
Every native <button> attribute passes straight through. The one exception is color, omitted because it collides with the color in the table above.
What the shared axes (variant size color density elevation) mean across the library is in prop conventions.
Examples
variant
solid is the primary action, outline a secondary one, text a low-weight action for a list or a toolbar. Keep one solid per screen.
import { Button } from 'neba';
export default function ButtonVariants() {
return (
<div className="flex flex-wrap items-center gap-3">
<Button variant="solid">Save</Button>
<Button variant="outline">Cancel</Button>
<Button variant="text">Details</Button>
</div>
);
}color
Six role colours only; arbitrary colour values are not accepted.
import { Button } from 'neba';
const COLORS = ['primary', 'secondary', 'success', 'warning', 'danger', 'info'] as const;
export default function ButtonColors() {
return (
<div className="flex flex-col gap-3">
{(['solid', 'outline', 'text'] as const).map((variant) => (
<div key={variant} className="flex flex-wrap items-center gap-2">
{COLORS.map((color) => (
<Button key={color} variant={variant} color={color}>
{color}
</Button>
))}
</div>
))}
</div>
);
}size
Sets the height and the type scale together: xs 22px · sm 26px · md 32px · lg 40px · xl 48px. md is the desktop default.
import { Button } from 'neba';
export default function ButtonSizes() {
return (
<div className="flex flex-wrap items-center gap-3">
<Button size="xs">xs</Button>
<Button size="sm">sm</Button>
<Button size="md">md</Button>
<Button size="lg">lg</Button>
<Button size="xl">xl</Button>
</div>
);
}density
density changes horizontal padding and nothing else. Two buttons of the same size are the same height whatever their density, so a mixed row keeps its baseline.
import { Button } from 'neba';
export default function ButtonDensity() {
return (
<div className="flex flex-col gap-3">
<div className="flex flex-wrap items-center gap-3">
<Button density="default">Save changes</Button>
<Button density="default" variant="outline">
Save changes
</Button>
</div>
<div className="flex flex-wrap items-center gap-3">
<Button density="compact">Save changes</Button>
<Button density="compact" variant="outline">
Save changes
</Button>
</div>
</div>
);
}startIcon and endIcon
Icons are drawn at 1.2em, so they track the label and never need a size of their own. With icons but no children the button goes square, and then it needs an aria-label: for an icon-only control, IconButton requires label instead.
import { Button } from 'neba';
function PlusIcon() {
return (
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M8 3.5v9M3.5 8h9" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" />
</svg>
);
}
function ChevronIcon() {
return (
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path
d="m6 4 4 4-4 4"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
export default function ButtonIcons() {
return (
<div className="flex flex-wrap items-center gap-3">
<Button startIcon={<PlusIcon />}>New project</Button>
<Button variant="outline" endIcon={<ChevronIcon />}>
Continue
</Button>
<Button variant="outline" aria-label="Add" startIcon={<PlusIcon />} />
</div>
);
}loading · readOnly · disabled
| prop | Appearance | Focus | Native disabled |
|---|---|---|---|
loading | Unchanged; a spinner takes the startIcon slot | Kept | No |
readOnly | Keeps its colour, goes flat, drains saturation | Kept | No |
disabled | Drops the colour family for neutral grey | Lost | Yes |
None of the three let a click reach the parent.
import { Button } from 'neba';
export default function ButtonStates() {
return (
<div className="flex flex-col gap-3">
{(['solid', 'outline', 'text'] as const).map((variant) => (
<div key={variant} className="flex flex-wrap items-center gap-2">
<Button variant={variant}>Normal</Button>
<Button variant={variant} loading>
Loading
</Button>
<Button variant={variant} disabled>
Disabled
</Button>
<Button variant={variant} readOnly>
Read-only
</Button>
</div>
))}
</div>
);
}elevation
Drop shadow depth. The default 0 means no shadow at all. Hovering adds a level and pressing removes one, so even a 0 button answers a press.
import { Button } from 'neba';
export default function ButtonElevation() {
return (
<div className="flex flex-wrap items-center gap-4">
<Button elevation={0} size="lg">
elevation 0
</Button>
<Button elevation={1} size="lg">
elevation 1
</Button>
<Button elevation={2} size="lg">
elevation 2
</Button>
<Button elevation={3} size="lg">
elevation 3
</Button>
</div>
);
}fullWidth
Stretches to the width of the container.
import { Button } from 'neba';
export default function ButtonFullWidth() {
return (
<div className="flex max-w-sm flex-col gap-2">
<Button fullWidth size="lg">
Create workspace
</Button>
<Button fullWidth variant="text" color="secondary">
Maybe later
</Button>
</div>
);
}render
Renders something other than a <button>. An action that navigates should be an <a href>: a crawler follows it, it appears in a screen reader's list of links, and the browser's own behaviour (open in a new tab, copy the address) keeps working. A router's Link goes in the same way.
The surface, the sizes and the press signature are unchanged. An <a> has no disabled, so a button that has to be unavailable stays a <button>.
import { Button } from 'neba';
export default function ButtonRender() {
return (
<div className="flex flex-wrap items-center gap-2">
<Button render={<a href="/guide/getting-started" />}>Get started</Button>
<Button render={<a href="/components/" />} variant="outline" color="secondary">
All components
</Button>
<Button render={<a href="/design/design-language" />} variant="text" size="sm">
Design language
</Button>
</div>
);
}Accessibility
- Renders a native
<button>by default.typepasses through, sotype="submit"works inside a form. - Changing the element with
renderkeeps that element's semantics: an<a href>stays a link rather than being covered byrole="button". - Give icon-only buttons an
aria-label. - The focus ring only appears on
:focus-visible, so a mouse click never draws one. loadingandreadOnlykeep focus: dropping out of the tab order costs keyboard users their sense of the page.- Every colour combination meets 4.5:1 for text on the fill.