Overview
A floating action button that fans its actions out above it. The + spins into a ×, and each action springs out of the button one after another, coming out of a soft blur with its label sliding in beside it. Closing tucks them back in, nearest last. Escape, a click outside, or tabbing away closes it; the arrow keys move through the actions, and focus returns to the button after a choice.
Installation
Use the CLI to install the component automatically:
$ pnpm dlx shadcn@latest add https://ui.sanjid.in/r/speed-dial.jsonInstall dependencies:
$ pnpm add motion lucide-reactAdd the utility function for class merging:
lib/utils.ts
tsx
1import { ClassValue, clsx } from "clsx";2import { twMerge } from "tailwind-merge";3 4export function cn(...inputs: ClassValue[]) {5 return twMerge(clsx(inputs));6}
Copy the component code into your project:
tsx
1"use client";2 3import React, { useEffect, useId, useRef, useState } from "react";4import { AnimatePresence, motion, useReducedMotion } from "motion/react";5import { Plus } from "lucide-react";6import { cn } from "@/lib/utils";7 8export type SpeedDialAction = {9 id: string;10 label: string;11 icon: React.ReactNode;12 onSelect?: () => void;13 href?: string;14};15 16export type SpeedDialProps = {17 actions: SpeedDialAction[];18 className?: string;19 /** Accessible name of the main button. */20 label?: string;21 /** Which way the actions fan out from the button. */22 direction?: "up" | "down";23 /** Side the action labels sit on. */24 labelSide?: "left" | "right";25 onOpenChange?: (open: boolean) => void;26};27 28const FAB = 56;29const ITEM = 44;30const GAP = 12;31const SPRING = { type: "spring" as const, bounce: 0.28, duration: 0.45 };32 33export function SpeedDial({34 actions,35 className,36 label = "Actions",37 direction = "up",38 labelSide = "left",39 onOpenChange,40}: SpeedDialProps) {41 const reduce = !!useReducedMotion();42 const [open, setOpenState] = useState(false);43 const [focused, setFocused] = useState(0);44 const rootRef = useRef<HTMLDivElement>(null);45 const triggerRef = useRef<HTMLButtonElement>(null);46 const itemRefs = useRef<Array<HTMLButtonElement | null>>([]);47 const menuId = useId();48 const sign = direction === "up" ? -1 : 1;49 50 const setOpen = (next: boolean, restoreFocus = false) => {51 setOpenState(next);52 onOpenChange?.(next);53 if (!next && restoreFocus) triggerRef.current?.focus();54 };55 56 // Escape or a click outside closes the dial.57 useEffect(() => {58 if (!open) return;59 const onKey = (e: KeyboardEvent) => {60 if (e.key === "Escape") {61 setOpenState(false);62 onOpenChange?.(false);63 triggerRef.current?.focus();64 }65 };66 const onPointer = (e: PointerEvent) => {67 if (!rootRef.current?.contains(e.target as Node)) {68 setOpenState(false);69 onOpenChange?.(false);70 }71 };72 window.addEventListener("keydown", onKey);73 window.addEventListener("pointerdown", onPointer);74 return () => {75 window.removeEventListener("keydown", onKey);76 window.removeEventListener("pointerdown", onPointer);77 };78 }, [open, onOpenChange]);79 80 const activate = (action: SpeedDialAction) => {81 action.onSelect?.();82 if (action.href) window.open(action.href, "_blank", "noopener,noreferrer");83 setOpen(false, true);84 };85 86 const focusItem = (i: number) => {87 const n = actions.length;88 const next = ((i % n) + n) % n;89 setFocused(next);90 itemRefs.current[next]?.focus();91 };92 93 const onMenuKeyDown = (e: React.KeyboardEvent) => {94 // Visual order runs away from the button, so "toward the button" is the95 // previous item when fanning up.96 const away = direction === "up" ? "ArrowUp" : "ArrowDown";97 const toward = direction === "up" ? "ArrowDown" : "ArrowUp";98 if (e.key === away) focusItem(focused + 1);99 else if (e.key === toward) {100 if (focused === 0) {101 e.preventDefault();102 return triggerRef.current?.focus();103 }104 focusItem(focused - 1);105 } else if (e.key === "Home") focusItem(0);106 else if (e.key === "End") focusItem(actions.length - 1);107 else return;108 e.preventDefault();109 };110 111 const stackH = actions.length * (ITEM + GAP);112 113 return (114 <div115 ref={rootRef}116 className={cn("relative inline-flex", className)}117 style={{ width: FAB, height: FAB }}118 onBlur={(e) => {119 // Tabbing out of the dial closes it, same as Escape or an outside click.120 if (open && !rootRef.current?.contains(e.relatedTarget as Node | null)) setOpen(false);121 }}122 >123 {/* Soft halo behind the open dial */}124 <AnimatePresence>125 {open ? (126 <motion.span127 aria-hidden128 initial={{ opacity: 0, scale: 0.6 }}129 animate={{ opacity: 1, scale: 1 }}130 exit={{ opacity: 0, scale: 0.6 }}131 transition={{ duration: 0.35, ease: [0.23, 1, 0.32, 1] }}132 className="pointer-events-none absolute left-1/2 -z-10 -translate-x-1/2 rounded-full bg-(--color-paper)/60 blur-2xl"133 style={{134 width: FAB * 3.2,135 height: stackH + FAB * 1.6,136 [direction === "up" ? "bottom" : "top"]: -FAB * 0.3,137 }}138 />139 ) : null}140 </AnimatePresence>141 142 <div143 id={menuId}144 role="menu"145 aria-label={label}146 aria-orientation="vertical"147 onKeyDown={onMenuKeyDown}148 className={cn("absolute left-1/2 flex -translate-x-1/2 items-center", direction === "up" ? "bottom-full mb-3 flex-col-reverse" : "top-full mt-3 flex-col")}149 style={{ gap: GAP, width: ITEM }}150 >151 <AnimatePresence>152 {open153 ? actions.map((action, i) => {154 // Each item starts tucked inside the button and springs out to155 // its slot; nearer items leave first and return last.156 const from = sign * -(i + 1) * (ITEM + GAP) + sign * -(FAB - ITEM) / 2;157 return (158 <motion.div159 key={action.id}160 className="group relative"161 initial={reduce ? { opacity: 0 } : { opacity: 0, y: from, scale: 0.4, filter: "blur(6px)" }}162 animate={{163 opacity: 1,164 y: 0,165 scale: 1,166 filter: "blur(0px)",167 transition: { ...SPRING, delay: reduce ? 0 : i * 0.035 },168 }}169 exit={170 reduce171 ? { opacity: 0, transition: { duration: 0.1 } }172 : {173 opacity: 0,174 y: from,175 scale: 0.4,176 filter: "blur(6px)",177 transition: { type: "spring", bounce: 0, duration: 0.3, delay: (actions.length - 1 - i) * 0.025 },178 }179 }180 >181 <button182 ref={(el) => {183 itemRefs.current[i] = el;184 }}185 type="button"186 role="menuitem"187 tabIndex={focused === i ? 0 : -1}188 aria-label={action.label}189 onFocus={() => setFocused(i)}190 onClick={() => activate(action)}191 className={cn(192 "peer flex items-center justify-center rounded-full outline-none",193 "bg-(--color-paper) text-(--color-ink) shadow-[inset_0_0_0_1px_var(--color-rule),0_1px_2px_rgba(0,0,0,0.06),0_8px_20px_-8px_rgba(0,0,0,0.3)]",194 "transition-[transform,background-color] duration-200 ease-out hover:scale-[1.08] hover:bg-(--color-paper-2) active:scale-95",195 "focus-visible:ring-2 focus-visible:ring-(--color-focus) focus-visible:ring-offset-2 focus-visible:ring-offset-(--color-paper)",196 "[&_svg]:size-[18px] [&_svg]:stroke-[1.75]",197 )}198 style={{ width: ITEM, height: ITEM }}199 >200 {action.icon}201 </button>202 <motion.span203 aria-hidden204 initial={reduce ? { opacity: 0 } : { opacity: 0, x: labelSide === "left" ? 8 : -8, filter: "blur(4px)" }}205 animate={{ opacity: 1, x: 0, filter: "blur(0px)", transition: { duration: 0.3, delay: reduce ? 0 : 0.08 + i * 0.035 } }}206 exit={{ opacity: 0, transition: { duration: 0.1 } }}207 className={cn(208 "pointer-events-none absolute top-1/2 -translate-y-1/2 rounded-full px-2.5 py-1 text-[12.5px] font-medium whitespace-nowrap",209 "bg-(--color-ink) text-(--color-paper) shadow-[0_4px_12px_-4px_rgba(0,0,0,0.35)]",210 "transition-transform duration-200 peer-hover:scale-105",211 labelSide === "left" ? "right-full mr-3 origin-right" : "left-full ml-3 origin-left",212 )}213 >214 {action.label}215 </motion.span>216 </motion.div>217 );218 })219 : null}220 </AnimatePresence>221 </div>222 223 <motion.button224 ref={triggerRef}225 type="button"226 aria-label={label}227 aria-expanded={open}228 aria-controls={menuId}229 aria-haspopup="menu"230 onClick={() => setOpen(!open)}231 onKeyDown={(e) => {232 const into = direction === "up" ? "ArrowUp" : "ArrowDown";233 if (e.key === into || (e.key === "Enter" && !open) || (e.key === " " && !open)) {234 e.preventDefault();235 if (!open) setOpen(true);236 // Wait for the items to mount before moving focus into the menu.237 requestAnimationFrame(() => focusItem(0));238 }239 }}240 whileTap={reduce ? undefined : { scale: 0.92 }}241 animate={{ rotate: open && !reduce ? 135 : 0 }}242 transition={SPRING}243 className={cn(244 "relative flex items-center justify-center rounded-full outline-none",245 "shadow-[inset_0_1px_0_rgba(255,255,255,0.16),0_2px_4px_rgba(0,0,0,0.12),0_12px_28px_-10px_rgba(0,0,0,0.45)]",246 "transition-[background-color,color] duration-300",247 "focus-visible:ring-2 focus-visible:ring-(--color-focus) focus-visible:ring-offset-2 focus-visible:ring-offset-(--color-paper)",248 open249 ? "bg-(--color-paper) text-(--color-ink) shadow-[inset_0_0_0_1px_var(--color-rule),0_12px_28px_-10px_rgba(0,0,0,0.35)]"250 : "bg-(--color-ink) text-(--color-paper)",251 )}252 style={{ width: FAB, height: FAB }}253 >254 <Plus className="size-6" strokeWidth={1.75} aria-hidden />255 </motion.button>256 </div>257 );258}
Adjust import paths according to your folder structure.
Basic Usage
tsx
1import { SpeedDial } from "@/components/ui/speed-dial";2import { FilePlus, Image, Link2 } from "lucide-react";3 4<SpeedDial5 className="fixed right-6 bottom-6"6 label="Create"7 actions={[8 { id: "note", label: "New note", icon: <FilePlus />, onSelect: createNote },9 { id: "image", label: "Upload image", icon: <Image />, onSelect: openUploader },10 { id: "docs", label: "Docs", icon: <Link2 />, href: "https://example.com" },11 ]}12/>
Props
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
| actions | SpeedDialAction[] | Yes | — | Actions with id, label, icon, and onSelect or href (opened in a new tab). The first action sits closest to the button. |
| label | string | No | "Actions" | Accessible name of the main button and its menu. |
| direction | "up" | "down" | No | "up" | Which way the actions fan out. |
| labelSide | "left" | "right" | No | "left" | Side the action labels sit on. Use right when the dial is on the left edge. |
| onOpenChange | (open: boolean) => void | No | — | Fires when the dial opens or closes. |
| className | string | No | — | Position the dial here, e.g. fixed bottom-right. |
Best Practices
- Keep it to three to five actions; past that, use a menu or a sheet.
- Put the most common action first, closest to the button.
- Keyboard: Enter, Space or the arrow toward the actions opens the dial and focuses the first one; arrows move, Home and End jump, Escape closes.
- Reduced motion swaps the spring-out for a fade and keeps the
+still.