# Speed Dial

> A floating action button that fans out labeled quick actions with staggered motion and full keyboard support.

- Author: Atelier UI
- License: MIT
- Page: https://chromotion.dev/en/components/speed-dial
- Site: Atelier UI (https://chromotion.dev)

## Install

```bash
npx shadcn@latest add https://chromotion.dev/r/speed-dial.json
```

Stack: React + TypeScript + Tailwind CSS v4, shadcn/ui conventions. `cn` comes from `@/lib/utils`.

## Dependencies

```bash
npm install lucide-react
```

## Usage

```tsx title="components/speed-dial-demo.tsx"
"use client"

import * as React from "react"
import { FilePlus, FolderPlus, Plus, ScanLine, Share2, Upload, Wand2 } from "lucide-react"

import { SpeedDial } from "@/components/speed-dial"

export default function SpeedDialDemo() {
  const [last, setLast] = React.useState<string | null>(null)

  const pick = (label: string) => () => setLast(label)

  return (
    <div className="relative h-[460px] w-full overflow-hidden rounded-2xl border bg-card">
      <div className="grid gap-4 p-5 pr-24 sm:p-6 sm:pr-28">
        <div>
          <p className="text-sm font-semibold">Project files</p>
          <p className="text-sm text-muted-foreground">Open the round button for quick actions, or press Esc to close it.</p>
        </div>
        <div className="grid gap-2">
          {["brand-guidelines.pdf", "homepage-v3.fig", "launch-notes.md"].map((name) => (
            <div key={name} className="flex min-w-0 items-center gap-3 rounded-lg border bg-background px-3 py-2.5 text-sm">
              <span className="size-2 shrink-0 rounded-full bg-primary/60" />
              <span className="min-w-0 flex-1 truncate">{name}</span>
              <span className="shrink-0 text-xs text-muted-foreground">Edited today</span>
            </div>
          ))}
        </div>
        <p role="status" aria-live="polite" className="min-h-5 text-sm text-muted-foreground">
          {last ? (
            <>
              Last action: <span className="font-medium text-foreground">{last}</span>
            </>
          ) : (
            "No action chosen yet."
          )}
        </p>
      </div>

      <div className="absolute top-5 right-5 sm:top-6 sm:right-6">
        <SpeedDial
          label="Editor tools"
          direction="left"
          icon={<Wand2 />}
          actions={[
            { id: "scan", label: "Scan document", icon: <ScanLine />, onSelect: pick("Scan document") },
            { id: "share", label: "Share link", icon: <Share2 />, onSelect: pick("Share link") },
          ]}
          className="[&>button]:size-12"
        />
      </div>

      <div className="absolute right-5 bottom-5 sm:right-6 sm:bottom-6">
        <SpeedDial
          label="Create new"
          icon={<Plus />}
          actions={[
            { id: "file", label: "New file", icon: <FilePlus />, onSelect: pick("New file") },
            { id: "folder", label: "New folder", icon: <FolderPlus />, onSelect: pick("New folder") },
            { id: "upload", label: "Upload", icon: <Upload />, onSelect: pick("Upload") },
          ]}
        />
      </div>
    </div>
  )
}
```

## Source

```tsx title="components/speed-dial.tsx"
"use client"

import * as React from "react"
import { Plus } from "lucide-react"

import { cn } from "@/lib/utils"

export interface SpeedDialAction {
  id: string
  /** Accessible name, also shown as the tooltip */
  label: string
  icon: React.ReactNode
  onSelect?: () => void
}

export interface SpeedDialProps extends Omit<React.ComponentProps<"div">, "children"> {
  actions: SpeedDialAction[]
  /** Direction the actions fan out in. Up puts tooltips beside the buttons, left puts them underneath. */
  direction?: "up" | "left"
  /** Accessible name of the main button and of the menu */
  label?: string
  /** Icon of the main button; it rotates 45 degrees while open */
  icon?: React.ReactNode
  defaultOpen?: boolean
  onOpenChange?: (open: boolean) => void
}

const stack = {
  up: "bottom-full left-1/2 mb-3 -translate-x-1/2 flex-col-reverse",
  left: "right-full top-1/2 mr-3 -translate-y-1/2 flex-row-reverse",
} as const

const hidden = { up: "translate-y-3", left: "translate-x-3" } as const

const tip = {
  up: "right-full top-1/2 mr-3 -translate-y-1/2",
  left: "top-full left-1/2 mt-2 -translate-x-1/2",
} as const

export function SpeedDial({
  actions,
  direction = "up",
  label = "Quick actions",
  icon,
  defaultOpen = false,
  onOpenChange,
  className,
  ...props
}: SpeedDialProps) {
  const [open, setOpenState] = React.useState(defaultOpen)
  const root = React.useRef<HTMLDivElement>(null)
  const fab = React.useRef<HTMLButtonElement>(null)
  const focusFirst = React.useRef(false)
  const menuId = React.useId()

  const setOpen = React.useCallback(
    (next: boolean) => {
      setOpenState(next)
      onOpenChange?.(next)
    },
    [onOpenChange]
  )

  React.useEffect(() => {
    if (!open) return
    if (focusFirst.current) {
      focusFirst.current = false
      root.current?.querySelector<HTMLElement>('[role="menuitem"]')?.focus()
    }
    const onPointerDown = (event: PointerEvent) => {
      if (!root.current?.contains(event.target as Node)) setOpen(false)
    }
    const onKeyDown = (event: KeyboardEvent) => {
      if (event.key !== "Escape") return
      setOpen(false)
      fab.current?.focus()
    }
    document.addEventListener("pointerdown", onPointerDown)
    document.addEventListener("keydown", onKeyDown)
    return () => {
      document.removeEventListener("pointerdown", onPointerDown)
      document.removeEventListener("keydown", onKeyDown)
    }
  }, [open, setOpen])

  function onMenuKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
    const items = Array.from(event.currentTarget.querySelectorAll<HTMLElement>('[role="menuitem"]'))
    const index = items.indexOf(document.activeElement as HTMLElement)
    if (index < 0) return
    let target: number | null = null
    if (event.key === "ArrowUp" || event.key === "ArrowLeft") target = (index + 1) % items.length
    else if (event.key === "ArrowDown" || event.key === "ArrowRight") target = (index - 1 + items.length) % items.length
    else if (event.key === "Home") target = 0
    else if (event.key === "End") target = items.length - 1
    if (target === null) return
    event.preventDefault()
    items[target].focus()
  }

  return (
    <div
      ref={root}
      className={cn("relative inline-flex", className)}
      onBlur={(event) => {
        const next = event.relatedTarget as Node | null
        if (open && next && !event.currentTarget.contains(next)) setOpen(false)
      }}
      {...props}
    >
      <div
        id={menuId}
        role="menu"
        aria-label={label}
        aria-orientation={direction === "up" ? "vertical" : "horizontal"}
        onKeyDown={onMenuKeyDown}
        className={cn("absolute flex items-center gap-3", stack[direction], !open && "pointer-events-none")}
      >
        {actions.map((action, index) => (
          <div
            key={action.id}
            role="none"
            style={{ "--delay": open ? `${index * 50}ms` : "0ms" } as React.CSSProperties}
            className={cn(
              "group relative transition-[opacity,translate,scale,visibility] duration-200 ease-out [transition-delay:var(--delay)] motion-reduce:transition-none motion-reduce:[transition-delay:0ms]",
              open ? "visible translate-x-0 translate-y-0 scale-100 opacity-100" : cn("invisible scale-50 opacity-0", hidden[direction])
            )}
          >
            <button
              type="button"
              role="menuitem"
              aria-label={action.label}
              tabIndex={open ? 0 : -1}
              onClick={() => {
                action.onSelect?.()
                setOpen(false)
                fab.current?.focus()
              }}
              className="flex size-11 items-center justify-center rounded-full border bg-card text-card-foreground shadow-md transition-colors hover:bg-accent focus-visible:ring-4 focus-visible:ring-ring/40 focus-visible:outline-none [&_svg]:size-5"
            >
              {action.icon}
            </button>
            <span
              aria-hidden
              className={cn(
                "pointer-events-none absolute z-10 rounded-md bg-foreground px-2.5 py-1 text-xs font-medium whitespace-nowrap text-background opacity-0 shadow-md transition-opacity duration-150 group-hover:opacity-100 group-has-[:focus-visible]:opacity-100",
                tip[direction]
              )}
            >
              {action.label}
            </span>
          </div>
        ))}
      </div>
      <button
        ref={fab}
        type="button"
        aria-label={label}
        aria-haspopup="menu"
        aria-expanded={open}
        aria-controls={menuId}
        onClick={() => {
          if (!open) focusFirst.current = true
          setOpen(!open)
        }}
        className="relative z-10 flex size-14 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg transition-[box-shadow,scale] hover:shadow-xl focus-visible:ring-4 focus-visible:ring-ring/40 focus-visible:outline-none active:scale-95 motion-reduce:transition-none"
      >
        <span className={cn("flex transition-transform duration-200 motion-reduce:transition-none [&_svg]:size-6", open && "rotate-45")}>
          {icon ?? <Plus />}
        </span>
      </button>
    </div>
  )
}
```
