Speed Dial
A floating + button that rotates to × and fans out labelled mini actions, staggered from the nearest one.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/speed-dial.json'use client'
import { useEffect, useRef, useState, type ReactNode } from 'react'
import './speed-dial.css'
export interface DialAction { id: string; label: string; icon: ReactNode }
export interface SpeedDialProps {
actions: DialAction[]
onAction?: (id: string) => void
showLabels?: boolean
fill?: 'dark' | 'light'
className?: string
}
/** Floating action button that fans out mini actions. */
export function SpeedDial({ actions, onAction, showLabels = true, fill = 'dark', className }: SpeedDialProps) {
const [open, setOpen] = useState(false)
const [last, setLast] = useState('')
const main = useRef<HTMLButtonElement>(null)
const items = useRef<(HTMLButtonElement | null)[]>([])
useEffect(() => { if (open) items.current[0]?.focus() }, [open])
const close = () => { setOpen(false); main.current?.focus() }
return (
<div className={['sd-spd', className].filter(Boolean).join(' ')} data-fill={fill} data-open={open || undefined} onKeyDown={(e) => e.key === 'Escape' && open && close()}>
<div className="sd-spd__msg" aria-live="polite">{last ? `New ${last.toLowerCase()}` : 'Tap + to create'}</div>
<div className="sd-spd__scrim" onClick={close} aria-hidden />
<div className="sd-spd__stack">
<button ref={main} type="button" className="sd-spd__main" aria-label={open ? 'Close' : 'Create'} aria-expanded={open} aria-haspopup="menu" onClick={() => setOpen(!open)}>
<svg viewBox="0 0 22 22" aria-hidden><path d="M11 4v14M4 11h14" /></svg>
</button>
<div className="sd-spd__menu" role="menu" aria-hidden={!open}>
{actions.map((a, i) => (
<button
key={a.id}
ref={(el) => { items.current[i] = el }}
type="button"
role="menuitem"
tabIndex={open ? 0 : -1}
className="sd-spd__it"
aria-label={a.label}
style={{ '--i': open ? i : actions.length - i } as React.CSSProperties}
onClick={() => { setLast(a.label); onAction?.(a.id); close() }}
onKeyDown={(e) => { const d = e.key === 'ArrowUp' ? 1 : e.key === 'ArrowDown' ? -1 : 0; if (d) { e.preventDefault(); items.current[(i + d + actions.length) % actions.length]?.focus() } }}
>
{showLabels && <span className="sd-spd__lab" aria-hidden>{a.label}</span>}
<span className="sd-spd__ic" aria-hidden>{a.icon}</span>
</button>
))}
</div>
</div>
</div>
)
}.sd-spd { --bg: #1c1c1c; --fg: #f6f6f6; --mute: #8e8e8e; --line: rgb(255 255 255 / 0.1); --soft: rgb(255 255 255 / 0.07); --strong: #fafafa; --on: #111;
color: var(--fg); font-size: 14px; line-height: 1.4; -webkit-tap-highlight-color: transparent; }
.sd-spd[data-fill='light'] { --bg: #fff; --fg: #121212; --mute: #6f6f6f; --line: rgb(0 0 0 / 0.09); --soft: rgb(0 0 0 / 0.05); --strong: #111; --on: #fff; }
.sd-spd button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-spd :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-spd .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-spd { position: relative; width: min(100%, 300px); height: 340px; border-radius: 22px; background: var(--soft); box-shadow: inset 0 0 0 1px var(--line); overflow: hidden; }
.sd-spd__scrim { position: absolute; inset: 0; background: color-mix(in srgb, var(--bg) 70%, transparent); opacity: 0; pointer-events: none; transition: opacity 240ms; }
.sd-spd[data-open] .sd-spd__scrim { opacity: 1; pointer-events: auto; }
.sd-spd__stack { position: absolute; right: 18px; bottom: 18px; display: flex; flex-direction: column-reverse; align-items: flex-end; gap: 12px; }
.sd-spd__main { display: grid; place-items: center; width: 56px; height: 56px; border-radius: 18px; background: var(--strong) !important; color: var(--on) !important; box-shadow: 0 10px 24px -10px rgb(0 0 0 / 0.6); transition: transform 160ms, border-radius 300ms; }
.sd-spd__main:active { transform: scale(0.92); }
.sd-spd[data-open] .sd-spd__main { border-radius: 28px; }
.sd-spd__main svg { width: 22px; height: 22px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; transition: transform 340ms cubic-bezier(0.3, 1.5, 0.4, 1); }
.sd-spd[data-open] .sd-spd__main svg { transform: rotate(135deg); }
.sd-spd__menu { display: flex; flex-direction: column-reverse; gap: 10px; align-items: flex-end; margin-right: 6px; }
.sd-spd__it { display: flex; align-items: center; gap: 10px; opacity: 0; transform: translateY(14px) scale(0.8); pointer-events: none; transition: opacity 180ms, transform 280ms cubic-bezier(0.3, 1.5, 0.4, 1); transition-delay: calc(var(--i) * 40ms); outline: 0; }
.sd-spd[data-open] .sd-spd__it { opacity: 1; transform: none; pointer-events: auto; }
.sd-spd__lab { padding: 6px 10px; border-radius: 8px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); font-size: 12.5px; font-weight: 500; white-space: nowrap; }
.sd-spd__ic { display: grid; place-items: center; width: 44px; height: 44px; border-radius: 14px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); }
.sd-spd__it:hover .sd-spd__ic, .sd-spd__it:focus-visible .sd-spd__ic { box-shadow: inset 0 0 0 1.5px var(--fg); }
.sd-spd__ic svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 1.5; stroke-linecap: round; stroke-linejoin: round; }
.sd-spd__msg { position: absolute; left: 18px; top: 18px; font-size: 13px; color: var(--mute); }
@media (prefers-reduced-motion: reduce) { .sd-spd *, .sd-spd *::before, .sd-spd *::after { animation: none !important; transition: none !important; } }Overview
Tap the button and its plus turns into a cross while the actions rise one after another, each with a label pill. Tapping outside, Escape or an action closes it.
Labels sit on the left so thumbs do not cover them on phones.
When to use
- Mobile create flows with 2–5 kinds of new item.
When not to use
- Desktop layouts with room for a toolbar.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/speed-dial.json$
npx shadcn@latest add https://sundaeui.dev/r/speed-dial.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/speed-dial.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/speed-dial.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy speed-dial.tsx and speed-dial.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { SpeedDial } from '@/components/sundae/speed-dial'
<SpeedDial />Props
| Prop | Type | Default |
|---|---|---|
actions*Mini actions, nearest first. | { id: string; label: string; icon: ReactNode }[] | — |
onActionCalled on pick. | (id: string) => void | — |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- The main button has aria-expanded and controls a role="menu" of menuitems.
- Focus moves into the menu on open and returns on close.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-spd; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
Keyboard
- Esc
- Close
- ↑ / ↓
- Move between actions
FAQ
- Is Speed Dial free?
- Yes. Speed Dial is free for personal and commercial projects, with full source on this page.
- How do I install Speed Dial?
- Run npx shadcn@latest add https://sundaeui.dev/r/speed-dial.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Speed Dial support keyboard and screen readers?
- The main button has aria-expanded and controls a role="menu" of menuitems. Focus moves into the menu on open and returns on close.
- Does Speed Dial respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All components →Split Button
Components · Refined- Pro
Sheet Drawer
Components · Refined Snooze Menu
Components · Refined- Pro
Context Menu
Components · Refined Pull to Refresh
Components · RefinedToast Stack
Components · Refined