Back to components

Floating Shortcut Button

A configurable floating action menu with coordinated size presets, a shrinking trigger, crossfading icon states, and staggered shortcuts.

overlayscale
"use client";

import * as React from "react";
import { motion, useReducedMotion, type Variants } from "motion/react";

import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";

type ButtonProps = React.ComponentProps<typeof Button>;
type ButtonClickEvent = Parameters<NonNullable<ButtonProps["onClick"]>>[0];
type ButtonKeyDownEvent = Parameters<
  NonNullable<ButtonProps["onKeyDown"]>
>[0];
export type FloatingShortcutTriggerProps = Omit<
  ButtonProps,
  | "aria-controls"
  | "aria-expanded"
  | "aria-haspopup"
  | "aria-label"
  | "children"
  | "ref"
  | "role"
  | "size"
  | "style"
  | "type"
  | "variant"
>;

export type FloatingShortcutButtonSize = "sm" | "md" | "lg";

export type FloatingShortcutMetrics = {
  triggerSize: number;
  iconOnlyTriggerSize: number;
  openTriggerSize: number;
  actionSize: number;
  triggerIconSize: number;
  closeIconSize: number;
  actionIconSize: number;
  stackGap: number;
  triggerGap: number;
  rowGap: number;
  captionGap: number;
};

export type FloatingShortcutMotion = {
  /** Total duration in milliseconds. Set to 0 to disable motion. */
  duration: number;
  /** Vertical travel used by action rows. */
  distance: number;
  /** Scale applied while an action is pressed. */
  pressScale: number;
};

export type FloatingShortcutButtonClassNames = {
  root?: string;
  menu?: string;
  triggerSlot?: string;
  trigger?: string;
  triggerSurface?: string;
  triggerFace?: string;
  triggerIcon?: string;
  caption?: string;
  closeFace?: string;
  actionRow?: string;
  actionButton?: string;
  actionIcon?: string;
  actionLabel?: string;
};

export const floatingShortcutSizePresets = {
  sm: {
    triggerSize: 48,
    iconOnlyTriggerSize: 40,
    openTriggerSize: 36,
    actionSize: 40,
    triggerIconSize: 18,
    closeIconSize: 24,
    actionIconSize: 18,
    stackGap: 6,
    triggerGap: 6,
    rowGap: 6,
    captionGap: 2,
  },
  md: {
    triggerSize: 56,
    iconOnlyTriggerSize: 48,
    openTriggerSize: 40,
    actionSize: 48,
    triggerIconSize: 20,
    closeIconSize: 28,
    actionIconSize: 20,
    stackGap: 8,
    triggerGap: 8,
    rowGap: 8,
    captionGap: 2,
  },
  lg: {
    triggerSize: 64,
    iconOnlyTriggerSize: 56,
    openTriggerSize: 44,
    actionSize: 56,
    triggerIconSize: 24,
    closeIconSize: 32,
    actionIconSize: 24,
    stackGap: 10,
    triggerGap: 10,
    rowGap: 10,
    captionGap: 3,
  },
} satisfies Record<FloatingShortcutButtonSize, FloatingShortcutMetrics>;

export const floatingShortcutMotionDefault = {
  duration: 170,
  distance: 8,
  pressScale: 0.97,
} satisfies FloatingShortcutMotion;

const ICON_SWAP_DELAY_RATIO = 0.5;
const ACTION_STAGGER_RATIO = 0.2;
const PRESS_DURATION_RATIO = 130 / 170;
const EASE_OUT = [0.23, 1, 0.32, 1] as const;

function resolvePositive(value: number | undefined, fallback: number) {
  return typeof value === "number" && Number.isFinite(value) && value > 0
    ? value
    : fallback;
}

function resolveNonNegative(value: number | undefined, fallback: number) {
  return typeof value === "number" && Number.isFinite(value) && value >= 0
    ? value
    : fallback;
}

const FOCUSABLE_SELECTOR = [
  "a[href]",
  "button:not(:disabled)",
  "input:not(:disabled)",
  "select:not(:disabled)",
  "textarea:not(:disabled)",
  '[tabindex]:not([tabindex="-1"])',
].join(",");

function focusAdjacentToTrigger(
  trigger: HTMLButtonElement | null,
  direction: -1 | 1,
) {
  if (!trigger) return;

  const focusableElements = Array.from(
    document.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR),
  ).filter(
    (element) =>
      element.tabIndex >= 0 &&
      !element.hasAttribute("hidden") &&
      !element.closest("[inert]") &&
      !element.closest('[aria-hidden="true"]'),
  );
  const triggerIndex = focusableElements.indexOf(trigger);

  if (triggerIndex === -1) {
    trigger.focus();
    return;
  }

  const target = focusableElements[triggerIndex + direction];

  (target ?? trigger).focus();
}

function resolveMetrics(
  size: FloatingShortcutButtonSize,
  overrides?: Partial<FloatingShortcutMetrics>,
): FloatingShortcutMetrics {
  const preset = floatingShortcutSizePresets[size];

  return {
    triggerSize: resolvePositive(overrides?.triggerSize, preset.triggerSize),
    iconOnlyTriggerSize: resolvePositive(
      overrides?.iconOnlyTriggerSize,
      preset.iconOnlyTriggerSize,
    ),
    openTriggerSize: resolvePositive(
      overrides?.openTriggerSize,
      preset.openTriggerSize,
    ),
    actionSize: resolvePositive(overrides?.actionSize, preset.actionSize),
    triggerIconSize: resolvePositive(
      overrides?.triggerIconSize,
      preset.triggerIconSize,
    ),
    closeIconSize: resolvePositive(
      overrides?.closeIconSize,
      preset.closeIconSize,
    ),
    actionIconSize: resolvePositive(
      overrides?.actionIconSize,
      preset.actionIconSize,
    ),
    stackGap: resolveNonNegative(overrides?.stackGap, preset.stackGap),
    triggerGap: resolveNonNegative(overrides?.triggerGap, preset.triggerGap),
    rowGap: resolveNonNegative(overrides?.rowGap, preset.rowGap),
    captionGap: resolveNonNegative(overrides?.captionGap, preset.captionGap),
  };
}

function resolveMotion(
  overrides?: Partial<FloatingShortcutMotion>,
): FloatingShortcutMotion {
  return {
    duration: resolveNonNegative(
      overrides?.duration,
      floatingShortcutMotionDefault.duration,
    ),
    distance: resolveNonNegative(
      overrides?.distance,
      floatingShortcutMotionDefault.distance,
    ),
    pressScale: resolvePositive(
      overrides?.pressScale,
      floatingShortcutMotionDefault.pressScale,
    ),
  };
}

function createActionItemVariants(
  motionConfig: FloatingShortcutMotion,
  reduceMotion: boolean,
  skipMotion = false,
) {
  const duration = skipMotion ? 0 : motionConfig.duration / 1000;
  const openTransform = "translate3d(0px, 0px, 0)";
  const closedTransform =
    reduceMotion || skipMotion
      ? openTransform
      : `translate3d(0px, ${motionConfig.distance}px, 0)`;

  return {
    open: {
      opacity: 1,
      transform: openTransform,
      transition: { duration, ease: EASE_OUT },
    },
    closed: {
      opacity: 0,
      transform: closedTransform,
      transition: { duration, ease: EASE_OUT },
    },
  } satisfies Variants;
}

function createActionListVariants(duration: number) {
  return {
    open: {
      transition: {
        staggerChildren: (duration * ACTION_STAGGER_RATIO) / 1000,
        staggerDirection: -1,
      },
    },
    closed: {
      transition: { staggerChildren: 0 },
    },
  } satisfies Variants;
}

type FloatingShortcutContextValue = {
  close: (skipMotion?: boolean) => void;
  closeOnAction: boolean;
  metrics: FloatingShortcutMetrics;
  motion: FloatingShortcutMotion;
  actionItemVariants: Variants;
  classNames?: FloatingShortcutButtonClassNames;
};

const FloatingShortcutContext = React.createContext<
  FloatingShortcutContextValue | undefined
>(undefined);

function useFloatingShortcutContext() {
  const context = React.useContext(FloatingShortcutContext);

  if (!context) {
    throw new Error(
      "FloatingShortcutAction must be used inside FloatingShortcutButton.",
    );
  }

  return context;
}

function ShortcutGridIcon() {
  return (
    <svg aria-hidden="true" viewBox="0 0 24 24" fill="none">
      <g transform="translate(2 2)">
        <path
          fill="currentColor"
          fillRule="evenodd"
          clipRule="evenodd"
          d="M6 11a3 3 0 0 1 3 3v3a3 3 0 0 1-3 3H3a3 3 0 0 1-3-3v-3a3 3 0 0 1 3-3zm-3 2a1 1 0 0 0-1 1v3a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1v-3a1 1 0 0 0-1-1zm14-2a3 3 0 0 1 3 3v3a3 3 0 0 1-3 3h-3a3 3 0 0 1-3-3v-3a3 3 0 0 1 3-3zm-3 2a1 1 0 0 0-1 1v3a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1v-3a1 1 0 0 0-1-1zM6.154.004A3 3 0 0 1 9 3v3a3 3 0 0 1-3 3H3A3 3 0 0 1 .004 6.154L0 6V3a3 3 0 0 1 3-3h3zM3 2a1 1 0 0 0-1 1v3l.005.103A1 1 0 0 0 3 7h3a1 1 0 0 0 1-1V3a1 1 0 0 0-.897-.995L6 2zM17.154.004A3 3 0 0 1 20 3v3a3 3 0 0 1-3 3h-3a3 3 0 0 1-3-3V3a3 3 0 0 1 3-3h3zM14 2a1 1 0 0 0-1 1v3a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1V3a1 1 0 0 0-.898-.995L17 2z"
        />
      </g>
    </svg>
  );
}

function ShortcutCloseIcon() {
  return (
    <svg
      aria-hidden="true"
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth="2"
      strokeLinecap="round"
    >
      <path d="m5.5 5.5 13 13m0-13-13 13" />
    </svg>
  );
}

export type FloatingShortcutButtonProps = Omit<
  React.ComponentProps<"div">,
  "onChange"
> & {
  /** FloatingShortcutAction elements revealed above the trigger. */
  children: React.ReactNode;
  /** Controlled open state. */
  open?: boolean;
  /** Initial open state for uncontrolled usage. */
  defaultOpen?: boolean;
  /** Called after the menu requests an open-state change. */
  onOpenChange?: (open: boolean) => void;
  /** Icon shown while the shortcut menu is closed. */
  triggerIcon?: React.ReactNode;
  /** Short caption shown under the trigger icon. Set to null to hide it. */
  triggerCaption?: React.ReactNode;
  /** Whether to render the trigger caption. Its text still labels the button. */
  showTriggerCaption?: boolean;
  /** Accessible name for the closed trigger. */
  triggerLabel?: string;
  /** Accessible name for the open trigger. */
  closeLabel?: string;
  /** Accessible name for the menu. */
  menuLabel?: string;
  /** Icon shown while the shortcut menu is open. */
  closeIcon?: React.ReactNode;
  /** Coordinated geometry preset. @default "md" */
  size?: FloatingShortcutButtonSize;
  /** Advanced geometry overrides. Invalid values fall back to the size preset. */
  metrics?: Partial<FloatingShortcutMetrics>;
  /** Motion overrides. Related delay and stagger values keep their ratios. */
  motion?: Partial<FloatingShortcutMotion>;
  /** Classes for styling individual component slots. */
  classNames?: FloatingShortcutButtonClassNames;
  /** Props forwarded to the trigger button. */
  triggerProps?: FloatingShortcutTriggerProps;
  /** Close the menu after an action runs. @default true */
  closeOnAction?: boolean;
};

export type FloatingShortcutActionProps = Omit<
  ButtonProps,
  | "aria-label"
  | "children"
  | "role"
  | "size"
  | "style"
  | "type"
  | "variant"
> & {
  /** Visible action label and accessible name. */
  label: string;
  /** Icon rendered inside the circular action button. */
  icon: React.ReactNode;
  /** Classes applied to the label-and-button row. */
  rowClassName?: string;
  /** Classes applied to this action's icon wrapper. */
  iconClassName?: string;
  /** Classes applied to this action's visible label. */
  labelClassName?: string;
  /** Override the root menu's close-on-action behavior. */
  closeOnAction?: boolean;
  /** Inline styles that do not replace geometry controlled by metrics. */
  style?: React.CSSProperties;
};

export function FloatingShortcutButton({
  children,
  open: openProp,
  defaultOpen = false,
  onOpenChange,
  triggerIcon,
  triggerCaption = "Quick",
  showTriggerCaption = true,
  triggerLabel,
  closeLabel = "Close shortcuts",
  menuLabel = "Shortcuts",
  closeIcon,
  size = "md",
  metrics: metricsOverrides,
  motion: motionOverrides,
  classNames,
  triggerProps,
  closeOnAction = true,
  className,
  style,
  onKeyDown,
  ref,
  ...props
}: FloatingShortcutButtonProps) {
  const [internalOpen, setInternalOpen] = React.useState(defaultOpen);
  const [skipMotion, setSkipMotion] = React.useState(false);
  const triggerRef = React.useRef<HTMLButtonElement | null>(null);
  const rootRef = React.useRef<HTMLDivElement | null>(null);
  const pendingFocusRef = React.useRef<"first" | "last" | null>(null);
  const menuId = React.useId();
  const shouldReduceMotion = useReducedMotion() ?? false;
  const resolvedMetrics = React.useMemo(
    () => resolveMetrics(size, metricsOverrides),
    [metricsOverrides, size],
  );
  const resolvedMotion = React.useMemo(
    () => resolveMotion(motionOverrides),
    [motionOverrides],
  );
  const actionListVariants = React.useMemo(
    () => createActionListVariants(skipMotion ? 0 : resolvedMotion.duration),
    [resolvedMotion.duration, skipMotion],
  );
  const actionItemVariants = React.useMemo(
    () =>
      createActionItemVariants(resolvedMotion, shouldReduceMotion, skipMotion),
    [resolvedMotion, shouldReduceMotion, skipMotion],
  );
  const iconDelay = resolvedMotion.duration * ICON_SWAP_DELAY_RATIO;
  const hasVisibleTriggerCaption =
    showTriggerCaption && Boolean(triggerCaption);
  const closedTriggerScale = hasVisibleTriggerCaption
    ? 1
    : Math.min(
        resolvedMetrics.iconOnlyTriggerSize,
        resolvedMetrics.triggerSize,
      ) / resolvedMetrics.triggerSize;
  const openScale =
    resolvedMetrics.openTriggerSize / resolvedMetrics.triggerSize;
  const controlled = openProp !== undefined;
  const open = controlled ? openProp : internalOpen;
  const captionLabel =
    typeof triggerCaption === "string" ? triggerCaption.trim() : "";
  const resolvedTriggerLabel =
    triggerLabel?.trim() || captionLabel || "Open shortcuts";
  const resolvedCloseLabel = closeLabel.trim() || "Close shortcuts";
  const resolvedMenuLabel = menuLabel.trim() || "Shortcuts";
  const {
    className: triggerClassName,
    disabled: triggerDisabled,
    onClick: onTriggerClick,
    onKeyDown: onTriggerKeyDown,
    ...restTriggerProps
  } = triggerProps ?? {};
  const setOpen = React.useCallback(
    (nextOpen: boolean, shouldSkipMotion = false) => {
      setSkipMotion(shouldSkipMotion);

      if (!controlled) {
        setInternalOpen(nextOpen);
      }

      onOpenChange?.(nextOpen);
    },
    [controlled, onOpenChange],
  );

  React.useEffect(() => {
    if (!skipMotion) return;

    const frame = window.requestAnimationFrame(() => setSkipMotion(false));

    return () => window.cancelAnimationFrame(frame);
  }, [open, skipMotion]);

  // The root node is needed internally (menu-item queries, outside-click) *and*
  // by consumers, so the consumer's ref is merged in rather than overwritten.
  const setRootRef = React.useCallback(
    (node: HTMLDivElement | null) => {
      rootRef.current = node;

      if (typeof ref === "function") {
        ref(node);
      } else if (ref) {
        ref.current = node;
      }
    },
    [ref],
  );

  const getEnabledItems = React.useCallback(
    () =>
      Array.from(
        rootRef.current?.querySelectorAll<HTMLButtonElement>(
          "[role='menuitem']:not(:disabled)",
        ) ?? [],
      ),
    [],
  );

  const close = React.useCallback(
    (shouldSkipMotion = false) => {
      setOpen(false, shouldSkipMotion);
      triggerRef.current?.focus();
    },
    [setOpen],
  );

  React.useEffect(() => {
    if (!open) return;

    // The ARIA menu-button pattern dismisses on outside interaction. Focus is
    // intentionally left where the user clicked (unlike Escape, which returns
    // it to the trigger).
    const handlePointerDown = (event: PointerEvent) => {
      const root = rootRef.current;

      if (!root || !(event.target instanceof Node)) return;
      if (root.contains(event.target)) return;

      setOpen(false);
    };

    document.addEventListener("pointerdown", handlePointerDown);

    return () => document.removeEventListener("pointerdown", handlePointerDown);
  }, [open, setOpen]);

  React.useEffect(() => {
    if (!open || !pendingFocusRef.current) return;

    const items = getEnabledItems();
    const pendingFocus = pendingFocusRef.current;
    pendingFocusRef.current = null;
    const item = pendingFocus === "first" ? items[0] : items.at(-1);

    item?.focus();
  }, [getEnabledItems, open]);

  const handleTriggerClick = (event: ButtonClickEvent) => {
    onTriggerClick?.(event);

    if (event.defaultPrevented || triggerDisabled) return;

    if (!open) {
      pendingFocusRef.current = "first";
    }

    setOpen(!open, event.detail === 0);
  };

  const handleTriggerKeyDown = (event: ButtonKeyDownEvent) => {
    onTriggerKeyDown?.(event);

    if (event.defaultPrevented || triggerDisabled) return;
    if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return;

    event.preventDefault();
    pendingFocusRef.current = event.key === "ArrowDown" ? "first" : "last";

    if (open) {
      const items = getEnabledItems();
      const item = event.key === "ArrowDown" ? items[0] : items.at(-1);
      pendingFocusRef.current = null;
      item?.focus();
      return;
    }

    setOpen(true, true);
  };

  const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
    onKeyDown?.(event);

    if (event.defaultPrevented || !open) return;

    if (event.key === "Escape") {
      event.preventDefault();
      close(true);
      return;
    }

    if (event.key === "Tab") {
      event.preventDefault();
      setOpen(false, true);
      focusAdjacentToTrigger(triggerRef.current, event.shiftKey ? -1 : 1);
      return;
    }

    const items = getEnabledItems();
    const activeIndex = items.indexOf(document.activeElement as HTMLButtonElement);

    if (activeIndex === -1) return;

    let nextIndex: number | undefined;

    if (event.key === "ArrowDown") {
      nextIndex = (activeIndex + 1) % items.length;
    } else if (event.key === "ArrowUp") {
      nextIndex = (activeIndex - 1 + items.length) % items.length;
    } else if (event.key === "Home") {
      nextIndex = 0;
    } else if (event.key === "End") {
      nextIndex = items.length - 1;
    } else if (
      event.key.length === 1 &&
      !event.altKey &&
      !event.ctrlKey &&
      !event.metaKey
    ) {
      const query = event.key.toLocaleLowerCase();

      for (let offset = 1; offset <= items.length; offset += 1) {
        const candidateIndex = (activeIndex + offset) % items.length;
        const candidateLabel =
          items[candidateIndex]
            ?.getAttribute("aria-label")
            ?.toLocaleLowerCase() ?? "";

        if (candidateLabel.startsWith(query)) {
          nextIndex = candidateIndex;
          break;
        }
      }
    }

    if (nextIndex !== undefined) {
      event.preventDefault();
      items[nextIndex]?.focus();
    }
  };

  const contextValue = React.useMemo<FloatingShortcutContextValue>(
    () => ({
      close,
      closeOnAction,
      metrics: resolvedMetrics,
      motion: resolvedMotion,
      actionItemVariants,
      classNames,
    }),
    [
      actionItemVariants,
      classNames,
      close,
      closeOnAction,
      resolvedMetrics,
      resolvedMotion,
    ],
  );

  return (
    <FloatingShortcutContext.Provider value={contextValue}>
      <div
        {...props}
        ref={setRootRef}
        data-slot="floating-shortcut-button"
        data-state={open ? "open" : "closed"}
        className={cn(
          "relative inline-flex flex-col items-end",
          classNames?.root,
          className,
        )}
        style={{ gap: resolvedMetrics.triggerGap, ...style }}
        onKeyDown={handleKeyDown}
      >
        <motion.div
          id={menuId}
          role="menu"
          aria-label={resolvedMenuLabel}
          aria-hidden={!open}
          inert={!open || undefined}
          data-slot="floating-shortcut-menu"
          className={cn("flex flex-col items-end", classNames?.menu)}
          style={{ gap: resolvedMetrics.stackGap }}
          variants={actionListVariants}
          initial={false}
          animate={open ? "open" : "closed"}
        >
          {children}
        </motion.div>

        <div
          data-slot="floating-shortcut-trigger-slot"
          className={cn(
            "relative grid place-items-center",
            classNames?.triggerSlot,
          )}
          style={{
            width: resolvedMetrics.triggerSize,
            height: resolvedMetrics.triggerSize,
          }}
        >
          <Button
            {...restTriggerProps}
            ref={triggerRef}
            type="button"
            variant="link"
            size="icon-lg"
            disabled={triggerDisabled}
            aria-label={open ? resolvedCloseLabel : resolvedTriggerLabel}
            aria-expanded={open}
            aria-haspopup="menu"
            aria-controls={menuId}
            data-state={open ? "open" : "closed"}
            onClick={handleTriggerClick}
            onKeyDown={handleTriggerKeyDown}
            className={cn(
              // `touch-manipulation` opts out of double-tap-to-zoom, which is
              // what makes mobile browsers hold a tap for ~300ms before firing
              // click — the menu would otherwise open a beat after the press.
              "relative origin-center touch-manipulation rounded-full p-0 no-underline",
              classNames?.trigger,
              triggerClassName,
            )}
            style={{
              width: resolvedMetrics.triggerSize,
              height: resolvedMetrics.triggerSize,
            }}
          >
            <span
              aria-hidden="true"
              data-slot="floating-shortcut-trigger-surface"
              className={cn(
                "pointer-events-none absolute inset-0 rounded-full bg-primary shadow-sm transition-[transform,background-color] group-hover/button:bg-primary/90 group-active/button:bg-primary/90 motion-reduce:transition-none",
                classNames?.triggerSurface,
              )}
              style={{
                transform: `scale(${open ? openScale : closedTriggerScale})`,
                transitionDuration: `${skipMotion ? 0 : resolvedMotion.duration}ms`,
                transitionTimingFunction:
                  "cubic-bezier(0.77, 0, 0.175, 1)",
              }}
            />
          </Button>

          <div
            aria-hidden="true"
            className="pointer-events-none absolute inset-0 grid place-items-center text-primary-foreground"
          >
            <span
              data-slot="floating-shortcut-trigger-face"
              className={cn(
                "col-start-1 row-start-1 flex flex-col items-center transition-[opacity,filter] motion-reduce:transition-none",
                open ? "opacity-0 blur-xs" : "opacity-100 blur-none",
                classNames?.triggerFace,
              )}
              style={{
                gap: resolvedMetrics.captionGap,
                transitionDuration: `${skipMotion ? 0 : resolvedMotion.duration}ms`,
                transitionDelay:
                  skipMotion || open ? "0ms" : `${iconDelay}ms`,
                transitionTimingFunction:
                  "cubic-bezier(0.23, 1, 0.32, 1)",
              }}
            >
              <span
                data-slot="floating-shortcut-trigger-icon"
                className={cn(
                  "grid place-items-center [&_svg]:size-full",
                  classNames?.triggerIcon,
                )}
                style={{
                  width: resolvedMetrics.triggerIconSize,
                  height: resolvedMetrics.triggerIconSize,
                }}
              >
                {triggerIcon ?? <ShortcutGridIcon />}
              </span>
              {showTriggerCaption && triggerCaption ? (
                <span
                  data-slot="floating-shortcut-caption"
                  className={cn(
                    "text-xs font-medium leading-none",
                    classNames?.caption,
                  )}
                >
                  {triggerCaption}
                </span>
              ) : null}
            </span>
            <span
              data-slot="floating-shortcut-close-face"
              className={cn(
                "col-start-1 row-start-1 grid place-items-center transition-[opacity,filter] motion-reduce:transition-none [&_svg]:size-full",
                open ? "opacity-100 blur-none" : "opacity-0 blur-xs",
                classNames?.closeFace,
              )}
              style={{
                width: resolvedMetrics.closeIconSize,
                height: resolvedMetrics.closeIconSize,
                transitionDuration: `${skipMotion ? 0 : resolvedMotion.duration}ms`,
                transitionDelay:
                  skipMotion || !open ? "0ms" : `${iconDelay}ms`,
                transitionTimingFunction:
                  "cubic-bezier(0.23, 1, 0.32, 1)",
              }}
            >
              {closeIcon ?? <ShortcutCloseIcon />}
            </span>
          </div>
        </div>
      </div>
    </FloatingShortcutContext.Provider>
  );
}

export function FloatingShortcutAction({
  label,
  icon,
  rowClassName,
  iconClassName,
  labelClassName,
  closeOnAction: closeOnActionProp,
  className,
  style,
  onClick,
  ...props
}: FloatingShortcutActionProps) {
  const context = useFloatingShortcutContext();
  const metrics = context.metrics;
  const motionConfig = context.motion;
  const actionOffset = (metrics.triggerSize - metrics.actionSize) / 2;
  const pressDuration = motionConfig.duration * PRESS_DURATION_RATIO;
  const resolvedStyle: React.CSSProperties & {
    "--floating-shortcut-press-scale": number;
  } = {
    ...style,
    width: metrics.actionSize,
    height: metrics.actionSize,
    marginInlineEnd: actionOffset,
    transitionDuration: `${pressDuration}ms`,
    transitionTimingFunction: "cubic-bezier(0.23, 1, 0.32, 1)",
    "--floating-shortcut-press-scale": motionConfig.pressScale,
  };

  const handleClick = (event: ButtonClickEvent) => {
    onClick?.(event);

    if (
      !event.defaultPrevented &&
      (closeOnActionProp ?? context.closeOnAction)
    ) {
      context.close(event.detail === 0);
    }
  };

  return (
    <motion.div
      role="none"
      data-slot="floating-shortcut-action-row"
      variants={context.actionItemVariants}
      className={cn(
        "flex items-center",
        context.classNames?.actionRow,
        rowClassName,
      )}
      style={{ gap: metrics.rowGap }}
    >
      <span
        data-slot="floating-shortcut-action-label"
        className={cn(
          "text-sm font-medium text-foreground",
          context.classNames?.actionLabel,
          labelClassName,
        )}
      >
        {label}
      </span>
      <Button
        {...props}
        type="button"
        role="menuitem"
        variant="ghost"
        size="icon-lg"
        aria-label={label}
        tabIndex={-1}
        onClick={handleClick}
        className={cn(
          "touch-manipulation rounded-full border-border bg-card text-card-foreground shadow-sm transition-[transform,background-color] hover:bg-accent active:scale-[var(--floating-shortcut-press-scale)] active:bg-accent/70 disabled:opacity-40 motion-reduce:transform-none motion-reduce:transition-none",
          context.classNames?.actionButton,
          className,
        )}
        style={resolvedStyle}
      >
        <span
          aria-hidden="true"
          data-icon
          data-slot="floating-shortcut-action-icon"
          className={cn(
            "grid place-items-center [&_svg]:size-full",
            context.classNames?.actionIcon,
            iconClassName,
          )}
          style={{ width: metrics.actionIconSize, height: metrics.actionIconSize }}
        >
          {icon}
        </span>
      </Button>
    </motion.div>
  );
}

Installation