IconButton
A round button with a glyph in it and nothing else — and a name it will not let you forget.
import { IconButton } from 'neba';
<IconButton icon={<PlusIcon />} label="Add item" />;A Button with an icon and no children already goes square: the same height, the same width, the acrylic corners cut off it. This is the other shape — a disc.
Everything else is Button's, unchanged and on purpose: the variants, the elevation ladder, the pointer light, loading, readOnly, and the values a surrounding ButtonGroup sets. Two components drawing the same surface from two copies of the same table are two components that will eventually disagree.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| icon * | ReactNode | — | The glyph. Passed bare it is sized in em against the button; wrap it in an Icon when it needs a size of its own |
| label * | string | — | What the button does, in words. The one required prop here: a button whose whole label is a drawing has no accessible name at all, and that is the single most common accessibility defect a component library ships |
| variantshared | 'solid' | 'outline' | 'text' | 'solid' | Weight of the surface: filled, hairline, or none |
| sizeshared | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'md' | Button's own height ladder, so a disc drops into a row of buttons without the row losing its baseline |
| colorshared | 'primary' | 'secondary' | 'success' | 'warning' | 'danger' | 'info' | 'primary' | Semantic colour role. Arbitrary colour values are not accepted |
| densityshared | 'default' | 'compact' | 'default' | Passed through but invisible: an icon-only control is square, so its horizontal padding is zero |
| elevationshared | 0 | 1 | 2 | 3 | 0 | Drop shadow depth. 0 means no shadow at all |
| loading | boolean | false | Puts a spinner in the glyph’s place and stops the button activating, while keeping it focusable |
| 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 |
Every native <button> attribute passes through.
Examples
Sizes
The same control heights everything else uses, so a disc drops into a row of buttons without the row losing its baseline.
import { Button, IconButton, Typography } from 'neba';
function SearchIcon() {
return (
<svg viewBox="0 0 16 16" fill="none">
<circle cx="7" cy="7" r="4.25" stroke="currentColor" strokeWidth="1.5" />
<path d="m10.25 10.25 3 3" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
);
}
const SIZES = ['xs', 'sm', 'md', 'lg', 'xl'] as const;
/**
* The same control heights everything else uses, so a disc drops into a row of
* buttons without the row losing its baseline.
*/
export default function IconButtonSizes() {
return (
<div className="flex flex-col gap-5">
<div className="flex flex-wrap items-end gap-4">
{SIZES.map((size) => (
<div key={size} className="flex flex-col items-center gap-2">
<IconButton icon={<SearchIcon />} label={`Search, ${size}`} size={size} />
<Typography level="caption">{size}</Typography>
</div>
))}
</div>
<div className="flex flex-wrap items-center gap-2">
<Button variant="outline">Filter</Button>
<IconButton icon={<SearchIcon />} label="Search" variant="outline" />
<Typography level="caption">Same height, different shape.</Typography>
</div>
</div>
);
}States
import { useState } from 'react';
import { IconButton, Typography } from 'neba';
function RefreshIcon() {
return (
<svg viewBox="0 0 16 16" fill="none">
<path
d="M13 8a5 5 0 1 1-1.6-3.7"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
<path
d="M13 2v3h-3"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
/** Every Button state is here unchanged, including the spinner. */
export default function IconButtonStates() {
const [loading, setLoading] = useState(false);
return (
<div className="flex flex-wrap items-center gap-4">
<div className="flex flex-col items-center gap-2">
<IconButton
icon={<RefreshIcon />}
label="Refresh"
loading={loading}
onClick={() => {
setLoading(true);
setTimeout(() => setLoading(false), 1600);
}}
/>
<Typography level="caption">press me</Typography>
</div>
<div className="flex flex-col items-center gap-2">
<IconButton icon={<RefreshIcon />} label="Refresh" disabled />
<Typography level="caption">disabled</Typography>
</div>
<div className="flex flex-col items-center gap-2">
<IconButton icon={<RefreshIcon />} label="Refresh" readOnly />
<Typography level="caption">readOnly</Typography>
</div>
<div className="flex flex-col items-center gap-2">
<IconButton icon={<RefreshIcon />} label="Refresh" elevation={2} />
<Typography level="caption">elevation 2</Typography>
</div>
</div>
);
}Why label is required
A button whose whole label is a drawing has no accessible name at all. "An icon button with no aria-label" is the single most common accessibility defect a component library ships, and the only fix that survives review is to make the name impossible to leave out — so it is a required prop rather than an optional one.
// Won't compile.
<IconButton icon={<TrashIcon />} />
// Will.
<IconButton icon={<TrashIcon />} label="Delete file" />Pair it with a Tooltip when the name should also be visible to a reader who can see the glyph but cannot guess it.
The round corner
The house radius rule holds every corner just short of the 50% that would make a control a pill, because the flat run along the top and bottom edge is what still reads as a sheet with its corners cut. A disc breaks that, deliberately.
The rule is about labelled controls: the flat run is there for the line of text to sit on, and a glyph has no line of text. A circle with a single mark centred in it is a punched token rather than a moulded key, so it protects the thing the rule exists to protect by a different route. When a square icon control is what a layout wants — inside a ButtonGroup, in a dense table row — use a Button with no children and it will already be one.
Coming from Material UI
| MUI | Neba |
|---|---|
<IconButton><Add /></IconButton> | icon={<Add />}, and label is required |
size="small" | size="sm" — the five-step ladder, and it is a control height |
edge="start" | Not offered. Use the layout around it |
color="error" | color="danger" |
disabled | The same. loading and readOnly are also here, from Button |