ComponentsButton
Speed dial
A floating action button that opens a set of actions in a line or on an arc.
Import
import { SpeedDial, SpeedDialTrigger, SpeedDialContent, SpeedDialAction } from "@booleanpress/ui/speed-dial"Usage
A speed dial keeps a few frequent actions behind one round button, usually in a corner of the screen or of a canvas. For a longer or labelled list, use a dropdown menu.
import { PencilIcon, Trash2Icon } from "lucide-react"
import { SpeedDial, SpeedDialAction, SpeedDialContent, SpeedDialTrigger } from "@booleanpress/ui/speed-dial"
export function TemplateActions() {
return (
<div className="relative h-60">
<SpeedDial className="absolute end-4 bottom-4">
<SpeedDialTrigger />
<SpeedDialContent>
<SpeedDialAction label="Edit template"><PencilIcon /></SpeedDialAction>
<SpeedDialAction label="Delete template"><Trash2Icon /></SpeedDialAction>
</SpeedDialContent>
</SpeedDial>
</div>
)
}Place the SpeedDial yourself, usually absolute or fixed in a positioned container. type="linear" (default) lines the actions up in a direction (up, down, left, right); circle sets them round the trigger; semi-circle on half a circle facing a direction; quarter-circle on a quarter facing up-left, up-right, down-left or down-right. Directions are physical: they do not flip in a right-to-left page. radius sets the arc's size, transitionDelay the stagger between actions. Each action needs a label: its accessible name and its tooltip, which opens on hover and focus on tooltipSide. mask dims the positioned container while the actions are open. It is uncontrolled, or controlled with open and onOpenChange. The trigger is a round 36 px button that takes severity; its plus turns into a cross while open, and children replace it. An action is a 28 px secondary button; variant, size and children change it.
Examples
Linear
Five actions above the trigger, each with a tooltip.
import { ExternalLinkIcon, PencilIcon, RotateCwIcon, Trash2Icon, UploadIcon } from "lucide-react"
import { SpeedDial, SpeedDialAction, SpeedDialContent, SpeedDialTrigger } from "@booleanpress/ui/speed-dial"
const ACTIONS = [
{ label: "Edit template", icon: PencilIcon },
{ label: "Retry failed emails", icon: RotateCwIcon },
{ label: "Delete template", icon: Trash2Icon },
{ label: "Import contacts", icon: UploadIcon },
{ label: "Open the delivery log", icon: ExternalLinkIcon },
]
export default function SpeedDialLinear() {
return (
<div className="relative h-60 w-full max-w-lg">
<SpeedDial className="absolute end-0 bottom-0">
<SpeedDialTrigger />
<SpeedDialContent>
{ACTIONS.map((action) => (
<SpeedDialAction key={action.label} label={action.label}>
<action.icon />
</SpeedDialAction>
))}
</SpeedDialContent>
</SpeedDial>
</div>
)
}Directions
direction sends the line up, down, left or right.
import { ExternalLinkIcon, PencilIcon, RotateCwIcon, Trash2Icon, UploadIcon } from "lucide-react"
import { SpeedDial, SpeedDialAction, SpeedDialContent, SpeedDialTrigger } from "@booleanpress/ui/speed-dial"
const ACTIONS = [
{ label: "Edit template", icon: PencilIcon },
{ label: "Retry failed emails", icon: RotateCwIcon },
{ label: "Delete template", icon: Trash2Icon },
{ label: "Import contacts", icon: UploadIcon },
{ label: "Open the delivery log", icon: ExternalLinkIcon },
]
const DIALS = [
{ direction: "up", place: "bottom-0 left-1/2 -translate-x-1/2" },
{ direction: "down", place: "top-0 left-1/2 -translate-x-1/2" },
{ direction: "left", place: "top-1/2 right-0 -translate-y-1/2" },
{ direction: "right", place: "top-1/2 left-0 -translate-y-1/2" },
] as const
export default function SpeedDialDirections() {
return (
<div className="relative h-[28rem] w-full max-w-xl">
{DIALS.map((dial) => (
<SpeedDial key={dial.direction} direction={dial.direction} className={`absolute ${dial.place}`}>
<SpeedDialTrigger />
<SpeedDialContent>
{ACTIONS.map((action) => (
<SpeedDialAction key={action.label} label={action.label}>
<action.icon />
</SpeedDialAction>
))}
</SpeedDialContent>
</SpeedDial>
))}
</div>
)
}Circle
type="circle" sets the actions all round the trigger.
import {
ExternalLinkIcon,
HeartIcon,
PencilIcon,
RotateCwIcon,
SettingsIcon,
Trash2Icon,
UploadIcon,
UserIcon,
} from "lucide-react"
import { SpeedDial, SpeedDialAction, SpeedDialContent, SpeedDialTrigger } from "@booleanpress/ui/speed-dial"
const ACTIONS = [
{ label: "Edit template", icon: PencilIcon },
{ label: "Retry failed emails", icon: RotateCwIcon },
{ label: "Delete template", icon: Trash2Icon },
{ label: "Import contacts", icon: UploadIcon },
{ label: "Open the delivery log", icon: ExternalLinkIcon },
{ label: "Settings", icon: SettingsIcon },
{ label: "Assign to me", icon: UserIcon },
{ label: "Add to favourites", icon: HeartIcon },
]
export default function SpeedDialCircle() {
return (
<div className="relative h-80 w-full max-w-lg">
<SpeedDial type="circle" className="absolute top-1/2 left-1/2 -translate-1/2">
<SpeedDialTrigger severity="warning" />
<SpeedDialContent>
{ACTIONS.map((action) => (
<SpeedDialAction key={action.label} label={action.label}>
<action.icon />
</SpeedDialAction>
))}
</SpeedDialContent>
</SpeedDial>
</div>
)
}Semicircle
type="semi-circle" sets them on half a circle facing each direction.
import { ExternalLinkIcon, PencilIcon, RotateCwIcon, Trash2Icon, UploadIcon } from "lucide-react"
import { SpeedDial, SpeedDialAction, SpeedDialContent, SpeedDialTrigger } from "@booleanpress/ui/speed-dial"
const ACTIONS = [
{ label: "Edit template", icon: PencilIcon },
{ label: "Retry failed emails", icon: RotateCwIcon },
{ label: "Delete template", icon: Trash2Icon },
{ label: "Import contacts", icon: UploadIcon },
{ label: "Open the delivery log", icon: ExternalLinkIcon },
]
const DIALS = [
{ direction: "up", place: "bottom-0 left-1/2 -translate-x-1/2" },
{ direction: "down", place: "top-0 left-1/2 -translate-x-1/2" },
{ direction: "left", place: "top-1/2 right-0 -translate-y-1/2" },
{ direction: "right", place: "top-1/2 left-0 -translate-y-1/2" },
] as const
export default function SpeedDialSemiCircle() {
return (
<div className="relative h-[28rem] w-full max-w-xl">
{DIALS.map((dial) => (
<SpeedDial key={dial.direction} type="semi-circle" direction={dial.direction} className={`absolute ${dial.place}`}>
<SpeedDialTrigger severity="success" />
<SpeedDialContent>
{ACTIONS.map((action) => (
<SpeedDialAction key={action.label} label={action.label}>
<action.icon />
</SpeedDialAction>
))}
</SpeedDialContent>
</SpeedDial>
))}
</div>
)
}Quarter circle
type="quarter-circle" from each corner, facing the middle.
import { ExternalLinkIcon, PencilIcon, RotateCwIcon, Trash2Icon, UploadIcon } from "lucide-react"
import { SpeedDial, SpeedDialAction, SpeedDialContent, SpeedDialTrigger } from "@booleanpress/ui/speed-dial"
const ACTIONS = [
{ label: "Edit template", icon: PencilIcon },
{ label: "Retry failed emails", icon: RotateCwIcon },
{ label: "Delete template", icon: Trash2Icon },
{ label: "Import contacts", icon: UploadIcon },
{ label: "Open the delivery log", icon: ExternalLinkIcon },
]
// Each dial sits in a corner and opens its quarter circle towards the middle.
const DIALS = [
{ direction: "down-right", place: "top-0 left-0" },
{ direction: "down-left", place: "top-0 right-0" },
{ direction: "up-right", place: "bottom-0 left-0" },
{ direction: "up-left", place: "bottom-0 right-0" },
] as const
export default function SpeedDialQuarterCircle() {
return (
<div className="relative h-[28rem] w-full max-w-xl">
{DIALS.map((dial) => (
<SpeedDial key={dial.direction} type="quarter-circle" direction={dial.direction} className={`absolute ${dial.place}`}>
<SpeedDialTrigger />
<SpeedDialContent>
{ACTIONS.map((action) => (
<SpeedDialAction key={action.label} label={action.label}>
<action.icon />
</SpeedDialAction>
))}
</SpeedDialContent>
</SpeedDial>
))}
</div>
)
}Transition delay
transitionDelay of 0, 30 (default) and 80 ms between the actions' entrances.
import { ExternalLinkIcon, PencilIcon, RotateCwIcon, Trash2Icon, UploadIcon } from "lucide-react"
import { SpeedDial, SpeedDialAction, SpeedDialContent, SpeedDialTrigger } from "@booleanpress/ui/speed-dial"
const ACTIONS = [
{ label: "Edit template", icon: PencilIcon },
{ label: "Retry failed emails", icon: RotateCwIcon },
{ label: "Delete template", icon: Trash2Icon },
{ label: "Import contacts", icon: UploadIcon },
{ label: "Open the delivery log", icon: ExternalLinkIcon },
]
// 0 opens every action at once; the default staggers them 30 ms apart; 80 makes the cascade easy to see.
const DIALS = [
{ delay: 0, place: "bottom-0 left-0", severity: "contrast" },
{ delay: 30, place: "bottom-0 left-1/2 -translate-x-1/2", severity: "success" },
{ delay: 80, place: "bottom-0 right-0", severity: "info" },
] as const
export default function SpeedDialTransitionDelay() {
return (
<div className="relative h-60 w-full max-w-lg">
{DIALS.map((dial) => (
<SpeedDial key={dial.delay} transitionDelay={dial.delay} className={`absolute ${dial.place}`}>
<SpeedDialTrigger severity={dial.severity} />
<SpeedDialContent>
{ACTIONS.map((action) => (
<SpeedDialAction key={action.label} label={action.label}>
<action.icon />
</SpeedDialAction>
))}
</SpeedDialContent>
</SpeedDial>
))}
</div>
)
}Labelled actions
Actions that show their label as text, with no tooltip, in a column under the trigger.
import { CopyIcon, PrinterIcon, SaveIcon, Share2Icon } from "lucide-react"
import { SpeedDial, SpeedDialAction, SpeedDialContent, SpeedDialTrigger } from "@booleanpress/ui/speed-dial"
const ACTIONS = [
{ label: "Share report", icon: Share2Icon },
{ label: "Print", icon: PrinterIcon },
{ label: "Save as PDF", icon: SaveIcon },
{ label: "Copy link", icon: CopyIcon },
]
export default function SpeedDialLabelledActions() {
return (
<div className="relative h-72 w-full max-w-lg">
<SpeedDial direction="down" className="absolute top-0 left-1/2 -translate-x-1/2">
<SpeedDialTrigger severity="warning" />
<SpeedDialContent className="items-stretch">
{ACTIONS.map((action) => (
// With its label visible, an action needs no tooltip.
<SpeedDialAction key={action.label} label={action.label} tooltip={false} variant="outline" size="default" className="w-full justify-start">
<action.icon /> {action.label}
</SpeedDialAction>
))}
</SpeedDialContent>
</SpeedDial>
</div>
)
}With tooltips
tooltipSide puts each label on the side with room, here for two dials in opposite corners.
import { ExternalLinkIcon, PencilIcon, RotateCwIcon, Trash2Icon, UploadIcon } from "lucide-react"
import { SpeedDial, SpeedDialAction, SpeedDialContent, SpeedDialTrigger } from "@booleanpress/ui/speed-dial"
const ACTIONS = [
{ label: "Edit template", icon: PencilIcon },
{ label: "Retry failed emails", icon: RotateCwIcon },
{ label: "Delete template", icon: Trash2Icon },
{ label: "Import contacts", icon: UploadIcon },
{ label: "Open the delivery log", icon: ExternalLinkIcon },
]
// Each action shows its label on hover and focus; `tooltipSide` puts the labels on the side with room.
const DIALS = [
{ side: "right", place: "bottom-0 left-0", severity: "danger" },
{ side: "left", place: "bottom-0 right-0", severity: "help" },
] as const
export default function SpeedDialWithTooltips() {
return (
<div className="relative h-72 w-full max-w-lg">
{DIALS.map((dial) => (
<SpeedDial key={dial.side} tooltipSide={dial.side} className={`absolute ${dial.place}`}>
<SpeedDialTrigger severity={dial.severity} />
<SpeedDialContent>
{ACTIONS.map((action) => (
<SpeedDialAction key={action.label} label={action.label}>
<action.icon />
</SpeedDialAction>
))}
</SpeedDialContent>
</SpeedDial>
))}
</div>
)
}With mask
mask dims the container while the actions are open; a click on it closes them.
import { ExternalLinkIcon, PencilIcon, RotateCwIcon, Trash2Icon, UploadIcon } from "lucide-react"
import { SpeedDial, SpeedDialAction, SpeedDialContent, SpeedDialTrigger } from "@booleanpress/ui/speed-dial"
const ACTIONS = [
{ label: "Edit template", icon: PencilIcon },
{ label: "Retry failed emails", icon: RotateCwIcon },
{ label: "Delete template", icon: Trash2Icon },
{ label: "Import contacts", icon: UploadIcon },
{ label: "Open the delivery log", icon: ExternalLinkIcon },
]
export default function SpeedDialWithMask() {
return (
// The mask covers the nearest positioned container: here, this box.
<div className="relative h-80 w-full max-w-xl rounded-md">
<SpeedDial mask className="absolute end-4 bottom-4">
<SpeedDialTrigger />
<SpeedDialContent>
{ACTIONS.map((action) => (
<SpeedDialAction key={action.label} label={action.label}>
<action.icon />
</SpeedDialAction>
))}
</SpeedDialContent>
</SpeedDial>
</div>
)
}Accessibility
- Semantics
- The menu button pattern: the trigger is a
buttonwitharia-haspopup="menu",aria-expandedandaria-controls; the actions are aulwithrole="menu", named by the trigger, holding buttons withrole="menuitem". Closed, the menu isinert: out of the focus order and the accessibility tree. - Labels
- The trigger is named by the provider's
speedDialActionsstring ("Actions"), the same open or closed:aria-expandedsays which. Passaria-labelto name it yourself. Each action is named by itslabel, which its tooltip also shows. - Focus
- The trigger is the only tab stop. Opening with the keyboard moves focus to the first action; the arrows move between actions, wrapping at the ends. Choosing an action, or Escape, closes the menu and returns focus to the trigger; Tab closes it and moves on. Opening with the pointer leaves focus on the trigger.
- Known limits
- The actions are commands: a menu, not a disclosure of buttons, so they share one tab stop and keep the page's tab order short. Use a toolbar or plain buttons for actions people need to tab through.
- Actions are icons: the tooltip shows the label to sighted pointer and keyboard users, the accessible name to screen readers. With
tooltip={false}, show the label as text. - Directions are physical and do not flip in a right-to-left page; place the speed dial for the page's direction.
- The 28 px actions meet WCAG 2.5.8's 24 px target size, with 8 px between them in a line.
Keyboard
| Key | Behaviour |
|---|---|
| EnterorSpace | On the trigger: opens the actions with focus on the first, or closes them. On an action: runs it and closes the menu. |
| โorโorโorโ | On the trigger: opens the actions with focus on the first (on the last with โ or โ when open). |
| โorโ | In a vertical line: the arrow pointing away from the trigger moves to the next action, the other to the previous, wrapping. On an arc, โ and โ move on, โ and โ back. |
| โorโ | In a horizontal line: the arrow pointing away from the trigger moves to the next action, the other to the previous. |
| HomeorEnd | Moves to the first or last action. |
| Escape | Closes the actions and returns focus to the trigger. |
| Tab | Closes the actions and moves on. |
API
SpeedDial
Renders a div and passes it every other prop.
| Prop | Type | Default | Description |
|---|---|---|---|
defaultOpen | boolean | false | Whether the actions show at the start, when the speed dial controls itself. |
direction | "left" | "right" | "up" | "down" | "up-left" | "up-right" | "down-left" | "down-right" | Where the actions go from the trigger. A line or a semicircle: up (default), down, left, right; a quarter
circle: up-left (default), up-right, down-left, down-right. A circle ignores it. Physical, as the page sees it. | |
mask | boolean | false | Dims the positioned container behind the open actions; a click on it closes them. |
maskClassName | string | Classes for the mask. | |
onOpenChange | ((open: boolean) => void) | Called with the new state when the actions open or close. | |
open | boolean | Whether the actions show, when you control it. Pair it with onOpenChange. | |
radius | number | The arc's radius in px, centre to centre: 80 by default, 120 for a quarter circle. | |
tooltipSide | "top" | "bottom" | "left" | "right" | The side of each action its tooltip opens on. By default beside a vertical line, above anything else. | |
transitionDelay | number | 30 | Milliseconds between one action's entrance and the next. 30 by default; 0 opens them together. |
type | "circle" | "linear" | "semi-circle" | "quarter-circle" | linear | linear (default) lines the actions up; circle, semi-circle and quarter-circle set them on an arc. |
SpeedDialTrigger
| Prop | Type | Default | Description |
|---|---|---|---|
asChild | boolean | ||
loading | boolean | Shows the spinner in place of the leading icon, sets aria-busy and aria-disabled and ignores presses (a click,
Enter, Space, a form's submission), while the button keeps its focus and its place in the tab order. With
asChild the child (a link) gets the same. | |
raised | boolean | null | ||
rounded | boolean | null | ||
severity | "success" | "info" | "warning" | "help" | "danger" | "contrast" | null | ||
size | "default" | "xs" | "sm" | "lg" | "icon" | "icon-xs" | "icon-sm" | "icon-lg" | null | ||
variant | "link" | "default" | "destructive" | "outline" | "secondary" | "ghost" | null |
SpeedDialContent
Renders a ul and passes it every other prop.
SpeedDialAction
| Prop | Type | Default | Description |
|---|---|---|---|
labelrequired | string | The action's name: its accessible name and its tooltip. Required, as the action shows an icon only. | |
asChild | boolean | ||
loading | boolean | Shows the spinner in place of the leading icon, sets aria-busy and aria-disabled and ignores presses (a click,
Enter, Space, a form's submission), while the button keeps its focus and its place in the tab order. With
asChild the child (a link) gets the same. | |
raised | boolean | null | ||
rounded | boolean | null | ||
severity | "success" | "info" | "warning" | "help" | "danger" | "contrast" | null | ||
size | "default" | "xs" | "sm" | "lg" | "icon" | "icon-xs" | "icon-sm" | "icon-lg" | null | icon-sm | Button's size; icon-sm (28 px) by default. |
tooltip | boolean | true | Shows the label in a tooltip on hover and focus. true by default. |
variant | "link" | "default" | "destructive" | "outline" | "secondary" | "ghost" | null | secondary | Button's look; secondary by default. |
Every part takes className, merged with its defaults by cn(), and ref, which reaches the element it renders.
Data attributes: data-slot="speed-dial-mask" (SpeedDial), data-slot="speed-dial-trigger" (SpeedDialTrigger), data-slot="speed-dial-content" (SpeedDialContent), data-slot="speed-dial-action" (SpeedDialAction), and data-state, data-type, data-direction.
Provider strings: speedDialActions (BooleanUIProvider's strings).
Theming
The trigger is a primary button (--primary), or a severity's fill; the actions are secondary buttons (--secondary). They grow from nothing with transform and opacity over --bui-duration-slow, with no stagger and no movement under reduced motion. The mask is --mask.
The tokens its classes read. Change their values in your theme, and it follows.
| Token | Used for |
|---|---|
--mask | background |