"use client"; import { type LucideIcon, PlusIcon } from "lucide-react"; import { type AnimationPlaybackControls, animate, type MotionValue, motion, motionValue, useReducedMotion, type ValueAnimationTransition, } from "motion/react"; import { type KeyboardEvent, type MouseEvent, useEffect, useId, useMemo, useRef, useState } from "react"; import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; export interface LiquidFabAction { id: string; label: string; icon: LucideIcon; onSelect?: () => void; } export type LiquidFabDirection = "up" | "up-left" | "up-right"; export interface LiquidFabProps { actions: LiquidFabAction[]; direction?: LiquidFabDirection; label?: string; size?: number; open?: boolean; defaultOpen?: boolean; onOpenChange?: (open: boolean) => void; className?: string; } const ARCS: Record = { up: [-150, -30], "up-left": [-90, -180], "up-right": [-90, 0], }; const settle = [0.23, 1, 0.32, 1] as const; interface Drop { x: MotionValue; y: MotionValue; neckX: MotionValue; neckY: MotionValue; scale: MotionValue; stretchX: MotionValue; stretchY: MotionValue; opacity: MotionValue; } function useSafeReducedMotion() { const prefers = useReducedMotion(); const [mounted, setMounted] = useState(false); useEffect(() => setMounted(true), []); return mounted && !!prefers; } function layout(count: number, direction: LiquidFabDirection, size: number, actionSize: number) { const [from, to] = ARCS[direction]; const step = count > 1 ? (to - from) / (count - 1) : 0; const spacing = Math.abs(step) * (Math.PI / 180); const reach = Math.max(size * 1.5, spacing ? (actionSize + 14) / (2 * Math.sin(spacing / 2)) : 0); return Array.from({ length: count }, (_, i) => { const angle = count > 1 ? from + step * i : (from + to) / 2; const rad = angle * (Math.PI / 180); const x = Math.cos(rad) * reach; const y = Math.sin(rad) * reach; const side: "left" | "right" | "top" = Math.cos(rad) < -0.4 ? "left" : Math.cos(rad) > 0.4 ? "right" : "top"; return { x, y, angle, side }; }); } export function LiquidFab({ actions, direction = "up", label = "Quick actions", size = 56, open: openProp, defaultOpen = false, onOpenChange, className, }: LiquidFabProps) { const uid = useId().replace(/:/g, ""); const filterId = `liquid-fab-${uid}`; const groupId = `liquid-fab-group-${uid}`; const reduce = useSafeReducedMotion(); const [openState, setOpenState] = useState(defaultOpen); const open = openProp ?? openState; const root = useRef(null); const trigger = useRef(null); const buttons = useRef<(HTMLButtonElement | null)[]>([]); const focusFirst = useRef(false); const mainScale = useMemo(() => motionValue(1), []); const actionSize = Math.round(size * 0.78); const blur = size * 0.125; const spots = useMemo(() => layout(actions.length, direction, size, actionSize), [actions.length, direction, size, actionSize]); const drops = useMemo( () => Array.from({ length: actions.length }, () => ({ x: motionValue(0), y: motionValue(0), neckX: motionValue(0), neckY: motionValue(0), scale: motionValue(0.5), stretchX: motionValue(1), stretchY: motionValue(1), opacity: motionValue(0), })), [actions.length], ); const margin = actionSize * 0.7 + 8; const minX = Math.min(-size / 2, ...spots.map((s) => s.x - margin)); const maxX = Math.max(size / 2, ...spots.map((s) => s.x + margin)); const minY = Math.min(-size / 2, ...spots.map((s) => s.y - margin)); const maxY = Math.max(size / 2, ...spots.map((s) => s.y + margin)); const boxW = maxX - minX; const boxH = maxY - minY; const pad = blur * 3; const setOpen = (next: boolean) => { if (openProp === undefined) setOpenState(next); onOpenChange?.(next); }; useEffect(() => { const count = drops.length; const runs: AnimationPlaybackControls[] = []; const run = (value: MotionValue, to: number | number[], options: ValueAnimationTransition) => { runs.push(animate(value, to, options)); }; drops.forEach((d, i) => { const spot = spots[i]; if (!spot) return; if (reduce) { if (open) { d.x.set(spot.x); d.y.set(spot.y); d.scale.set(1); } run(d.opacity, open ? 1 : 0, { duration: 0.16 }); return; } if (open) { const delay = i * 0.04; run(d.x, spot.x, { type: "spring", stiffness: 460, damping: 26, delay }); run(d.y, spot.y, { type: "spring", stiffness: 460, damping: 26, delay }); run(d.scale, 1, { duration: 0.3, ease: settle, delay }); run(d.stretchX, [d.stretchX.get(), 1.32, 0.94, 1], { duration: 0.55, times: [0, 0.3, 0.65, 1], delay }); run(d.stretchY, [d.stretchY.get(), 0.8, 1.05, 1], { duration: 0.55, times: [0, 0.3, 0.65, 1], delay }); run(d.neckX, [0, spot.x * 0.42, 0], { duration: 0.42, times: [0, 0.4, 1], ease: "easeOut", delay }); run(d.neckY, [0, spot.y * 0.42, 0], { duration: 0.42, times: [0, 0.4, 1], ease: "easeOut", delay }); run(d.opacity, 1, { duration: 0.16, delay: delay + 0.1 }); } else { const delay = (count - 1 - i) * 0.03; run(d.opacity, 0, { duration: 0.1 }); run(d.x, 0, { type: "spring", stiffness: 420, damping: 34, delay }); run(d.y, 0, { type: "spring", stiffness: 420, damping: 34, delay }); run(d.scale, 0.5, { duration: 0.34, ease: [0.4, 0, 0.2, 1], delay }); run(d.stretchX, [d.stretchX.get(), 1.22, 1], { duration: 0.36, delay }); run(d.stretchY, [d.stretchY.get(), 0.86, 1], { duration: 0.36, delay }); run(d.neckX, [0, d.x.get() * 0.5, 0], { duration: 0.34, times: [0, 0.35, 1], delay }); run(d.neckY, [0, d.y.get() * 0.5, 0], { duration: 0.34, times: [0, 0.35, 1], delay }); } }); if (!reduce) { run(mainScale, open ? [mainScale.get(), 0.9, 1.04, 1] : [mainScale.get(), 1, 1.05, 1], { duration: 0.42, times: [0, 0.3, 0.65, 1], delay: open ? 0 : 0.12, }); } return () => { for (const r of runs) r.stop(); }; }, [open, reduce, drops, spots, mainScale]); useEffect(() => { if (!open) return; if (focusFirst.current) { focusFirst.current = false; buttons.current[0]?.focus(); } const onDown = (e: PointerEvent) => { if (!root.current?.contains(e.target as Node)) setOpen(false); }; document.addEventListener("pointerdown", onDown); return () => document.removeEventListener("pointerdown", onDown); }); const toggle = (e: MouseEvent) => { if (!open && e.detail === 0) focusFirst.current = true; setOpen(!open); }; const choose = (action: LiquidFabAction) => { action.onSelect?.(); setOpen(false); trigger.current?.focus(); }; const onKeyDown = (e: KeyboardEvent) => { if (e.key === "Escape" && open) { e.stopPropagation(); setOpen(false); trigger.current?.focus(); return; } if (!open) return; const keys = ["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Home", "End"]; if (!keys.includes(e.key)) return; const list = buttons.current.filter(Boolean) as HTMLButtonElement[]; const at = list.indexOf(document.activeElement as HTMLButtonElement); e.preventDefault(); if (e.key === "Home") return list[0]?.focus(); if (e.key === "End") return list[list.length - 1]?.focus(); const forward = e.key === "ArrowUp" || (direction === "up-left" ? e.key === "ArrowLeft" : e.key === "ArrowRight"); const next = at === -1 ? 0 : (at + (forward ? 1 : -1) + list.length) % list.length; list[next]?.focus(); }; return (
{ if (open && !root.current?.contains(e.relatedTarget as Node)) setOpen(false); }} onKeyDown={onKeyDown} ref={root} style={{ width: size, height: size }} > {drops.map((d, i) => { const spot = spots[i]; if (!spot || reduce) return null; const neck = actionSize * 0.5; return ( ); })}
{actions.map((action, i) => { const d = drops[i]; const spot = spots[i]; if (!d || !spot) return null; const Icon = action.icon; return ( choose(action)} ref={(el: HTMLButtonElement | null) => { buttons.current[i] = el; }} style={{ left: size / 2 - actionSize / 2, top: size / 2 - actionSize / 2, width: actionSize, height: actionSize, x: d.x, y: d.y, scale: d.scale, opacity: d.opacity, }} type="button" whileTap={reduce ? undefined : { scale: 0.92 }} /> } > {action.label} ); })}
); }