# 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"`
- **APG Menu button:** <https://www.w3.org/WAI/ARIA/apg/patterns/menu-button/>
- **Page:** <https://ui.booleanpress.com/components/speed-dial> · @booleanpress/ui 0.2.0

## 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](/components/dropdown-menu).

```tsx
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](/components/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.

```tsx
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.

```tsx
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.

```tsx
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`.

```tsx
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.

```tsx
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.

```tsx
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.

```tsx
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.

```tsx
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.

```tsx
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

| Key | Behaviour |
| --- | --- |
| Enter or Space | 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. |
| Home or End | 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 |
| --- | --- | --- | --- |
| `label` (required) | `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`.

| Token | Used for |
| --- | --- |
| `--mask` | background |
