Web InnoventixFreeCode

FAB Button

Original · free

A floating action button that expands into a speed-dial.

byWeb InnoventixReact + Tailwind
btnfabbuttons
Open

Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.

npx shadcn@latest add https://webinnoventix.com/r/btn-fab.json
btn-fab.tsx
"use client";

import { type ReactNode, useEffect, useId, useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

type Action = {
  id: string;
  label: string;
  hint?: string;
  icon: ReactNode;
  tone: string;
};

const PlusIcon = () => (
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.2} strokeLinecap="round" aria-hidden="true" className="h-6 w-6">
    <path d="M12 5v14M5 12h14" />
  </svg>
);

const EditIcon = () => (
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.9} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className="h-5 w-5">
    <path d="M12 20h9" />
    <path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z" />
  </svg>
);

const PhotoIcon = () => (
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.9} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className="h-5 w-5">
    <rect x="3" y="3" width="18" height="18" rx="3" />
    <circle cx="8.5" cy="8.5" r="1.6" />
    <path d="m21 15-5-5L5 21" />
  </svg>
);

const NoteIcon = () => (
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.9} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className="h-5 w-5">
    <path d="M4 4h16v12l-4 4H4Z" />
    <path d="M16 20v-4h4" />
    <path d="M8 9h8M8 13h5" />
  </svg>
);

const MicIcon = () => (
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.9} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className="h-5 w-5">
    <rect x="9" y="2" width="6" height="12" rx="3" />
    <path d="M5 11a7 7 0 0 0 14 0M12 18v4" />
  </svg>
);

const ShareIcon = () => (
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.9} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className="h-5 w-5">
    <circle cx="18" cy="5" r="3" />
    <circle cx="6" cy="12" r="3" />
    <circle cx="18" cy="19" r="3" />
    <path d="m8.6 13.5 6.8 4M15.4 6.5l-6.8 4" />
  </svg>
);

const ACTIONS: Action[] = [
  { id: "note", label: "New note", hint: "N", icon: <NoteIcon />, tone: "text-sky-600 dark:text-sky-400" },
  { id: "photo", label: "Upload photo", hint: "U", icon: <PhotoIcon />, tone: "text-emerald-600 dark:text-emerald-400" },
  { id: "voice", label: "Record voice", hint: "R", icon: <MicIcon />, tone: "text-rose-600 dark:text-rose-400" },
  { id: "post", label: "Write post", hint: "W", icon: <EditIcon />, tone: "text-amber-600 dark:text-amber-500" },
];

function SpeedDial({
  actions,
  accent,
  align,
  label,
}: {
  actions: Action[];
  accent: string;
  align: "start" | "end";
  label: string;
}) {
  const [open, setOpen] = useState(false);
  const [picked, setPicked] = useState<string | null>(null);
  const reduce = useReducedMotion();
  const rootRef = useRef<HTMLDivElement>(null);
  const fabRef = useRef<HTMLButtonElement>(null);
  const menuId = useId();

  useEffect(() => {
    if (!open) return;
    const onKey = (e: KeyboardEvent) => {
      if (e.key === "Escape") {
        setOpen(false);
        fabRef.current?.focus();
      }
    };
    const onClick = (e: MouseEvent) => {
      if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false);
    };
    document.addEventListener("keydown", onKey);
    document.addEventListener("mousedown", onClick);
    return () => {
      document.removeEventListener("keydown", onKey);
      document.removeEventListener("mousedown", onClick);
    };
  }, [open]);

  const dur = reduce ? 0 : 0.22;

  return (
    <div
      ref={rootRef}
      className={`relative flex flex-col-reverse ${align === "end" ? "items-end" : "items-start"}`}
    >
      <AnimatePresence>
        {open && (
          <motion.ul
            id={menuId}
            role="menu"
            aria-label={label}
            initial="hide"
            animate="show"
            exit="hide"
            variants={{
              show: { transition: { staggerChildren: reduce ? 0 : 0.045, delayChildren: 0.02 } },
              hide: { transition: { staggerChildren: reduce ? 0 : 0.03, staggerDirection: -1 } },
            }}
            className={`mb-3 flex flex-col gap-2.5 ${align === "end" ? "items-end" : "items-start"}`}
          >
            {actions.map((a) => (
              <motion.li
                key={a.id}
                role="none"
                variants={{
                  hide: { opacity: 0, y: reduce ? 0 : 12, scale: reduce ? 1 : 0.9 },
                  show: { opacity: 1, y: 0, scale: 1 },
                }}
                transition={{ duration: dur, ease: [0.16, 1, 0.3, 1] }}
                className={`flex items-center gap-3 ${align === "end" ? "flex-row" : "flex-row-reverse"}`}
              >
                <span
                  className={`select-none whitespace-nowrap rounded-lg bg-slate-900/90 px-2.5 py-1 text-xs font-medium text-white shadow-sm ring-1 ring-black/5 dark:bg-white/90 dark:text-slate-900 ${
                    align === "end" ? "order-first" : ""
                  }`}
                >
                  {a.label}
                  {a.hint && (
                    <kbd className="ml-1.5 rounded bg-white/20 px-1 font-mono text-[10px] dark:bg-slate-900/15">
                      {a.hint}
                    </kbd>
                  )}
                </span>
                <button
                  type="button"
                  role="menuitem"
                  onClick={() => {
                    setPicked(a.id);
                    setOpen(false);
                    fabRef.current?.focus();
                  }}
                  className={`grid h-11 w-11 place-items-center rounded-full bg-white ${a.tone} shadow-md ring-1 ring-slate-200 transition-transform duration-150 hover:-translate-y-0.5 hover:shadow-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-900 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 active:scale-95 dark:bg-slate-800 dark:ring-slate-700 dark:focus-visible:ring-white dark:focus-visible:ring-offset-slate-900`}
                  aria-label={a.label}
                >
                  {a.icon}
                </button>
              </motion.li>
            ))}
          </motion.ul>
        )}
      </AnimatePresence>

      <button
        ref={fabRef}
        type="button"
        onClick={() => setOpen((v) => !v)}
        aria-expanded={open}
        aria-controls={menuId}
        aria-label={open ? "Close quick actions" : "Open quick actions"}
        className={`group relative grid h-14 w-14 place-items-center rounded-full text-white shadow-lg shadow-black/20 ring-1 ring-white/20 transition-[transform,box-shadow] duration-200 hover:shadow-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 active:scale-95 dark:focus-visible:ring-offset-slate-900 ${accent}`}
      >
        {!reduce && open && (
          <span className="bfab-ripple pointer-events-none absolute inset-0 rounded-full ring-2 ring-current opacity-40" />
        )}
        <motion.span
          animate={{ rotate: open ? 135 : 0 }}
          transition={{ duration: dur, ease: [0.16, 1, 0.3, 1] }}
          className="grid place-items-center"
        >
          <PlusIcon />
        </motion.span>
      </button>

      <div aria-live="polite" className="sr-only">
        {picked ? `${actions.find((a) => a.id === picked)?.label} selected` : ""}
      </div>
    </div>
  );
}

export default function BtnFab() {
  const [labelled, setLabelled] = useState(false);

  return (
    <section className="relative w-full bg-slate-50 px-6 py-20 dark:bg-slate-950">
      <style>{`
        @keyframes bfab-ripple-kf {
          0% { transform: scale(1); opacity: 0.45; }
          100% { transform: scale(1.9); opacity: 0; }
        }
        .bfab-ripple { animation: bfab-ripple-kf 1.6s ease-out infinite; }
        @keyframes bfab-float-kf {
          0%, 100% { transform: translateY(0); }
          50% { transform: translateY(-4px); }
        }
        .bfab-float { animation: bfab-float-kf 4s ease-in-out infinite; }
        @media (prefers-reduced-motion: reduce) {
          .bfab-ripple, .bfab-float { animation: none !important; }
        }
      `}</style>

      <div className="mx-auto max-w-5xl">
        <header className="mb-14 text-center">
          <span className="inline-flex items-center gap-1.5 rounded-full bg-indigo-100 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-indigo-700 ring-1 ring-indigo-200 dark:bg-indigo-500/10 dark:text-indigo-300 dark:ring-indigo-500/20">
            <ShareIcon />
            Floating action button
          </span>
          <h2 className="mt-4 text-3xl font-bold tracking-tight text-slate-900 dark:text-white">
            Speed-dial FAB
          </h2>
          <p className="mx-auto mt-3 max-w-xl text-sm leading-relaxed text-slate-600 dark:text-slate-400">
            One tap fans out a stack of quick actions. Fully keyboard driven, closes on Escape or an
            outside click, and respects reduced motion.
          </p>
        </header>

        <div className="grid gap-5 sm:grid-cols-3">
          <div className="relative flex min-h-[22rem] flex-col justify-between overflow-hidden rounded-2xl border border-slate-200 bg-white p-6 dark:border-slate-800 dark:bg-slate-900">
            <div>
              <p className="text-sm font-semibold text-slate-900 dark:text-white">Indigo, upward</p>
              <p className="mt-1 text-xs text-slate-500 dark:text-slate-400">
                Default anchored bottom-right.
              </p>
            </div>
            <div className="flex justify-end">
              <SpeedDial actions={ACTIONS} accent="bg-indigo-600 hover:bg-indigo-500 focus-visible:ring-indigo-600" align="end" label="Quick actions" />
            </div>
          </div>

          <div className="relative flex min-h-[22rem] flex-col justify-between overflow-hidden rounded-2xl border border-slate-200 bg-white p-6 dark:border-slate-800 dark:bg-slate-900">
            <div>
              <p className="text-sm font-semibold text-slate-900 dark:text-white">Emerald, left aligned</p>
              <p className="mt-1 text-xs text-slate-500 dark:text-slate-400">
                Labels flip to the right side.
              </p>
            </div>
            <div className="flex justify-start">
              <SpeedDial actions={ACTIONS.slice(0, 3)} accent="bg-emerald-600 hover:bg-emerald-500 focus-visible:ring-emerald-600" align="start" label="Compose actions" />
            </div>
          </div>

          <div className="relative flex min-h-[22rem] flex-col justify-between overflow-hidden rounded-2xl border border-slate-200 bg-white p-6 dark:border-slate-800 dark:bg-slate-900">
            <div>
              <p className="text-sm font-semibold text-slate-900 dark:text-white">Violet, idle pulse</p>
              <p className="mt-1 text-xs text-slate-500 dark:text-slate-400">
                Gentle float draws the eye.
              </p>
            </div>
            <div className="flex justify-end">
              <div className="bfab-float">
                <SpeedDial actions={ACTIONS} accent="bg-violet-600 hover:bg-violet-500 focus-visible:ring-violet-600" align="end" label="Create menu" />
              </div>
            </div>
          </div>
        </div>

        <div className="mt-6 flex flex-col items-center gap-3 rounded-2xl border border-slate-200 bg-white p-5 text-center dark:border-slate-800 dark:bg-slate-900">
          <button
            type="button"
            onClick={() => setLabelled((v) => !v)}
            aria-pressed={labelled}
            className={`inline-flex items-center gap-2 rounded-full px-4 py-2 text-sm font-medium ring-1 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900 ${
              labelled
                ? "bg-slate-900 text-white ring-slate-900 focus-visible:ring-slate-900 dark:bg-white dark:text-slate-900 dark:ring-white dark:focus-visible:ring-white"
                : "bg-slate-100 text-slate-700 ring-slate-200 hover:bg-slate-200 focus-visible:ring-slate-400 dark:bg-slate-800 dark:text-slate-300 dark:ring-slate-700 dark:hover:bg-slate-700"
            }`}
          >
            <span className={`h-2 w-2 rounded-full ${labelled ? "bg-emerald-400" : "bg-slate-400"}`} />
            Extended label {labelled ? "on" : "off"}
          </button>

          <ExtendedFab extended={labelled} />
        </div>
      </div>
    </section>
  );
}

function ExtendedFab({ extended }: { extended: boolean }) {
  const [saving, setSaving] = useState(false);
  const [done, setDone] = useState(false);

  const run = () => {
    if (saving) return;
    setDone(false);
    setSaving(true);
    window.setTimeout(() => {
      setSaving(false);
      setDone(true);
    }, 1200);
  };

  return (
    <button
      type="button"
      onClick={run}
      disabled={saving}
      aria-label="Save draft"
      className="inline-flex items-center gap-2.5 rounded-full bg-rose-600 px-5 py-3 text-sm font-semibold text-white shadow-lg shadow-rose-600/25 transition-[transform,background-color] duration-200 hover:bg-rose-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-600 focus-visible:ring-offset-2 focus-visible:ring-offset-white active:scale-95 disabled:cursor-not-allowed disabled:opacity-70 dark:focus-visible:ring-offset-slate-900"
    >
      {saving ? (
        <svg viewBox="0 0 24 24" className="h-5 w-5 animate-spin" fill="none" aria-hidden="true">
          <circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="3" className="opacity-25" />
          <path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" strokeWidth="3" strokeLinecap="round" />
        </svg>
      ) : done ? (
        <svg viewBox="0 0 24 24" className="h-5 w-5" fill="none" stroke="currentColor" strokeWidth={2.2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
          <path d="m20 6-11 11-5-5" />
        </svg>
      ) : (
        <NoteIcon />
      )}
      {extended && <span>{saving ? "Saving…" : done ? "Saved" : "Save draft"}</span>}
    </button>
  );
}

Dependencies

motion

Licence

Built by Web Innoventix. Free for personal and commercial use, no attribution required.

Built by Web Innoventix

Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.

Get a free quote

Similar components

Browse all →