docs / sula / sula-fab
SulaFab
a speed dial whose actions are liquid beads: each necks off the trigger, travels to its slot on a thinning thread, and settles. it collapses to a plain stacked menu under reduced motion.
intensity · assertsrsc · client
provenance
authored in usvalayer
sulaintensity
asserts · one per region. it is the focal pointcomposition
the page's one primary action with a few close relativesactions with href render as real anchors when the action navigatesnot for toolbars or a dozen commands. a plain menu reads fasterone sula per region. it never shares a corner with SulaNava11y
the trigger carriesaria-expanded and aria-haspopup · closed actions are inert · open focuses the first action, Escape returns focus to the triggerjest-axedependencies
motion · ogl · sula-core and sula-motion from the same packagelive demo · try it out
customize
labelthe trigger's accessible name
layoutstack along a line or fan an arc
directionwhich way a line opens
tooltipPositiontooltip side; auto is left for lines, top for arcs
gapedge gap in px between trigger and beads
fluidoff renders the plain stacked menu
shine0 matte glass, 1 full neon rim
usage
import { SulaFab } from "usva/sula/sula-fab";
<SulaFab
label="Create"
actions={actions}
/>props
| prop | type | default | notes |
|---|---|---|---|
| actions | SulaFabAction[] | — | { icon, label, onClick?, href? } each. beads are icon-only; the label is the accessible name and the tooltip. |
| icon | ReactNode | a plus | the trigger glyph. |
| label | string | "Actions" | the trigger's accessible name. |
| open / defaultOpen | boolean | false | controlled and uncontrolled open state. |
| onOpenChange | (open: boolean) => void | — | fires on toggle, Escape, or an action. |
| layout | "line" | "arc" | "line" | line stacks the beads along direction; arc fans them upward. |
| direction | "up" | "down" | "left" | "right" | "up" | which way a line layout opens. |
| tooltipPosition | "left" | "right" | "top" | — | where action tooltips sit. left for line, top for arc. |
| gap | number | 12 | constant edge gap in px between trigger and beads. raise it for touch. |
| fluid | boolean | true | false renders a plain stacked menu and mounts no canvas. |
| backdrop / tint / accentColor | string | — | the glass palette. default to the bg, surface-2 and accent tokens. |
| shine | number | per backdrop | 0 is flat matte glass, 1 is the full neon rim. |
get it
npx shadcn add https://usva.build/r/sula-fab.jsonsource · components/ui/fab-geometry.tsexactly what this command copies
import { emergeDroplet } from "./emerge";
import { type Blob, bridgeNecks, type Neck } from "./geometry";
export type FabLayout = "line" | "arc";
export type FabDirection = "up" | "down" | "left" | "right";
export interface FabSlot {
x: number;
y: number;
}
/** Real trigger and bead radii plus the constant edge gap between neighbours. */
export interface FabSpacing {
triggerR: number;
beadR: number;
gap: number;
}
/** Per-bead launch delay, as a fraction of open progress. Nearer beads lead. */
export const FAB_STAGGER = 0.08;
/** The arc fan spans this many degrees, centred on the layout direction. */
const ARC_SPAN = (100 * Math.PI) / 180;
const UNIT: Record<FabDirection, FabSlot> = {
up: { x: 0, y: -1 },
down: { x: 0, y: 1 },
left: { x: -1, y: 0 },
right: { x: 1, y: 0 },
};
function lineSlots(
count: number,
direction: FabDirection,
{ triggerR, beadR, gap }: FabSpacing,
): FabSlot[] {
const unit = UNIT[direction];
/* Edge gaps are constant: the trigger edge to bead0 edge is `gap`, and every
* bead edge to the next bead edge is `gap`, whatever the two radii are. */
const first = triggerR + beadR + gap;
const step = 2 * beadR + gap;
return Array.from({ length: count }, (_, i) => {
const distance = first + i * step;
return { x: unit.x * distance, y: unit.y * distance };
});
}
function arcSlots(
count: number,
direction: FabDirection,
{ triggerR, beadR, gap }: FabSpacing,
): FabSlot[] {
/* Fan evenly across ARC_SPAN centred on the direction's axis. A single bead
* sits on that axis. Every bead is one radius from the trigger centre, so the
* trigger edge to bead edge gap is `gap`. */
const unit = UNIT[direction];
const center = Math.atan2(unit.y, unit.x);
const radius = triggerR + beadR + gap;
return Array.from({ length: count }, (_, i) => {
const frac = count === 1 ? 0.5 : i / (count - 1);
const angle = center - ARC_SPAN / 2 + frac * ARC_SPAN;
return { x: Math.cos(angle) * radius, y: Math.sin(angle) * radius };
});
}
/** Slot centres for the actions, relative to the trigger centre at (0,0). */
export function fabSlots(
count: number,
layout: FabLayout,
direction: FabDirection,
spacing: FabSpacing,
): FabSlot[] {
if (count <= 0) return [];
return layout === "arc"
? arcSlots(count, direction, spacing)
: lineSlots(count, direction, spacing);
}
/** A fully rounded blob from a measured rect, relative to the stage box. */
export function blobFromRect(rect: {
left: number;
top: number;
width: number;
height: number;
}): Blob {
const hw = rect.width / 2;
const hh = rect.height / 2;
return {
cx: rect.left + hw,
cy: rect.top + hh,
hw,
hh,
r: Math.min(hw, hh),
};
}
/**
* The field at open-progress `t` (0 closed, 1 open; may pass 1 for spring
* overshoot): the trigger blob plus one bead per action, each emerging from the
* trigger to its slot, staggered so nearer beads lead. Necks tether the beads
* that are still travelling. `slots` are the beads at their rest slot positions.
*/
export function fabPhase(
trigger: Blob,
slots: Blob[],
t: number,
): { blobs: Blob[]; necks: Neck[] } {
const blobs: Blob[] = [trigger];
const necks: Neck[] = [];
for (let i = 0; i < slots.length; i++) {
const slot = slots[i] as Blob;
/* Later beads launch after the nearer ones, but every local timeline maps
* global t=1 back to exactly 1. This preserves the cascade without leaving
* the early beads stranded at different overshoot positions at rest. */
const delay = i * FAB_STAGGER;
const ti = (t - delay) / (1 - delay);
const { blob, neck } = emergeDroplet(trigger, slot, ti);
blobs.push(blob);
if (neck) necks.push(neck);
}
return { blobs, necks };
}
/** Resting surface tension follows the layout's visual topology. Arc actions
* radiate from the trigger. Line actions form a strong chain, with only the first
* action tied weakly to the trigger so the menu reads as one strand that can be
* pulled back into the FAB. */
export function fabBridges(
blobs: Blob[],
k: number,
merge: number,
layout: FabLayout,
): Neck[] {
const trigger = blobs[0];
if (!trigger) return [];
const actions = blobs.slice(1);
if (layout === "arc") {
return actions.flatMap((bead) => bridgeNecks([trigger, bead], k, merge));
}
const actionChain = bridgeNecks(actions, k, merge);
const first = actions[0];
const triggerTie = first ? bridgeNecks([trigger, first], k, merge * 0.5) : [];
return [...actionChain, ...triggerTie];
}
source · components/ui/sula-fab.tsxexactly what this command copies
"use client";
import { animate, useReducedMotion } from "motion/react";
import * as React from "react";
import { cn } from "@/lib/utils";
import {
createField,
type FieldColors,
resolveColor,
shineForBackdrop,
} from "./field";
import {
type Blob,
type Neck,
packHover,
packUniforms,
} from "./geometry";
import { createPauseGate } from "./pause";
import { useContextRecovery } from "./recovery";
import { useFieldRetune } from "./retune";
import { clamp01, smoothstep } from "./curves";
import { createEnergyTracker } from "./energy";
import { sideSpring } from "./springs";
import {
FAB_STAGGER,
type FabDirection,
type FabLayout,
fabBridges,
fabPhase,
fabSlots,
} from "./fab-geometry";
export interface SulaFabAction {
icon: React.ReactNode;
/** The accessible name and the tooltip text. */
label: string;
onClick?: () => void;
href?: string;
}
export type SulaFabTooltipPosition = "left" | "right" | "top";
export interface SulaFabProps
extends Omit<React.HTMLAttributes<HTMLDivElement>, "onChange"> {
actions: SulaFabAction[];
/** The trigger glyph. Defaults to a plus. */
icon?: React.ReactNode;
/** The trigger's accessible name. Defaults to "Actions". */
label?: string;
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
layout?: FabLayout;
direction?: FabDirection;
/** Tooltip side. Defaults to left for line layouts and top for arcs. */
tooltipPosition?: SulaFabTooltipPosition;
/** Constant edge gap in px between the trigger and beads, and between beads. */
gap?: number;
/** false or reduced-motion renders a plain stacked menu with no canvas. */
fluid?: boolean;
accentColor?: string;
backdrop?: string;
tint?: string;
shine?: number;
}
/** Edge gap between neighbours in px. Kept tight so open beads sit within bridge
* reach and hold the rest necks. */
const GAP_DEFAULT = 12;
/** Nominal trigger radius from the `size-14` class (56px box), for the non-fluid
* fallback layout before any measurement runs. */
const NOMINAL_TRIGGER_R = 28;
/** Nominal bead radius from the `size-11` class (44px box). */
const NOMINAL_BEAD_R = 22;
/** Room past the outermost bead so its cap and neck are never clipped. */
const SLACK = 26;
const MAX_DPR = 2;
/** Merge radius at rest: firm glass. */
const REST_K = 16;
/** Merge radius while beads travel: gooey. */
const K_ACTIVE = 26;
/** Peak surface undulation in px, alive only while beads move. */
const WOBBLE = 0.6;
/** Merge floor while open: adjacent beads hold a soft surface-tension waist at
* rest instead of floating apart. Kept low so the chain never fuses. */
const REST_MERGE = 0.32;
/** Peak edge displacement of the hover ripple, in px. Local to the hovered
* part, so it can run hotter than the global settle wobble. */
const HOVER_WOBBLE = 1.1;
/** Per-frame ease toward the hover target, in and out. */
const HOVER_EASE = 0.16;
function readColors(
node: HTMLElement,
overrides: {
backdrop?: string;
tint?: string;
accent?: string;
shine?: number;
},
): FieldColors {
const styles = getComputedStyle(node);
const token = (name: string) => styles.getPropertyValue(name).trim();
const backdrop = resolveColor(overrides.backdrop ?? token("--usva-bg"));
const tintToken =
overrides.tint ?? token("--usva-surface-2") ?? token("--usva-surface");
return {
backdrop,
tint: resolveColor(tintToken || token("--usva-surface")),
accent: resolveColor(overrides.accent ?? token("--usva-accent")),
shine: overrides.shine ?? shineForBackdrop(backdrop),
};
}
const DefaultPlus = (
<svg
viewBox="0 0 24 24"
width="20"
height="20"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
aria-hidden="true"
>
<path d="M12 5v14M5 12h14" />
</svg>
);
export const SulaFab = React.forwardRef<HTMLDivElement, SulaFabProps>(
(
{
actions,
icon = DefaultPlus,
label = "Actions",
open: openProp,
defaultOpen = false,
onOpenChange,
layout = "line",
direction = "up",
tooltipPosition,
gap = GAP_DEFAULT,
fluid = true,
accentColor,
backdrop,
tint,
shine,
className,
...props
},
forwardedRef,
) => {
const reduced = useReducedMotion();
const rootRef = React.useRef<HTMLDivElement | null>(null);
const canvasRef = React.useRef<HTMLCanvasElement | null>(null);
const stageRef = React.useRef<HTMLDivElement | null>(null);
const triggerRef = React.useRef<HTMLButtonElement | null>(null);
const actionRefs = React.useRef<Array<HTMLElement | null>>([]);
const { failed, generation, onContextLost, onContextReady } =
useContextRecovery(canvasRef);
const overrides = React.useMemo(
() => ({ backdrop, tint, accent: accentColor, shine }),
[backdrop, tint, accentColor, shine],
);
const overridesRef = React.useRef(overrides);
overridesRef.current = overrides;
const gapRef = React.useRef(gap);
gapRef.current = gap;
const fieldRef = React.useRef<ReturnType<typeof createField>>(null);
const wakeRef = React.useRef<(() => void) | null>(null);
const measureRef = React.useRef<(() => void) | null>(null);
const [mounted, setMounted] = React.useState(false);
React.useEffect(() => setMounted(true), []);
const isFluid = fluid && !reduced && !failed && mounted;
/* Kept mounted through a failure so the context can be handed back. */
const keepCanvas = fluid && !reduced && mounted;
const resolvedTooltipPosition =
tooltipPosition ?? (layout === "arc" ? "top" : "left");
const isControlled = openProp !== undefined;
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen);
const open = isControlled ? openProp : uncontrolledOpen;
const openRef = React.useRef(open);
openRef.current = open;
const setOpen = React.useCallback(
(next: boolean) => {
if (!isControlled) setUncontrolledOpen(next);
onOpenChange?.(next);
},
[isControlled, onOpenChange],
);
const slotOffsets = React.useMemo(
() =>
fabSlots(actions.length, layout, direction, {
triggerR: NOMINAL_TRIGGER_R,
beadR: NOMINAL_BEAD_R,
gap,
}),
[actions.length, layout, direction, gap],
);
const openTargetRef = React.useRef<(next: boolean) => void>(() => {});
// biome-ignore lint/correctness/useExhaustiveDependencies: `generation` is not read here, it is what rebuilds the field on a restored context
React.useEffect(() => {
if (!isFluid) return;
const canvas = canvasRef.current;
const stage = stageRef.current;
const root = rootRef.current;
const trigger = triggerRef.current;
if (!canvas || !stage || !root || !trigger) return;
const field = createField({
canvas,
colors: readColors(root, overridesRef.current),
onContextLost,
});
if (!field) {
onContextLost();
return;
}
fieldRef.current = field;
onContextReady();
let triggerBlob: Blob = { cx: 0, cy: 0, hw: 0, hh: 0, r: 0 };
let triggerCenter = { x: 0, y: 0 };
let slotBlobs: Blob[] = [];
let canvasH = 0;
let dpr = 1;
let raf = 0;
let running = 0;
let hoverIndex = -1;
let hoverAmt = 0;
let hoverBlob: Blob | null = null;
const energy = createEnergyTracker();
const start = performance.now();
const oT = { value: openRef.current ? 1 : 0 };
let pOpen = oT.value;
/* Index 0 is the trigger, i + 1 the beads, matching fabPhase's blob order.
* On leave the last blob is kept so the ripple fades in place. */
const updateHover = (blobs: Blob[]) => {
const focus = hoverIndex >= 0 ? blobs[hoverIndex] : null;
hoverAmt += ((focus ? 1 : 0) - hoverAmt) * HOVER_EASE;
if (focus) hoverBlob = focus;
};
let lastW = 0;
let lastH = 0;
const measure = () => {
const rootBox = root.getBoundingClientRect();
const triggerRect = trigger.getBoundingClientRect();
/* Layout sizes, never rect sizes: the open trigger carries rotate-45,
* which inflates its client-rect AABB by sqrt(2). Measured that way the
* first slot sat a near-doubled edge gap out. The rect centre is still
* exact under any rotation, so positions may keep using it. */
const tw = trigger.offsetWidth || triggerRect.width;
const th = trigger.offsetHeight || triggerRect.height;
const firstNode = actionRefs.current[0];
const beadH = firstNode ? firstNode.offsetHeight : th * 0.7;
const triggerR = Math.min(tw, th) / 2;
const beadR = firstNode
? Math.min(firstNode.offsetWidth, firstNode.offsetHeight) / 2
: beadH / 2;
const offsets = fabSlots(actions.length, layout, direction, {
triggerR,
beadR,
gap: gapRef.current,
});
// Stage bounds in root coords, covering the trigger and every slot.
const cx0 = triggerRect.left - rootBox.left + triggerRect.width / 2;
const cy0 = triggerRect.top - rootBox.top + triggerRect.height / 2;
let minX = -tw / 2;
let maxX = tw / 2;
let minY = -th / 2;
let maxY = th / 2;
for (const [i, off] of offsets.entries()) {
const node = actionRefs.current[i];
const bw = (node?.offsetWidth || beadH) / 2;
const bh = (node?.offsetHeight || beadH) / 2;
minX = Math.min(minX, off.x - bw);
maxX = Math.max(maxX, off.x + bw);
minY = Math.min(minY, off.y - bh);
maxY = Math.max(maxY, off.y + bh);
}
stage.style.left = `${cx0 + minX - SLACK}px`;
stage.style.top = `${cy0 + minY - SLACK}px`;
stage.style.width = `${maxX - minX + SLACK * 2}px`;
stage.style.height = `${maxY - minY + SLACK * 2}px`;
const stageBox = stage.getBoundingClientRect();
const width = Math.ceil(stageBox.width);
const height = Math.ceil(stageBox.height);
const nextDpr = Math.min(window.devicePixelRatio || 1, MAX_DPR);
if (width !== lastW || height !== lastH || nextDpr !== dpr) {
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
field.resize(width, height, nextDpr);
lastW = width;
lastH = height;
}
canvasH = height;
dpr = nextDpr;
triggerBlob = {
cx: triggerRect.left + triggerRect.width / 2 - stageBox.left,
cy: triggerRect.top + triggerRect.height / 2 - stageBox.top,
hw: tw / 2,
hh: th / 2,
r: triggerR,
};
triggerCenter = { x: triggerBlob.cx, y: triggerBlob.cy };
slotBlobs = offsets.map((off, i) => {
const node = actionRefs.current[i];
const bw = (node?.offsetWidth || beadH) / 2;
const bh = (node?.offsetHeight || beadH) / 2;
return {
cx: triggerCenter.x + off.x,
cy: triggerCenter.y + off.y,
hw: bw,
hh: bh,
r: Math.min(bw, bh),
};
});
};
const paint = (blobs: Blob[], necks: Neck[], liveK: number) => {
field.draw({
packed: packUniforms({ blobs, necks, k: liveK }, dpr, canvasH),
k: liveK * dpr,
time: (performance.now() - start) / 1000,
wobble: WOBBLE * energy.value,
alpha: 1,
hover:
hoverBlob && hoverAmt > 0.01
? packHover(hoverBlob, hoverAmt * HOVER_WOBBLE, dpr, canvasH)
: null,
});
};
const liveK = () => REST_K + (K_ACTIVE - REST_K) * energy.value;
const tick = () => {
const t = oT.value;
const prog = clamp01(t);
const v = t - pOpen;
pOpen = t;
energy.bump(Math.abs(v));
const { blobs, necks } = fabPhase(triggerBlob, slotBlobs, t);
updateHover(blobs);
const k = liveK();
/* Melt the neck as the fab closes: the whole merge fades to zero by the
* time the beads absorb, so the trigger-to-bead neck recedes by strength
* instead of snapping off at the clean-close cut. At settled open the
* floor keeps a faint pull; mid-travel the sine drives the full goo.
* Floor the neck merge while open so settled-open beads keep a faint
* pull; necks still fully retract as prog falls to 0 on close. */
const merge =
prog > 0
? Math.max(REST_MERGE, Math.sin(Math.PI * prog)) *
smoothstep(0.02, 0.28, prog)
: 0;
const bridge = fabBridges(blobs, k, merge, layout);
/* Below this the field settles to a clean trigger, no absorbed-bead halo. */
const closed = prog < 0.06;
const drawBlobs = closed ? [triggerBlob] : blobs;
const drawNecks = closed ? [] : [...necks, ...bridge];
paint(drawBlobs, drawNecks, k);
// Beads are DOM parts moved to match their blob each frame.
const last = slotBlobs.length;
for (let i = 0; i < last; i++) {
const node = actionRefs.current[i];
const bead = blobs[i + 1];
if (!node || !bead) continue;
node.style.transform = `translate(-50%, -50%) translate(${
bead.cx - triggerCenter.x
}px, ${bead.cy - triggerCenter.y}px)`;
node.style.opacity = closed
? "0"
: `${clamp01((prog - i * FAB_STAGGER) / 0.22)}`;
}
if (
running === 0 &&
energy.parked() &&
hoverIndex < 0 &&
hoverAmt < 0.02
) {
return;
}
raf = requestAnimationFrame(tick);
};
const wake = () => {
if (!gate.awake()) return;
cancelAnimationFrame(raf);
raf = requestAnimationFrame(tick);
};
wakeRef.current = wake;
measureRef.current = measure;
const gate = createPauseGate({
target: root,
onPause: () => cancelAnimationFrame(raf),
onResume: () => wake(),
});
const controls = new Set<ReturnType<typeof animate>>();
const run = (to: [number, number]) => {
running += 1;
const control = animate(oT, { value: to }, sideSpring);
controls.add(control);
const finish = () => {
running = Math.max(0, running - 1);
controls.delete(control);
};
void control.finished.then(finish, finish);
wake();
return control;
};
let openControl: ReturnType<typeof animate> | null = null;
const toOpen = (next: boolean) => {
measure();
openControl?.stop();
/* Motion caches one visual element per animated subject and reads the
* next "from" out of it, so a bare oT.value reset is invisible to it.
* The explicit [0,1] / [1,0] keyframes are what make the second and
* every later toggle animate instead of snapping. */
openControl = run(next ? [0, 1] : [1, 0]);
};
openTargetRef.current = toOpen;
measure();
wake();
/* Hovering the trigger or a bead wakes a ripple on that part alone; the
* rest of the chain stays a calm sheet of glass. */
const hoverNodes = [trigger, ...actionRefs.current].filter(
(node): node is HTMLElement => node != null,
);
const hoverHandlers = hoverNodes.map((node, index) => {
const enter = () => {
hoverIndex = index;
wake();
};
const leave = () => {
if (hoverIndex === index) hoverIndex = -1;
};
node.addEventListener("pointerenter", enter);
node.addEventListener("pointerleave", leave);
return { node, enter, leave };
});
const remeasure = () => {
measure();
wake();
};
const observer =
typeof ResizeObserver === "undefined"
? null
: new ResizeObserver(remeasure);
observer?.observe(root);
window.addEventListener("resize", remeasure);
void document.fonts?.ready.then(remeasure).catch(() => undefined);
const themeObserver =
typeof MutationObserver === "undefined"
? null
: new MutationObserver(() => {
field.setColors(readColors(root, overridesRef.current));
wake();
});
themeObserver?.observe(document.documentElement, {
attributes: true,
attributeFilter: ["data-theme", "class"],
});
return () => {
for (const control of controls) control.stop();
openTargetRef.current = () => {};
for (const { node, enter, leave } of hoverHandlers) {
node.removeEventListener("pointerenter", enter);
node.removeEventListener("pointerleave", leave);
}
window.removeEventListener("resize", remeasure);
observer?.disconnect();
themeObserver?.disconnect();
gate.dispose();
cancelAnimationFrame(raf);
field.dispose();
fieldRef.current = null;
wakeRef.current = null;
measureRef.current = null;
canvas.style.width = "";
canvas.style.height = "";
for (const node of actionRefs.current) {
if (!node) continue;
node.style.transform = "";
node.style.opacity = "";
}
};
}, [isFluid, actions.length, layout, direction, generation]);
useFieldRetune(
fieldRef,
() => (rootRef.current ? readColors(rootRef.current, overrides) : null),
overrides,
wakeRef,
);
/* gap moves every slot, so the field has to re-measure, and a parked loop
* needs waking or the new spacing waits for the pointer. */
// biome-ignore lint/correctness/useExhaustiveDependencies: `gap` is not read here, the effect closure reads it live
React.useEffect(() => {
measureRef.current?.();
wakeRef.current?.();
}, [gap]);
// Drive the field toggle from the open state.
const previousOpen = React.useRef(open);
React.useLayoutEffect(() => {
const was = previousOpen.current;
previousOpen.current = open;
if (isFluid && was !== open) openTargetRef.current(open);
}, [open, isFluid]);
// Hide the actions from AT and the tab order while closed, and move focus.
React.useEffect(() => {
const nodes = actionRefs.current;
for (const node of nodes) {
if (!node) continue;
if (open) node.removeAttribute("inert");
else node.setAttribute("inert", "");
}
if (open) {
const first = nodes[0]?.querySelector<HTMLElement>(
"a, button, [tabindex]",
);
first?.focus();
}
}, [open]);
const onRootKeyDown = (event: React.KeyboardEvent) => {
if (event.key === "Escape" && openRef.current) {
event.stopPropagation();
setOpen(false);
triggerRef.current?.focus();
}
};
const runAction = (action: SulaFabAction) => {
action.onClick?.();
setOpen(false);
};
return (
// biome-ignore lint/a11y/noStaticElementInteractions: the root is a plain container; the keydown only closes the menu on Escape, while the real controls are the trigger button and the action buttons inside it
<div
ref={(node) => {
rootRef.current = node;
if (typeof forwardedRef === "function") forwardedRef(node);
else if (forwardedRef) forwardedRef.current = node;
}}
data-fluid={isFluid ? "on" : "off"}
data-open={open || undefined}
onKeyDown={onRootKeyDown}
className={cn("relative inline-flex", className)}
{...props}
>
{keepCanvas ? (
<div
ref={stageRef}
aria-hidden="true"
className={cn(
"pointer-events-none absolute overflow-hidden",
!isFluid && "hidden",
)}
>
<canvas ref={canvasRef} />
</div>
) : null}
{actions.map((action, index) => {
const offset = slotOffsets[index] ?? { x: 0, y: 0 };
const restTransform = `translate(-50%, -50%) translate(${
open ? offset.x : 0
}px, ${open ? offset.y : 0}px)`;
const domStyle: React.CSSProperties | undefined = isFluid
? undefined
: {
transform: restTransform,
opacity: open ? 1 : 0,
};
const Tag = action.href ? "a" : "button";
return (
<div
key={action.label}
ref={(node) => {
actionRefs.current[index] = node;
}}
style={domStyle}
className={cn(
"absolute top-1/2 left-1/2 z-10",
!isFluid &&
"transition-[transform,opacity] duration-slow ease-spring motion-reduce:transition-none",
!isFluid && !open && "pointer-events-none",
)}
>
<Tag
type={action.href ? undefined : "button"}
href={action.href}
aria-label={action.label}
onClick={() => runAction(action)}
className={cn(
"peer grid size-11 place-items-center rounded-full outline-none",
"border border-border bg-surface/80 text-ink shadow-raised backdrop-blur-md",
"transition-tint duration-fast ease-soft hover:text-accent",
"focus-visible:ring-focus",
)}
>
<span aria-hidden="true" className="inline-flex">
{action.icon}
</span>
</Tag>
<span
aria-hidden="true"
data-tooltip-position={resolvedTooltipPosition}
className={cn(
"pointer-events-none absolute",
resolvedTooltipPosition === "left" &&
"top-1/2 right-full mr-3 -translate-y-1/2",
resolvedTooltipPosition === "right" &&
"top-1/2 left-full ml-3 -translate-y-1/2",
resolvedTooltipPosition === "top" &&
"bottom-full left-1/2 mb-3 -translate-x-1/2",
"rounded-md border border-border bg-sunken px-2 py-1 text-xs whitespace-nowrap text-on-sunken",
"opacity-0 transition-opacity duration-fast",
"peer-hover:opacity-100 peer-focus-visible:opacity-100",
)}
>
{action.label}
</span>
</div>
);
})}
<button
ref={triggerRef}
type="button"
aria-label={label}
aria-expanded={open}
aria-haspopup="menu"
onClick={() => setOpen(!open)}
className={cn(
"relative z-20 grid size-14 place-items-center rounded-full outline-none",
"border border-border bg-surface text-ink shadow-raised",
"transition-transform duration-fast ease-soft focus-visible:ring-focus",
"data-[open=true]:rotate-45",
)}
data-open={open || undefined}
>
<span aria-hidden="true" className="inline-flex">
{icon}
</span>
</button>
</div>
);
},
);
SulaFab.displayName = "SulaFab";