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.json

Install dependencies:

$ pnpm add motion lucide-react

Add the utility function for class merging:

lib/utils.ts
tsx
import { ClassValue, clsx } from "clsx";import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {  return twMerge(clsx(inputs));}

Copy the component code into your project:

components/ui/speed-dial.tsx
tsx
"use client";
import React, { useEffect, useId, useRef, useState } from "react";import { AnimatePresence, motion, useReducedMotion } from "motion/react";import { Plus } from "lucide-react";import { cn } from "@/lib/utils";
export type SpeedDialAction = {  id: string;  label: string;  icon: React.ReactNode;  onSelect?: () => void;  href?: string;};
export type SpeedDialProps = {  actions: SpeedDialAction[];  className?: string;  /** Accessible name of the main button. */  label?: string;  /** Which way the actions fan out from the button. */  direction?: "up" | "down";  /** Side the action labels sit on. */  labelSide?: "left" | "right";  onOpenChange?: (open: boolean) => void;};
const FAB = 56;const ITEM = 44;const GAP = 12;const SPRING = { type: "spring" as const, bounce: 0.28, duration: 0.45 };
export function SpeedDial({  actions,  className,  label = "Actions",  direction = "up",  labelSide = "left",  onOpenChange,}: SpeedDialProps) {  const reduce = !!useReducedMotion();  const [open, setOpenState] = useState(false);  const [focused, setFocused] = useState(0);  const rootRef = useRef<HTMLDivElement>(null);  const triggerRef = useRef<HTMLButtonElement>(null);  const itemRefs = useRef<Array<HTMLButtonElement | null>>([]);  const menuId = useId();  const sign = direction === "up" ? -1 : 1;
  const setOpen = (next: boolean, restoreFocus = false) => {    setOpenState(next);    onOpenChange?.(next);    if (!next && restoreFocus) triggerRef.current?.focus();  };
  // Escape or a click outside closes the dial.  useEffect(() => {    if (!open) return;    const onKey = (e: KeyboardEvent) => {      if (e.key === "Escape") {        setOpenState(false);        onOpenChange?.(false);        triggerRef.current?.focus();      }    };    const onPointer = (e: PointerEvent) => {      if (!rootRef.current?.contains(e.target as Node)) {        setOpenState(false);        onOpenChange?.(false);      }    };    window.addEventListener("keydown", onKey);    window.addEventListener("pointerdown", onPointer);    return () => {      window.removeEventListener("keydown", onKey);      window.removeEventListener("pointerdown", onPointer);    };  }, [open, onOpenChange]);
  const activate = (action: SpeedDialAction) => {    action.onSelect?.();    if (action.href) window.open(action.href, "_blank", "noopener,noreferrer");    setOpen(false, true);  };
  const focusItem = (i: number) => {    const n = actions.length;    const next = ((i % n) + n) % n;    setFocused(next);    itemRefs.current[next]?.focus();  };
  const onMenuKeyDown = (e: React.KeyboardEvent) => {    // Visual order runs away from the button, so "toward the button" is the    // previous item when fanning up.    const away = direction === "up" ? "ArrowUp" : "ArrowDown";    const toward = direction === "up" ? "ArrowDown" : "ArrowUp";    if (e.key === away) focusItem(focused + 1);    else if (e.key === toward) {      if (focused === 0) {        e.preventDefault();        return triggerRef.current?.focus();      }      focusItem(focused - 1);    } else if (e.key === "Home") focusItem(0);    else if (e.key === "End") focusItem(actions.length - 1);    else return;    e.preventDefault();  };
  const stackH = actions.length * (ITEM + GAP);
  return (    <div      ref={rootRef}      className={cn("relative inline-flex", className)}      style={{ width: FAB, height: FAB }}      onBlur={(e) => {        // Tabbing out of the dial closes it, same as Escape or an outside click.        if (open && !rootRef.current?.contains(e.relatedTarget as Node | null)) setOpen(false);      }}    >      {/* Soft halo behind the open dial */}      <AnimatePresence>        {open ? (          <motion.span            aria-hidden            initial={{ opacity: 0, scale: 0.6 }}            animate={{ opacity: 1, scale: 1 }}            exit={{ opacity: 0, scale: 0.6 }}            transition={{ duration: 0.35, ease: [0.23, 1, 0.32, 1] }}            className="pointer-events-none absolute left-1/2 -z-10 -translate-x-1/2 rounded-full bg-(--color-paper)/60 blur-2xl"            style={{              width: FAB * 3.2,              height: stackH + FAB * 1.6,              [direction === "up" ? "bottom" : "top"]: -FAB * 0.3,            }}          />        ) : null}      </AnimatePresence>
      <div        id={menuId}        role="menu"        aria-label={label}        aria-orientation="vertical"        onKeyDown={onMenuKeyDown}        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")}        style={{ gap: GAP, width: ITEM }}      >        <AnimatePresence>          {open            ? actions.map((action, i) => {                // Each item starts tucked inside the button and springs out to                // its slot; nearer items leave first and return last.                const from = sign * -(i + 1) * (ITEM + GAP) + sign * -(FAB - ITEM) / 2;                return (                  <motion.div                    key={action.id}                    className="group relative"                    initial={reduce ? { opacity: 0 } : { opacity: 0, y: from, scale: 0.4, filter: "blur(6px)" }}                    animate={{                      opacity: 1,                      y: 0,                      scale: 1,                      filter: "blur(0px)",                      transition: { ...SPRING, delay: reduce ? 0 : i * 0.035 },                    }}                    exit={                      reduce                        ? { opacity: 0, transition: { duration: 0.1 } }                        : {                            opacity: 0,                            y: from,                            scale: 0.4,                            filter: "blur(6px)",                            transition: { type: "spring", bounce: 0, duration: 0.3, delay: (actions.length - 1 - i) * 0.025 },                          }                    }                  >                    <button                      ref={(el) => {                        itemRefs.current[i] = el;                      }}                      type="button"                      role="menuitem"                      tabIndex={focused === i ? 0 : -1}                      aria-label={action.label}                      onFocus={() => setFocused(i)}                      onClick={() => activate(action)}                      className={cn(                        "peer flex items-center justify-center rounded-full outline-none",                        "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)]",                        "transition-[transform,background-color] duration-200 ease-out hover:scale-[1.08] hover:bg-(--color-paper-2) active:scale-95",                        "focus-visible:ring-2 focus-visible:ring-(--color-focus) focus-visible:ring-offset-2 focus-visible:ring-offset-(--color-paper)",                        "[&_svg]:size-[18px] [&_svg]:stroke-[1.75]",                      )}                      style={{ width: ITEM, height: ITEM }}                    >                      {action.icon}                    </button>                    <motion.span                      aria-hidden                      initial={reduce ? { opacity: 0 } : { opacity: 0, x: labelSide === "left" ? 8 : -8, filter: "blur(4px)" }}                      animate={{ opacity: 1, x: 0, filter: "blur(0px)", transition: { duration: 0.3, delay: reduce ? 0 : 0.08 + i * 0.035 } }}                      exit={{ opacity: 0, transition: { duration: 0.1 } }}                      className={cn(                        "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",                        "bg-(--color-ink) text-(--color-paper) shadow-[0_4px_12px_-4px_rgba(0,0,0,0.35)]",                        "transition-transform duration-200 peer-hover:scale-105",                        labelSide === "left" ? "right-full mr-3 origin-right" : "left-full ml-3 origin-left",                      )}                    >                      {action.label}                    </motion.span>                  </motion.div>                );              })            : null}        </AnimatePresence>      </div>
      <motion.button        ref={triggerRef}        type="button"        aria-label={label}        aria-expanded={open}        aria-controls={menuId}        aria-haspopup="menu"        onClick={() => setOpen(!open)}        onKeyDown={(e) => {          const into = direction === "up" ? "ArrowUp" : "ArrowDown";          if (e.key === into || (e.key === "Enter" && !open) || (e.key === " " && !open)) {            e.preventDefault();            if (!open) setOpen(true);            // Wait for the items to mount before moving focus into the menu.            requestAnimationFrame(() => focusItem(0));          }        }}        whileTap={reduce ? undefined : { scale: 0.92 }}        animate={{ rotate: open && !reduce ? 135 : 0 }}        transition={SPRING}        className={cn(          "relative flex items-center justify-center rounded-full outline-none",          "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)]",          "transition-[background-color,color] duration-300",          "focus-visible:ring-2 focus-visible:ring-(--color-focus) focus-visible:ring-offset-2 focus-visible:ring-offset-(--color-paper)",          open            ? "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)]"            : "bg-(--color-ink) text-(--color-paper)",        )}        style={{ width: FAB, height: FAB }}      >        <Plus className="size-6" strokeWidth={1.75} aria-hidden />      </motion.button>    </div>  );}

Adjust import paths according to your folder structure.


Basic Usage

tsx
import { SpeedDial } from "@/components/ui/speed-dial";import { FilePlus, Image, Link2 } from "lucide-react";
<SpeedDial  className="fixed right-6 bottom-6"  label="Create"  actions={[    { id: "note", label: "New note", icon: <FilePlus />, onSelect: createNote },    { id: "image", label: "Upload image", icon: <Image />, onSelect: openUploader },    { id: "docs", label: "Docs", icon: <Link2 />, href: "https://example.com" },  ]}/>

Props

PropTypeRequiredDefaultDescription
actionsSpeedDialAction[]Yes—Actions with id, label, icon, and onSelect or href (opened in a new tab). The first action sits closest to the button.
labelstringNo"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) => voidNo—Fires when the dial opens or closes.
classNamestringNo—Position the dial here, e.g. fixed bottom-right.

Best Practices

  1. Keep it to three to five actions; past that, use a menu or a sheet.
  2. Put the most common action first, closest to the button.
  3. 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.
  4. Reduced motion swaps the spring-out for a fade and keeps the + still.