Fab
The floating action button — one action pinned over the content, with an optional dial of others behind it.
The floating action button: one screen, one thing it is mostly for.
It floats, which is the whole problem with it. A button pinned over the content is a button covering some of it, and the bottom-right corner of a scrolling list is exactly where the last row goes. So it is right when a screen has one action worth that trade and wrong when it has three — a corner with three buttons in it is a toolbar that has been put in the wrong place. Where a screen has several, the honest shapes are a ButtonGroup in a bar or, if one of them really does lead, this with a Fab.Group behind it.
Installation
Fab ships with the library — no separate install.
import { Fab, PlusIcon, PencilIcon, ImageIcon, PaperclipIcon, MicIcon, TrashIcon, SendIcon } from 'panelui-native';Or copy the source into your project, to own and edit it:
npx panelui-cli@latest add fabUsage
<Fab
placement="bottom-right"
icon={<PlusIcon size={24} />}
accessibilityLabel="New note"
onPress={compose}
/>Composition
{/* on its own */}
<Fab icon={…} accessibilityLabel="…" onPress={…} />
{/* or with a dial behind it */}
<Fab.Group icon={…} accessibilityLabel="…">
<Fab.Action icon={…} label="…" onPress={…} />
<Fab.Action icon={…} label="…" onPress={…} />
</Fab.Group>Fab.Action only works inside a Fab.Group — it reads the dial's open progress to know when its own turn to unfold is, and throws outside one rather than rendering something that would never animate.
Examples
Over a list
The case it exists for. placement pins it to a corner against the nearest positioned ancestor, which on a screen means the screen — so give the content below it enough bottom padding to scroll clear. Nothing here can work out how tall your list is, and a button sitting on the last row forever is the failure people actually hit.
<View className="flex-1">
<ScrollView contentContainerStyle={{ paddingBottom: 96 }}>
{/* …rows… */}
</ScrollView>
<Fab
placement="bottom-right"
icon={<PlusIcon size={24} />}
accessibilityLabel="New note"
haptics
onPress={compose}
/>
</View>Spelling the action out
A lone glyph is a guess unless the glyph is a plus. extended turns the circle into a stadium and puts the label beside it — worth the width for anything a plus would not have said on its own.
<Fab extended placement="bottom-right" icon={<PencilIcon size={20} />}>
Write
</Fab>A speed dial
The actions unfold one after another rather than together — a few frames apart each, which is enough to read as a list opening instead of a menu appearing. Every one carries its label, because a column of unlabelled circles is a quiz.
Opening drops a scrim over the screen. Not for looks: an open dial is modal — the next tap either picks something or closes it — and the scrim is both what says so and what catches the tap.
<Fab.Group icon={<PlusIcon size={24} />} accessibilityLabel="Add something" blur>
<Fab.Action icon={<ImageIcon size={18} />} label="Photo" onPress={addPhoto} />
<Fab.Action icon={<PaperclipIcon size={18} />} label="Attachment" onPress={attach} />
<Fab.Action icon={<MicIcon size={18} />} label="Voice note" onPress={record} />
<Fab.Action icon={<TrashIcon size={18} />} label="Empty drafts" destructive onPress={empty} />
</Fab.Group>In the flow instead of over it
Leave placement out and it is an ordinary round button where you put it — for a screen that already has somewhere for it to sit, and for laying several out side by side.
<View className="flex-row items-center gap-4">
<Fab icon={<PlusIcon size={20} />} accessibilityLabel="Add" />
<Fab variant="surface" icon={<SearchIcon size={20} />} accessibilityLabel="Search" />
<Fab variant="destructive" icon={<TrashIcon size={20} />} accessibilityLabel="Delete" />
</View>Versions
Over a list
The case it exists for — one action pinned over content that scrolls under it.
<View className="flex-1">
<ScrollView contentContainerStyle={{ paddingBottom: 96 }}>
{/* …rows… */}
</ScrollView>
<Fab
placement="bottom-right"
icon={<PlusIcon size={24} />}
accessibilityLabel="New note"
haptics
onPress={compose}
/>
</View>A speed dial
Actions unfolding out of the button, over a screen that says the dial is modal.
<Fab.Group icon={<PlusIcon size={24} />} accessibilityLabel="Add something" blur>
<Fab.Action icon={<ImageIcon size={18} />} label="Photo" onPress={addPhoto} />
<Fab.Action icon={<PaperclipIcon size={18} />} label="Attachment" onPress={attach} />
<Fab.Action icon={<MicIcon size={18} />} label="Voice note" onPress={record} />
<Fab.Action icon={<TrashIcon size={18} />} label="Empty drafts" destructive onPress={empty} />
</Fab.Group>Where it parks
The extended form, in each of the three corners it can take.
<Fab extended placement={placement} icon={<PencilIcon size={20} />}>
Write
</Fab>
{/* placement is 'bottom-left' | 'bottom-center' | 'bottom-right' */}Variants
size
smmd(default)lg
<Fab size="sm">…</Fab>
<Fab size="md">…</Fab>
<Fab size="lg">…</Fab>extended
truefalse(default)
<Fab extended="true">…</Fab>
<Fab extended="false">…</Fab>variant
primary(default)secondarysurfacedestructive
<Fab variant="primary">…</Fab>
<Fab variant="secondary">…</Fab>
<Fab variant="surface">…</Fab>
<Fab variant="destructive">…</Fab>API Reference
Fab
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — | |
icon | ReactNode | — | The glyph. Sized by you — this is the one thing that should not guess. |
extended | boolean | false | Spell the action out beside the glyph. |
placement | FabPlacement | 'bottom-right' | Pin it over the content, in a corner. Left out, it is an ordinary button in the flow — which is what you want inside a Fab.Group, or when the screen already has somewhere for it to sit. |
offset | number | DEFAULT_OFFSET | Distance from the edges when placement is set, in points. |
onPress | () => void | — | |
disabled | boolean | false | |
haptics | boolean | false | A tick on press. Off by default — needs the optional expo-haptics, and is silent without it. |
accessibilityLabel | string | — | Required for an icon-only button. A lone glyph reads out as nothing. |
Fab.Group
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — | |
icon | ReactNode | — | The glyph on the trigger. |
label | string | — | The trigger's label, if it should be extended while closed. |
open | boolean | — | Controlled open state. |
onOpenChange | (open: boolean) => void | — | |
placement | FabPlacement | 'bottom-right' | Which corner of the screen the whole dial parks in. |
offset | number | DEFAULT_OFFSET | Distance from the screen's edges, in points. Add your safe-area inset. |
size | FabSize | md | |
variant | FabVariant | primary | |
disabled | boolean | false | |
haptics | boolean | false | |
blur | boolean | false | Frost the screen behind the open dial instead of dimming it. |
accessibilityLabel | string | — | Required — the trigger is a lone glyph until it is opened. |
rotateOnOpen | boolean | true | Turn the trigger's glyph a quarter circle while the dial is open. On by default, and it is doing real work when the glyph is a plus: the same mark becomes a cross, which says "this closes now" without a second icon that has to be swapped in. Turn it off for a glyph that means something at one angle only. |
Fab.Action
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — | |
icon | ReactNode | — | The glyph. |
label | string | — | What it does, beside the glyph. A column of unlabelled circles is a quiz. |
onPress | () => void | — | |
disabled | boolean | false | |
destructive | boolean | — | Draws it in the destructive colour, for the one that removes something. |
labelClassName | string | — | Extra classes for the label chip. |
Every part also accepts the underlying React Native props (ViewProps or TextProps) and a className for Tailwind utilities.
Notes
Give it a label
accessibilityLabel is not optional in practice. An icon-only button reads out as nothing at all, and the plus that is obvious to someone looking at it says nothing to someone who is not. Fab.Action takes its label from label, so it is already covered.
The actions are unmounted while the dial is closed
Not hidden. A column kept alive behind the trigger would still be in the accessibility tree, and a screen reader would walk into four buttons nobody can see.
One timing, not one per action
The whole dial runs off a single shared value, and each action works out its own share of it on the UI thread from its index. There is no chain of timeouts to fall out of step with itself when the dial is closed halfway through opening — closing simply runs the same value back down, and every action follows it.
The trigger turns rather than swapping
rotateOnOpen gives the glyph a quarter circle while the dial is up, which is doing real work when the glyph is a plus: the same mark becomes a cross, so the button says this closes now without a second icon to swap in and animate between. Turn it off for a glyph that means something at one angle only.
A group draws itself above everything
The scrim and the buttons go through the same portal, scrim first. They have to travel together: portalled content is drawn above everything in the ordinary tree, so a portalled scrim over an in-place trigger would cover the trigger and its actions — the dial would open and immediately vanish behind its own backdrop.
One consequence worth knowing: a group's offset is measured from the screen's edges rather than from whatever it is written inside, which is what lets you declare it next to the content it belongs to instead of at the root of the screen. Add your safe-area inset to it if you want it clear of the home indicator. A plain Fab is not portalled and anchors to its nearest positioned ancestor, as an absolutely positioned view normally would.