Buttons

Floating Action Button

A fixed-position action button that enters with a scale-and-blur rise and compresses on press.

Install

npx shadcn@latest add @paragon/floating-action-button

floating-action-button.tsx

"use client";

import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";

const floatingActionButtonVariants = cva(
  "fixed z-40 inline-flex items-center justify-center rounded-full text-sm font-medium whitespace-nowrap transition-[scale,background-color,box-shadow] duration-150 ease-out outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-5 [&_svg]:shrink-0",
  {
    variants: {
      variant: {
        default:
          "bg-primary text-primary-foreground shadow-overlay hover:bg-primary/90",
        outline:
          "bg-card text-foreground shadow-border hover:shadow-border-hover",
      },
      position: {
        "bottom-right": "right-6 bottom-6",
        "bottom-left": "left-6 bottom-6",
      },
    },
    defaultVariants: {
      variant: "default",
      position: "bottom-right",
    },
  },
);

export interface FloatingActionButtonProps
  extends React.ComponentProps<"button">,
    VariantProps<typeof floatingActionButtonVariants> {
  /**
   * Text for an extended FAB. Omit for the icon-only circle — then pass
   * `aria-label` describing the action.
   */
  label?: string;
  /** Disables the enter animation and press-scale feedback. */
  static?: boolean;
}

/**
 * A fixed-position action button that rises in with scale + blur and
 * compresses on press. Defaults to the bottom-right corner of the viewport;
 * override positioning via `className` (e.g. `absolute` inside a relative
 * panel).
 */
export function FloatingActionButton({
  className,
  variant,
  position,
  label,
  static: isStatic = false,
  children,
  style,
  ...props
}: FloatingActionButtonProps) {
  const id = React.useId().replace(/[^a-zA-Z0-9-]/g, "");

  return (
    <>
      {!isStatic && (
        <style href={`paragon-floating-action-button-${id}`} precedence="paragon">{`
          @keyframes fab-enter-${id} {
            from {
              opacity: 0;
              translate: 0 var(--distance-enter);
              scale: 0.9;
              filter: blur(var(--blur-enter));
            }
          }
          @keyframes fab-fade-${id} {
            from { opacity: 0; }
          }
          @media (prefers-reduced-motion: reduce) {
            [data-fab="${id}"] { animation-name: fab-fade-${id} !important; }
          }
        `}</style>
      )}
      <button
        type="button"
        data-slot="floating-action-button"
        data-fab={isStatic ? undefined : id}
        className={cn(
          floatingActionButtonVariants({ variant, position }),
          label ? "h-14 gap-2 pr-6 pl-5" : "size-14",
          !isStatic && "active:not-disabled:scale-[0.97]",
          className,
        )}
        style={
          isStatic
            ? style
            : {
                animation: `fab-enter-${id} var(--duration-base) var(--ease-out) backwards`,
                ...style,
              }
        }
        {...props}
      >
        {children}
        {label ? <span>{label}</span> : null}
      </button>
    </>
  );
}

export { floatingActionButtonVariants };