Buoy
Floating action button that springs open an arc of glass bubble actions — buoyant overshoot with a bottom-up stagger, asynchronous idle bob, liquid squash on toggle, and a single coral accent on the active bubble. Full menu-button keyboard support with Escape to close.
motionfree
"use client";
import * as React from "react";
import { AnimatePresence, motion } from "motion/react";
import { cn } from "@/lib/utils";
import { useReducedMotion } from "@/registry/default/hooks/use-reduced-motion/use-reduced-motion";
export interface BuoyItem {
/** Icon node rendered inside the bubble (decorative — the label is the accessible name). */
icon: React.ReactNode;
/** Accessible name, also shown as a small tooltip on hover/focus. */
label: string;
/** Fires on activation. The menu closes afterwards. */
onClick?: () => void;
/** Renders the bubble as a link instead of a button. */
href?: string;
}
export interface BuoyProps
extends Omit<React.ComponentPropsWithoutRef<"div">, "onClick"> {
/** Action bubbles, in arc order. Defaults to a sample set of four. */
items?: BuoyItem[];
/** Controlled open state. */
open?: boolean;
/** Initial open state when uncontrolled. @default false */
defaultOpen?: boolean;
/** Fires whenever the menu opens or closes, controlled or not. */
onOpenChange?: (open: boolean) => void;
/** Distance from the button center to each bubble center, in px. @default 92 */
radius?: number;
/**
* Arc the bubbles fan across, as `[fromDeg, toDeg]` — 0° points right,
* 90° straight up. @default [150, 30] (fanned overhead)
*/
arc?: [number, number];
/** Accent fill for the hovered/focused bubble — the set's single warm note. @default "#ff8a73" (coral) */
accentColor?: string;
/** Accessible name for the toggle button and menu. @default "Quick actions" */
label?: string;
}
function DefaultIcon({ d }: { d: string }) {
return (
<svg
aria-hidden
viewBox="0 0 24 24"
className="size-[18px]"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d={d} />
</svg>
);
}
const DEFAULT_ITEMS: BuoyItem[] = [
{
label: "Search",
icon: <DefaultIcon d="M10.5 3.5a7 7 0 1 1 0 14 7 7 0 0 1 0-14Zm5 12 5 5" />,
},
{
label: "Alerts",
icon: (
<DefaultIcon d="M12 3a6 6 0 0 1 6 6v3l1.7 3.2a1 1 0 0 1-.9 1.5H5.2a1 1 0 0 1-.9-1.5L6 12V9a6 6 0 0 1 6-6Zm-2 15.5a2 2 0 0 0 4 0" />
),
},
{
label: "Favorites",
icon: (
<DefaultIcon d="M12 20.5 4.7 13a4.8 4.8 0 0 1 6.8-6.8l.5.5.5-.5a4.8 4.8 0 0 1 6.8 6.8L12 20.5Z" />
),
},
{
label: "Compose",
icon: <DefaultIcon d="m16.5 4.5 3 3L8 19H5v-3L16.5 4.5Zm-3 3 3 3" />,
},
];
/**
* Buoy — a floating action button that springs open an arc of glass action
* bubbles. Buoyancy is the motion signature: bubbles overshoot their seat and
* settle with a slow bob, opening bottom-up (a buoyant rise) and closing
* top-down; the button itself squashes liquidly on toggle and carries a gentle
* idle float. Neutral dark-glass bubbles, with a single soft coral fill on the
* hovered or focused item.
*
* Full menu-button keyboard support: the toggle exposes aria-expanded and
* aria-controls, opening focuses the first bubble, Arrow keys cycle, Escape and
* Tab close and return focus. Honors prefers-reduced-motion: bubbles fade in
* place with no springs, bob, or squash.
*/
export function Buoy({
items = DEFAULT_ITEMS,
open: openProp,
defaultOpen = false,
onOpenChange,
radius = 92,
arc = [150, 30],
accentColor = "#ff8a73",
label = "Quick actions",
className,
style,
...props
}: BuoyProps) {
const reducedMotion = useReducedMotion();
const menuId = React.useId();
const fabRef = React.useRef<HTMLButtonElement>(null);
const itemRefs = React.useRef<Array<HTMLButtonElement | HTMLAnchorElement | null>>(
[]
);
const isControlled = openProp !== undefined;
const [internalOpen, setInternalOpen] = React.useState(defaultOpen);
const [hasToggled, setHasToggled] = React.useState(false);
const open = isControlled ? openProp : internalOpen;
const setOpen = React.useCallback(
(next: boolean) => {
setHasToggled(true);
if (!isControlled) setInternalOpen(next);
onOpenChange?.(next);
},
[isControlled, onOpenChange]
);
// Bubble seats + bottom-up open order (lowest bubble rises first).
const positions = React.useMemo(() => {
const n = items.length;
const seats = items.map((_, i) => {
const t = n > 1 ? i / (n - 1) : 0.5;
const deg = arc[0] + (arc[1] - arc[0]) * t;
const rad = (deg * Math.PI) / 180;
return { x: Math.cos(rad) * radius, y: -Math.sin(rad) * radius };
});
const byDepth = seats
.map((seat, i) => ({ i, y: seat.y }))
.sort((a, b) => b.y - a.y); // largest y = lowest on screen = first out
const rank = new Array<number>(n);
byDepth.forEach((entry, order) => {
rank[entry.i] = order;
});
return seats.map((seat, i) => ({ ...seat, rank: rank[i] }));
}, [items, arc, radius]);
// Focus management: opening focuses the first bubble.
const prevOpenRef = React.useRef(open);
React.useEffect(() => {
if (open && !prevOpenRef.current) itemRefs.current[0]?.focus();
prevOpenRef.current = open;
}, [open]);
const close = React.useCallback(
(refocus: boolean) => {
setOpen(false);
if (refocus) fabRef.current?.focus();
},
[setOpen]
);
const focusItem = React.useCallback(
(index: number) => {
const n = items.length;
itemRefs.current[((index % n) + n) % n]?.focus();
},
[items.length]
);
const handleMenuKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
const current = itemRefs.current.findIndex(
(el) => el === document.activeElement
);
if (event.key === "Escape") {
event.preventDefault();
close(true);
} else if (event.key === "ArrowDown" || event.key === "ArrowRight") {
event.preventDefault();
focusItem(current + 1);
} else if (event.key === "ArrowUp" || event.key === "ArrowLeft") {
event.preventDefault();
focusItem(current - 1);
} else if (event.key === "Home") {
event.preventDefault();
focusItem(0);
} else if (event.key === "End") {
event.preventDefault();
focusItem(items.length - 1);
} else if (event.key === "Tab") {
// Close, park focus on the toggle, and let Tab continue from there.
setOpen(false);
fabRef.current?.focus();
}
},
[close, focusItem, items.length, setOpen]
);
const handleFabKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLButtonElement>) => {
if ((event.key === "ArrowDown" || event.key === "ArrowUp") && !open) {
event.preventDefault();
setOpen(true);
}
},
[open, setOpen]
);
const activate = React.useCallback(
(item: BuoyItem) => {
item.onClick?.();
close(true);
},
[close]
);
const maxRank = items.length - 1;
const bubbleClass = cn(
"group relative flex size-11 items-center justify-center rounded-full border border-white/15 bg-white/[0.08] text-neutral-200 backdrop-blur-md",
"shadow-[0_10px_24px_-10px_rgba(0,0,0,0.7),inset_0_1px_0_rgba(255,255,255,0.12)] transition-colors duration-200",
"hover:border-transparent hover:bg-(--buoy-accent) hover:text-neutral-950",
"focus-visible:border-transparent focus-visible:bg-(--buoy-accent) focus-visible:text-neutral-950 focus-visible:outline-none"
);
return (
<div
data-crucible="buoy"
className={cn("relative inline-block", className)}
style={{ ["--buoy-accent" as string]: accentColor, ...style }}
{...props}
>
{/* Bubble layer, anchored at the button's center. */}
<div
id={menuId}
role="menu"
aria-label={label}
aria-hidden={!open}
onKeyDown={handleMenuKeyDown}
className="absolute left-1/2 top-1/2 z-10 size-0"
>
<AnimatePresence>
{open &&
items.map((item, i) => {
const seat = positions[i];
const openDelay = reducedMotion ? 0 : seat.rank * 0.06;
const exitDelay = reducedMotion ? 0 : (maxRank - seat.rank) * 0.045;
const isLink = typeof item.href === "string";
const setItemRef = (
el: HTMLButtonElement | HTMLAnchorElement | null
) => {
itemRefs.current[i] = el;
};
const inner = (
<>
<span aria-hidden>{item.icon}</span>
<span
aria-hidden
className="pointer-events-none absolute -top-9 left-1/2 -translate-x-1/2 whitespace-nowrap rounded-full border border-white/10 bg-neutral-900 px-2.5 py-1 text-[11px] font-medium text-neutral-200 opacity-0 transition-opacity duration-150 group-hover:opacity-100 group-focus-visible:opacity-100"
>
{item.label}
</span>
</>
);
return (
<motion.div
key={item.label}
className="absolute -ml-[22px] -mt-[22px]"
initial={
reducedMotion
? { x: seat.x, y: seat.y, scale: 1, opacity: 0 }
: { x: 0, y: 0, scale: 0.35, opacity: 0 }
}
animate={{ x: seat.x, y: seat.y, scale: 1, opacity: 1 }}
exit={
reducedMotion
? { opacity: 0, transition: { duration: 0.12 } }
: {
x: 0,
y: 0,
scale: 0.35,
opacity: 0,
transition: {
delay: exitDelay,
duration: 0.26,
ease: "easeIn",
},
}
}
transition={
reducedMotion
? { duration: 0.15 }
: {
type: "spring",
stiffness: 420,
damping: 15,
mass: 0.8,
delay: openDelay,
}
}
>
{/* Asynchronous idle bob — objects on still water. */}
<motion.div
animate={reducedMotion ? { y: 0 } : { y: [0, -2.5, 0] }}
transition={
reducedMotion
? { duration: 0 }
: {
duration: 2.6 + (i % 3) * 0.45,
repeat: Infinity,
ease: "easeInOut",
delay: 0.9 + i * 0.3,
}
}
>
{isLink ? (
<a
ref={setItemRef}
role="menuitem"
tabIndex={-1}
href={item.href}
aria-label={item.label}
onClick={() => activate(item)}
className={bubbleClass}
>
{inner}
</a>
) : (
<button
ref={setItemRef}
role="menuitem"
tabIndex={-1}
type="button"
aria-label={item.label}
onClick={() => activate(item)}
className={bubbleClass}
>
{inner}
</button>
)}
</motion.div>
</motion.div>
);
})}
</AnimatePresence>
</div>
{/* The buoy itself — gentle idle bob, liquid squash on toggle. */}
<motion.div
animate={reducedMotion ? { y: 0 } : { y: [0, -4, 0] }}
transition={
reducedMotion
? { duration: 0 }
: { duration: 3.4, repeat: Infinity, ease: "easeInOut" }
}
>
<motion.button
ref={fabRef}
type="button"
aria-expanded={open}
aria-controls={menuId}
aria-label={label}
aria-haspopup="menu"
onClick={() => setOpen(!open)}
onKeyDown={handleFabKeyDown}
whileTap={reducedMotion ? undefined : { scale: 0.92 }}
className="relative flex size-14 items-center justify-center rounded-full border border-white/15 bg-white/[0.08] text-white backdrop-blur-md shadow-[0_16px_36px_-12px_rgba(0,0,0,0.8),inset_0_1px_0_rgba(255,255,255,0.14)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-950"
>
<motion.span
key={hasToggled ? String(open) : "initial"}
aria-hidden
className="grid h-full w-full place-items-center"
initial={{ scaleX: 1, scaleY: 1 }}
animate={
hasToggled && !reducedMotion
? {
scaleX: [1, 1.14, 0.94, 1],
scaleY: [1, 0.84, 1.08, 1],
}
: { scaleX: 1, scaleY: 1 }
}
transition={{ duration: 0.45, ease: "easeOut" }}
>
<motion.svg
viewBox="0 0 24 24"
className="size-6"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
animate={{ rotate: open ? 45 : 0 }}
transition={
reducedMotion
? { duration: 0 }
: { type: "spring", stiffness: 380, damping: 22 }
}
>
<path d="M12 5v14M5 12h14" />
</motion.svg>
</motion.span>
</motion.button>
</motion.div>
</div>
);
}
Installation
CLI
npx shadcn@latest add @crucible/buoyManual — install dependencies, then copy the source
npm install motionProps
| Prop | Type | Default | Description |
|---|---|---|---|
| items | BuoyItem[] | a sample set of four | Action bubbles, in arc order. |
| open | boolean | Controlled open state. | |
| defaultOpen | boolean | false | Initial open state when uncontrolled. |
| onOpenChange | (open: boolean) => void | Fires whenever the menu opens or closes, controlled or not. | |
| radius | number | 92 | Distance from the button center to each bubble center, in px. |
| arc | [number, number] | [150, 30] (fanned overhead) | Arc the bubbles fan across, as [fromDeg, toDeg] — 0° points right, 90° straight up. |
| accentColor | string | "#ff8a73" (coral) | Accent fill for the hovered/focused bubble — the set's single warm note. |
| label | string | "Quick actions" | Accessible name for the toggle button and menu. |
Also accepts all props of Omit<React.ComponentPropsWithoutRef<"div">, "onClick"> — they pass through to the underlying element. | |||
Honors prefers-reduced-motion with a designed static fallback, and passes className through.