Button
A control that runs an action. Base UI's Button primitive with Neba's acrylic surface on top of it.
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 |
| 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, which is 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
Variants
Keep one solid per screen. If there are two primary actions, neither of them is the primary action.
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>
);
}Colours
Six roles, and that is all. Arbitrary colour values are not accepted — a colour is a role, not a value.
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>
);
}Sizes
md (32px) is the desktop default. xs and sm are for toolbars and table rows; lg and xl are for the one action a screen is actually about.
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>
);
}Icons
Icons are drawn at 1.2em, so they track the label and never need a size of their own. Pass no label and the button goes square — which is when it needs an aria-label.
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>
);
}States
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>
);
}| State | 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.
Elevation
The default 0 means no shadow at all — what separates the surface from the page is the acrylic edge. Hovering adds a level and pressing removes one, so a flat button answers a press without moving.
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>
);
}Full width
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>
);
}Accessibility
- Always renders a native
<button>.typepasses through, sotype="submit"works inside a form. - 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 focus order costs keyboard users their sense of the page.- Every colour combination meets 4.5:1 for text on the fill.