Overview

A floating action button that morphs into its menu using transitions.dev plus → menu morph (t-morph). The circular trigger grows into a rounded panel; the plus fades, slides, and rotates 45° while the action list cross-fades in with blur. Open uses a bouncier ease than close. Escape and outside click dismiss.


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, useMemo, useRef, useState } from "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 FAB = 48;const ROW = 40;const PAD_Y = 12;const PAD_X = 12;const PLUS_RESERVE = 52;
export function SpeedDial({  actions,  className,  label = "Actions",}: SpeedDialProps) {  const [open, setOpen] = useState(false);  const rootRef = useRef<HTMLDivElement>(null);  const listId = useId();
  const openSize = useMemo(() => {    const h = Math.max(      FAB,      actions.length * ROW + PAD_Y * 2 + PLUS_RESERVE,    );    const w = 196;    return { w, h };  }, [actions.length]);
  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 justify-end", className)}      style={{        // Anchor sized to the OPEN footprint so the morph grows up-and-left        width: openSize.w,        height: openSize.h,      }}    >      <div        className={cn(          "t-morph absolute right-0 bottom-0",          open            ? "border border-(--color-rule) shadow-[0_10px_32px_rgba(0,0,0,0.16)]"            : "border border-transparent shadow-[0_6px_20px_rgba(0,0,0,0.18)]",        )}        data-open={open ? "true" : "false"}        style={          {            // CSS owns the morph — set sizes via tokens, not fighting inline w/h            "--morph-w-closed": `${FAB}px`,            "--morph-h-closed": `${FAB}px`,            "--morph-w-open": `${openSize.w}px`,            "--morph-h-open": `${openSize.h}px`,            "--morph-r-closed": "999px",            "--morph-r-open": "16px",            "--morph-slide": "18px",            "--morph-blur": "2px",            "--morph-scale": "0.97",            "--morph-rotate": "45deg",            background: open              ? "var(--color-paper)"              : "var(--color-ink)",          } as React.CSSProperties        }      >        <div          id={listId}          role="menu"          aria-hidden={!open}          className="t-morph-menu flex flex-col justify-start"          style={{            padding: `${PAD_Y}px ${PAD_X}px ${PLUS_RESERVE}px`,            gap: 2,          }}        >          {actions.map((action, i) => (            <button              key={action.id}              type="button"              role="menuitem"              id={`${listId}-${action.id}`}              aria-label={action.label}              tabIndex={open ? 0 : -1}              onClick={() => activate(action)}              className={cn(                "inline-flex h-10 w-full items-center gap-2.5 rounded-md px-2 text-left",                "outline-none select-none",                "text-(--color-ink)",                "transition-[background-color,transform] duration-100 ease-[cubic-bezier(0.23,1,0.32,1)]",                "hover:bg-(--color-paper-2) active:scale-[0.97]",                "focus-visible:ring-2 focus-visible:ring-(--color-focus)",              )}              style={                open                  ? {                      transitionDelay: `${Math.min(i * 30, 120)}ms`,                    }                  : undefined              }            >              <span                className={cn(                  "flex size-8 shrink-0 items-center justify-center rounded-full",                  "border border-(--color-rule) bg-(--color-paper-2)",                  "text-(--color-ink)",                  "[&_svg]:size-[44%] [&_svg]:stroke-[1.5]",                )}              >                {action.icon}              </span>              <span className="truncate text-[13px] font-medium leading-none">                {action.label}              </span>            </button>          ))}        </div>
        <button          type="button"          className={cn(            "t-morph-plus z-2",            "text-(--color-paper)",            "outline-none",            "focus-visible:ring-2 focus-visible:ring-(--color-focus) focus-visible:ring-offset-2 focus-visible:ring-offset-(--color-paper)",            "active:scale-[0.97]",          )}          aria-label={label}          aria-expanded={open}          aria-controls={listId}          aria-haspopup="menu"          onClick={(e) => {            e.stopPropagation();            setOpen((o) => !o);          }}        >          <Plus className="size-5 stroke-[1.5]" aria-hidden />        </button>      </div>    </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. Let CSS own the morph: set --morph-w/h-open from content size; open uses --morph-ease, close uses --morph-close-ease.
  4. Keep the plus pinned with inset: auto 0 0 auto and overflow: hidden on .t-morph so the panel grows up-and-left without spilling.
  5. Under prefers-reduced-motion, the snippet zeroes transitions — keep that guard.