A fixed-position action button that enters with a scale-and-blur rise and compresses on press.
npx shadcn@latest add @paragon/floating-action-button"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 };