Skip to the content

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 button with aria-haspopup="menu", aria-expanded and aria-controls; the actions are a ul with role="menu", named by the trigger, holding buttons with role="menuitem". Closed, the menu is inert: out of the focus order and the accessibility tree.
Labels
The trigger is named by the provider's speedDialActions string ("Actions"), the same open or closed: aria-expanded says which. Pass aria-label to name it yourself. Each action is named by its label, 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

Keyboard
KeyBehaviour
EnterorSpaceOn 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.
HomeorEndMoves to the first or last action.
EscapeCloses the actions and returns focus to the trigger.
TabCloses the actions and moves on.

API

SpeedDial

Renders a div and passes it every other prop.

SpeedDial props
PropTypeDefaultDescription
defaultOpenbooleanfalseWhether 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.
maskbooleanfalseDims the positioned container behind the open actions; a click on it closes them.
maskClassNamestringClasses for the mask.
onOpenChange((open: boolean) => void)Called with the new state when the actions open or close.
openbooleanWhether the actions show, when you control it. Pair it with onOpenChange.
radiusnumberThe 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.
transitionDelaynumber30Milliseconds between one action's entrance and the next. 30 by default; 0 opens them together.
type"circle" | "linear" | "semi-circle" | "quarter-circle"linearlinear (default) lines the actions up; circle, semi-circle and quarter-circle set them on an arc.

SpeedDialTrigger

SpeedDialTrigger props
PropTypeDefaultDescription
asChildboolean
loadingbooleanShows 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.
raisedboolean | null
roundedboolean | 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

SpeedDialAction props
PropTypeDefaultDescription
labelrequiredstringThe action's name: its accessible name and its tooltip. Required, as the action shows an icon only.
asChildboolean
loadingbooleanShows 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.
raisedboolean | null
roundedboolean | null
severity"success" | "info" | "warning" | "help" | "danger" | "contrast" | null
size"default" | "xs" | "sm" | "lg" | "icon" | "icon-xs" | "icon-sm" | "icon-lg" | nullicon-smButton's size; icon-sm (28 px) by default.
tooltipbooleantrueShows the label in a tooltip on hover and focus. true by default.
variant"link" | "default" | "destructive" | "outline" | "secondary" | "ghost" | nullsecondaryButton'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.

Theme tokens
TokenUsed for
--maskbackground