Floating Action Button
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";

/*
 * Enter runs the house language — opacity, 12px rise, scale 0.9, 4px blur —
 * over 250ms ease-out. The keyframes are identical for every instance, so a
 * single hoisted <style> (href + precedence) serves all FABs; reduced motion
 * swaps the rise for a plain fade via an animation-name override.
 */
const fabStyles = `
@keyframes pg-fab-enter {
  from {
    opacity: 0;
    translate: 0 var(--distance-enter);
    scale: 0.9;
    filter: blur(var(--blur-enter));
  }
}
@keyframes pg-fab-fade {
  from { opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
  [data-slot="floating-action-button"] {
    animation-name: pg-fab-fade !important;
  }
}
`;

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) {
  return (
    <>
      {!isStatic && (
        <style href="paragon-floating-action-button" precedence="paragon">
          {fabStyles}
        </style>
      )}
      <button
        type="button"
        data-slot="floating-action-button"
        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: `pg-fab-enter var(--duration-base) var(--ease-out) backwards`,
                ...style,
              }
        }
        {...props}
      >
        {children}
        {label ? <span>{label}</span> : null}
      </button>
    </>
  );
}

export { floatingActionButtonVariants };