Overview

A floating action button that opens a clean vertical stack of labeled actions. Each row is one cohesive chip (label + icon) so nothing overlaps. Items stagger up from the FAB with scale(0.95) — never from zero. The Plus rotates 45° when open.


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;  label?: string;};
const ENTER_SPRING = { type: "spring" as const, bounce: 0, duration: 0.28 };
export function SpeedDial({  actions,  className,  label = "Actions",}: SpeedDialProps) {  const reduce = useReducedMotion();  const [open, setOpen] = useState(false);  const rootRef = useRef<HTMLDivElement>(null);  const listId = useId();
  useEffect(() => {    if (!open) return;    const onKey = (e: KeyboardEvent) => {      if (e.key === "Escape") setOpen(false);    };    const onPointer = (e: PointerEvent) => {      if (!rootRef.current?.contains(e.target as Node)) setOpen(false);    };    window.addEventListener("keydown", onKey);    window.addEventListener("pointerdown", onPointer);    return () => {      window.removeEventListener("keydown", onKey);      window.removeEventListener("pointerdown", onPointer);    };  }, [open]);
  const activate = (action: SpeedDialAction) => {    action.onSelect?.();    if (action.href) window.open(action.href, "_blank", "noopener,noreferrer");    setOpen(false);  };
  return (    <div      ref={rootRef}      className={cn(        "relative flex flex-col items-end gap-2.5",        className,      )}    >      <div        id={listId}        role="menu"        aria-hidden={!open}        className="flex flex-col items-end gap-2.5"      >        <AnimatePresence initial={false}>          {open &&            actions.map((action, i) => {              // Nearest to FAB (last item) animates first              const stagger = (actions.length - 1 - i) * 0.04;              return (              <motion.button                key={action.id}                type="button"                role="menuitem"                id={`${listId}-${action.id}`}                aria-label={action.label}                onClick={() => activate(action)}                initial={                  reduce                    ? { opacity: 0 }                    : { opacity: 0, y: 12, scale: 0.95 }                }                animate={{ opacity: 1, y: 0, scale: 1 }}                exit={                  reduce                    ? { opacity: 0, transition: { duration: 0.1 } }                    : {                        opacity: 0,                        y: 8,                        scale: 0.95,                        transition: { duration: 0.16 },                      }                }                transition={                  reduce                    ? { duration: 0.12 }                    : { ...ENTER_SPRING, delay: stagger }                }                whileTap={reduce ? undefined : { scale: 0.96 }}                className="inline-flex items-center gap-2 outline-none select-none focus-visible:ring-2 focus-visible:ring-(--color-focus) focus-visible:ring-offset-2"              >                <span                  className={cn(                    "rounded-full border border-(--color-rule) bg-(--color-paper)",                    "px-3 py-1.5 text-xs font-medium whitespace-nowrap text-(--color-ink)",                    "shadow-[0_4px_14px_rgba(0,0,0,0.08)]",                  )}                >                  {action.label}                </span>                <span                  className={cn(                    "flex size-10 shrink-0 items-center justify-center rounded-full",                    "border border-(--color-rule) bg-(--color-paper)",                    "text-(--color-ink)",                    "shadow-[0_6px_20px_rgba(0,0,0,0.12)]",                    "[&_svg]:size-[45%] [&_svg]:stroke-[1.5]",                  )}                >                  {action.icon}                </span>              </motion.button>              );            })}        </AnimatePresence>      </div>
      <motion.button        type="button"        aria-label={label}        aria-expanded={open}        aria-controls={listId}        aria-haspopup="menu"        onClick={() => setOpen((o) => !o)}        whileTap={reduce ? undefined : { scale: 0.96 }}        animate={{ rotate: open ? 45 : 0 }}        transition={          reduce            ? { duration: 0.12 }            : { type: "spring", bounce: 0, duration: 0.28 }        }        className={cn(          "relative z-2 flex size-14 items-center justify-center rounded-full",          "bg-(--color-ink) text-(--color-paper) outline-none",          "shadow-[0_8px_28px_rgba(0,0,0,0.18)]",          "focus-visible:ring-2 focus-visible:ring-(--color-focus) focus-visible:ring-offset-2",        )}      >        <Plus className="size-5 stroke-[1.5]" />      </motion.button>    </div>  );}

Adjust import paths according to your folder structure.


Basic Usage

tsx
import { SpeedDial } from "@/components/ui/speed-dial";import { FilePlus, Image } from "lucide-react";
<SpeedDial  actions={[    { id: "note", label: "New note", icon: <FilePlus />, onSelect: () => {} },    { id: "image", label: "Upload", icon: <Image />, onSelect: () => {} },  ]}/>

Props

PropTypeRequiredDefaultDescription
actionsSpeedDialAction[]YesActions with id, label, icon, onSelect or href.
labelstringNo"Actions"Accessible name for the main button.
classNamestringNoOptional class names on the root.

Best Practices

  1. Limit to 3–5 actions — denser stacks become noisy.
  2. Labels stay visible whenever open (no hover-only orphans).
  3. Enter uses bounce: 0, duration: 0.28, stagger 40ms; exit snaps at 160ms.
  4. Under reduced motion, actions fade without travel.