FloatingActionButton
The one action a screen is about, as a round button floating over it: a FAB. Give it FloatingActions as children and it becomes a small set that fans out when it is pressed.
import { FloatingActionButton } from 'neba';
<FloatingActionButton icon={<PencilIcon />} label="Compose" onClick={compose} />;Props
| Prop | Type | Default | Description |
|---|---|---|---|
| variantshared | 'solid' | 'outline' | 'text' | 'solid' | Weight of the surface: filled, hairline, or none |
| sizeshared | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'lg' | Height. The same ladder a Button is on, started a step up: this is the one control that has to be found and hit with a thumb without being looked at |
| 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 | 2 | Drop shadow depth. 2 for the reason Pill’s is: this button is not part of the page, it hovers over it |
| icon | ReactNode | a plus | The glyph on the button |
| label * | string | — | What the button does, in words. Required: a button whose whole label is a drawing has no accessible name at all. With extended it is also the word written on it |
| extended | boolean | false | Writes label beside the glyph, which turns the disc into a stadium |
| openIcon | ReactNode | — | The glyph while the dial is open. Defaults to a × when the button has actions; pass the same node as icon to keep it unchanged |
| position | 'static' | 'sticky' | 'fixed' | 'absolute' | 'fixed' | How it sits. fixed pins it to a corner of the window; absolute pins it to a corner of the nearest positioned ancestor |
| cornershared | 'top-start' | 'top-end' | 'bottom-start' | 'bottom-end' | 'bottom-end' | Which corner it is pinned to |
| offset | number | string | 16 | How far in from both edges, as a CSS length or a number of pixels |
| direction | 'top' | 'bottom' | — | Which way the actions fan out. Taken from corner when it is left out |
| open | boolean | — | Whether the dial is open. Use with onOpenChange for a controlled dial |
| defaultOpen | boolean | false | Where an uncontrolled dial starts |
| onOpenChange | (open: boolean) => void | — | Called when the dial opens or closes |
| openOnHover | boolean | true | Opens the dial when a mouse comes to rest on the button. Touch and pen are excluded |
| closeOnAction | boolean | true | Closes the dial when one of the actions is pressed |
| showLabels | boolean | true | Draws each action’s name on a lozenge beside it. Turned off, the names are still read out |
| disabled | boolean | false | Unavailable. The button and every action stop answering |
| onClick | MouseEventHandler<HTMLButtonElement> | — | Fires when the button is pressed. It still fires when the button has actions, where the press also opens and closes the dial |
| children | ReactNode | — | The FloatingActions, if there are any |
| Prop | Type | Default | Description |
|---|---|---|---|
| icon | ReactNode | — | The glyph |
| label * | string | — | What the action does, in words. Drawn beside it, and always read out |
| disabled | boolean | false | Unavailable, but still part of the dial |
| onClick | MouseEventHandler<HTMLButtonElement> | — | Fires when the action is pressed |
Every other <div> attribute passes through to the root and every other <button> attribute to each action. onClick belongs to the button itself.
The shared axes (variant size color density elevation corner) are defined in prop conventions.
Examples
Fanning out actions
Give it FloatingAction children and the button becomes a dial: pressing it (or resting a mouse on it) fans the actions out and swaps the glyph for a ×. Each action's name is drawn on a lozenge beside it.
closeOnAction decides whether the dial goes away once an action is pressed, showLabels whether the names are drawn, and openOnHover whether a resting mouse opens it.
import { useState } from 'react';
import { FloatingAction, FloatingActionButton, Typography } from 'neba';
function LinkIcon() {
return (
<svg viewBox="0 0 16 16" fill="none">
<path
d="M6.5 9.5a2.75 2.75 0 0 0 4 .25l1.75-1.75a2.75 2.75 0 0 0-3.9-3.9L7.75 5.2"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
<path
d="M9.5 6.5a2.75 2.75 0 0 0-4-.25L3.75 8a2.75 2.75 0 0 0 3.9 3.9l.6-.6"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
);
}
function MailIcon() {
return (
<svg viewBox="0 0 16 16" fill="none">
<rect
x="2.25"
y="3.75"
width="11.5"
height="8.5"
rx="1.5"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d="m2.75 5 5.25 3.75L13.25 5"
stroke="currentColor"
strokeWidth="1.5"
strokeLinejoin="round"
/>
</svg>
);
}
function PrintIcon() {
return (
<svg viewBox="0 0 16 16" fill="none">
<path
d="M4.75 6.5v-3.5h6.5V6.5"
stroke="currentColor"
strokeWidth="1.5"
strokeLinejoin="round"
/>
<rect
x="2.25"
y="6.5"
width="11.5"
height="5"
rx="1.5"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d="M4.75 9.5h6.5v3.75h-6.5V9.5Z"
stroke="currentColor"
strokeWidth="1.5"
strokeLinejoin="round"
/>
</svg>
);
}
function ShareIcon() {
return (
<svg viewBox="0 0 16 16" fill="none">
<path
d="M8 10.5V2.75m0 0L5.25 5.5M8 2.75 10.75 5.5"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M3.25 9.5v3.25h9.5V9.5"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
);
}
export default function FloatingActionButtonDial() {
const [last, setLast] = useState('nothing yet');
return (
<div className="relative h-64 w-full max-w-sm overflow-hidden rounded-(--neba-radius-md) border [border-color:var(--neba-border)] p-4">
<Typography level="caption" className="text-(--neba-muted-fg)">
Last action: {last}
</Typography>
<FloatingActionButton position="absolute" icon={<ShareIcon />} label="Share">
<FloatingAction
icon={<LinkIcon />}
label="Copy link"
onClick={() => setLast('Copy link')}
/>
<FloatingAction icon={<MailIcon />} label="Email" onClick={() => setLast('Email')} />
<FloatingAction icon={<PrintIcon />} label="Print" onClick={() => setLast('Print')} />
</FloatingActionButton>
</div>
);
}extended
extended writes label beside the glyph, which turns the disc into a stadium: the shape to use when a drawing alone does not say what the screen is for. label is the accessible name either way, so the word that is drawn and the word that is read can never differ.
import { FloatingActionButton } from 'neba';
function PencilIcon() {
return (
<svg viewBox="0 0 16 16" fill="none">
<path
d="M11 2.5 13.5 5 6 12.5l-3.25.75L3.5 10 11 2.5Z"
stroke="currentColor"
strokeWidth="1.5"
strokeLinejoin="round"
/>
</svg>
);
}
export default function FloatingActionButtonExtended() {
return (
<div className="flex flex-wrap items-center justify-center gap-4">
<FloatingActionButton position="static" icon={<PencilIcon />} label="Compose" extended />
<FloatingActionButton
position="static"
icon={<PencilIcon />}
label="Compose"
extended
variant="outline"
/>
<FloatingActionButton position="static" icon={<PencilIcon />} label="Compose" />
</div>
);
}position, corner, offset
position defaults to fixed, which pins the button to a corner of the window. absolute pins it to a corner of the nearest positioned ancestor (a card, a map, the screen of a Mockup), and static puts it back in the flow.
corner picks one of the four corners and offset is the distance from both edges. Which way the actions fan out follows from corner, and direction overrides it.
import { useState } from 'react';
import { FloatingAction, FloatingActionButton, Segment, SegmentedButton } from 'neba';
function PlusIcon() {
return (
<svg viewBox="0 0 16 16" fill="none">
<path d="M8 3.5v9M3.5 8h9" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" />
</svg>
);
}
function DotIcon() {
return (
<svg viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="4.25" stroke="currentColor" strokeWidth="1.5" />
</svg>
);
}
const corners = ['top-start', 'top-end', 'bottom-start', 'bottom-end'] as const;
export default function FloatingActionButtonCorners() {
const [corner, setCorner] = useState<string | number | null>('bottom-end');
return (
<div className="flex w-full max-w-sm flex-col items-center gap-4">
<SegmentedButton size="sm" aria-label="Corner" value={corner} onValueChange={setCorner}>
{corners.map((value) => (
<Segment key={value} value={value}>
{value}
</Segment>
))}
</SegmentedButton>
<div className="relative h-56 w-full overflow-hidden rounded-(--neba-radius-md) border [border-color:var(--neba-border)]">
<FloatingActionButton
position="absolute"
corner={(corner ?? 'bottom-end') as (typeof corners)[number]}
offset={12}
icon={<PlusIcon />}
label="Add"
>
<FloatingAction icon={<DotIcon />} label="Task" />
<FloatingAction icon={<DotIcon />} label="Note" />
</FloatingActionButton>
</div>
</div>
);
}variant, size, color
The button itself is a Button, unchanged: the variants, the elevation ladder, the pointer light and the press behaviour are all the ones every other control has. Only size starts a step higher, at lg, because this is the one control that has to be found and hit with a thumb without being looked at. The actions are drawn a step back down.
import { FloatingActionButton, Typography } from 'neba';
function PlusIcon() {
return (
<svg viewBox="0 0 16 16" fill="none">
<path d="M8 3.5v9M3.5 8h9" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" />
</svg>
);
}
export default function FloatingActionButtonAppearance() {
return (
<div className="flex flex-col items-center gap-5">
<div className="flex flex-wrap items-center justify-center gap-4">
{(['sm', 'md', 'lg', 'xl'] as const).map((size) => (
<FloatingActionButton
key={size}
position="static"
size={size}
icon={<PlusIcon />}
label={`Add, ${size}`}
/>
))}
</div>
<div className="flex flex-wrap items-center justify-center gap-4">
<FloatingActionButton position="static" icon={<PlusIcon />} label="Add" variant="outline" />
<FloatingActionButton
position="static"
icon={<PlusIcon />}
label="Add"
color="success"
elevation={3}
/>
<FloatingActionButton position="static" icon={<PlusIcon />} label="Add" disabled />
</div>
<Typography level="caption" className="text-(--neba-muted-fg)">
size defaults to lg, elevation to 2
</Typography>
</div>
);
}Controlling it
Pass open and the dial keeps no state of its own.
const [open, setOpen] = useState(false);
<FloatingActionButton label="Share" open={open} onOpenChange={setOpen}>
<FloatingAction icon={<LinkIcon />} label="Copy link" />
</FloatingActionButton>;Accessibility
labelis required. A button whose whole label is a drawing has no accessible name at all.- With actions, the button carries
aria-expandedand anaria-controlspointing at the set it revealed. It is not arole="menu": a menu promises one tab stop for the set, arrow keys within it and typeahead, and what wants those is Menu. - The actions are ordinary buttons in the tab order right after the one that revealed them.
- Escape closes the dial and hands the focus back to the button. A press outside closes it too.
- The lozenge beside an action is
aria-hidden, because the same string is already the button's accessible name and would otherwise be announced twice.