diff --git a/package.json b/package.json
index 86c71f6..7fdf8c8 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@schemavaults/ui",
- "version": "0.55.0",
+ "version": "0.56.2",
"private": false,
"license": "UNLICENSED",
"description": "React.js UI components for SchemaVaults frontend applications",
diff --git a/src/components/ui/floating-action-button/FloatingActionButton.stories.tsx b/src/components/ui/floating-action-button/FloatingActionButton.stories.tsx
new file mode 100644
index 0000000..1b33046
--- /dev/null
+++ b/src/components/ui/floating-action-button/FloatingActionButton.stories.tsx
@@ -0,0 +1,277 @@
+import type { Meta, StoryObj } from "@storybook/react";
+import { fn } from "storybook/test";
+import {
+ ArrowUp,
+ Bell,
+ Edit,
+ Heart,
+ MessageCircle,
+ Plus,
+ Trash2,
+} from "lucide-react";
+import type { ReactElement } from "react";
+
+import {
+ FloatingActionButton,
+ floatingActionButtonPositionIds,
+ floatingActionButtonSizeIds,
+ floatingActionButtonVariantIds,
+ type FloatingActionButtonPositionId,
+ type FloatingActionButtonSizeId,
+ type FloatingActionButtonVariantId,
+} from "./floating-action-button";
+
+interface FloatingActionButtonExampleProps {
+ variant?: FloatingActionButtonVariantId;
+ size?: FloatingActionButtonSizeId;
+ position?: FloatingActionButtonPositionId;
+ label?: string;
+ disabled?: boolean;
+ onClick?: () => void;
+}
+
+function FloatingActionButtonExample({
+ label,
+ position,
+ ...props
+}: FloatingActionButtonExampleProps): ReactElement {
+ const isFixed = position !== undefined && position !== "static";
+ return (
+
+
+ This box acts as a stand-in viewport so you can see how the FAB anchors
+ to each corner without it escaping into the Storybook chrome. In a real
+ app, non-static positions are position: fixed{" "}
+ relative to the browser viewport.
+
+
}
+ aria-label="Create new item"
+ className={isFixed ? "absolute" : undefined}
+ />
+
+ );
+}
+
+const meta = {
+ title: "Components/FloatingActionButton",
+ component: FloatingActionButtonExample,
+ parameters: {
+ layout: "padded",
+ },
+ tags: ["autodocs"],
+ argTypes: {
+ variant: {
+ options: floatingActionButtonVariantIds,
+ control: { type: "select" },
+ },
+ size: {
+ options: floatingActionButtonSizeIds,
+ control: { type: "radio" },
+ },
+ position: {
+ options: floatingActionButtonPositionIds,
+ control: { type: "select" },
+ },
+ label: {
+ control: { type: "text" },
+ },
+ disabled: {
+ control: { type: "boolean" },
+ },
+ },
+ args: {
+ variant: "primary",
+ size: "default",
+ position: "bottom-right",
+ disabled: false,
+ onClick: fn(),
+ },
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {
+ args: {},
+};
+
+export const Extended: Story = {
+ args: {
+ label: "New item",
+ },
+};
+
+export const Brand: Story = {
+ args: {
+ variant: "brand",
+ },
+};
+
+export const Destructive: Story = {
+ args: {
+ variant: "destructive",
+ label: "Delete",
+ },
+};
+
+export const Outline: Story = {
+ args: {
+ variant: "outline",
+ },
+};
+
+export const Small: Story = {
+ args: {
+ size: "sm",
+ },
+};
+
+export const Large: Story = {
+ args: {
+ size: "lg",
+ label: "Compose",
+ },
+};
+
+export const BottomLeft: Story = {
+ args: {
+ position: "bottom-left",
+ },
+};
+
+export const TopRight: Story = {
+ args: {
+ position: "top-right",
+ variant: "secondary",
+ },
+};
+
+function AllVariantsExample(): ReactElement {
+ return (
+
+ {floatingActionButtonVariantIds.map((variant) => (
+
+ }
+ aria-label={`${variant} FAB`}
+ />
+
+ {variant}
+
+
+ ))}
+
+ );
+}
+
+export const AllVariants: Story = {
+ render: () => ,
+ args: {},
+};
+
+function AllSizesExample(): ReactElement {
+ return (
+
+ {floatingActionButtonSizeIds.map((size) => (
+
+ }
+ aria-label={`${size} FAB`}
+ />
+
+ {size}
+
+
+ ))}
+
+ );
+}
+
+export const AllSizes: Story = {
+ render: () => ,
+ args: {},
+};
+
+function ExtendedShowcaseExample(): ReactElement {
+ return (
+
+ }
+ label="Compose"
+ />
+ }
+ label="New message"
+ />
+ }
+ label="Delete forever"
+ />
+ }
+ label="Favorite"
+ />
+ }
+ label="Notifications"
+ />
+
+ );
+}
+
+export const ExtendedShowcase: Story = {
+ render: () => ,
+ args: {},
+};
+
+function ScrollToTopExample(): ReactElement {
+ return (
+
+
+
Long scrollable content
+ {Array.from({ length: 14 }).map((_, idx) => (
+
+ Paragraph {idx + 1} - A floating action button is most useful when
+ pinned to a fixed corner of the viewport. In this story it is pinned
+ to the bottom-right of this container so reviewers can see how it
+ stays anchored while the surrounding content scrolls underneath.
+
+ ))}
+
+
}
+ aria-label="Scroll to top"
+ className="absolute"
+ />
+
+ );
+}
+
+export const PinnedToContainer: Story = {
+ render: () => ,
+ args: {},
+};
diff --git a/src/components/ui/floating-action-button/floating-action-button.tsx b/src/components/ui/floating-action-button/floating-action-button.tsx
new file mode 100644
index 0000000..e602c7b
--- /dev/null
+++ b/src/components/ui/floating-action-button/floating-action-button.tsx
@@ -0,0 +1,173 @@
+"use client";
+
+import { Slot } from "@radix-ui/react-slot";
+import { cva, type VariantProps } from "class-variance-authority";
+import type {
+ ButtonHTMLAttributes,
+ ReactElement,
+ ReactNode,
+ Ref,
+} from "react";
+
+import { cn } from "@/lib/utils";
+
+export const floatingActionButtonVariantIds = [
+ "primary",
+ "secondary",
+ "destructive",
+ "outline",
+ "brand",
+] as const satisfies string[];
+
+export type FloatingActionButtonVariantId =
+ (typeof floatingActionButtonVariantIds)[number];
+
+export const floatingActionButtonSizeIds = [
+ "sm",
+ "default",
+ "lg",
+] as const satisfies string[];
+
+export type FloatingActionButtonSizeId =
+ (typeof floatingActionButtonSizeIds)[number];
+
+export const floatingActionButtonPositionIds = [
+ "bottom-right",
+ "bottom-left",
+ "top-right",
+ "top-left",
+ "static",
+] as const satisfies string[];
+
+export type FloatingActionButtonPositionId =
+ (typeof floatingActionButtonPositionIds)[number];
+
+const floatingActionButtonVariants = cva(
+ "group/fab inline-flex items-center justify-center gap-2 font-medium ring-offset-background shadow-lg transition-[background-color,border-color,color,box-shadow,transform] hover:shadow-xl active:scale-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 disabled:shadow-none z-40",
+ {
+ variants: {
+ variant: {
+ primary:
+ "bg-primary text-primary-foreground hover:bg-primary/90 shadow-primary/25",
+ secondary:
+ "bg-secondary text-secondary-foreground hover:bg-secondary/80 shadow-secondary/25",
+ destructive:
+ "bg-destructive text-destructive-foreground hover:bg-destructive/90 shadow-destructive/30",
+ outline:
+ "border border-input bg-background text-foreground hover:bg-accent hover:text-accent-foreground",
+ brand:
+ "bg-schemavaults-brand-blue text-primary-foreground hover:bg-schemavaults-brand-blue/90 shadow-schemavaults-brand-blue/30",
+ } satisfies Record,
+ size: {
+ sm: "h-10 w-10 rounded-full text-sm [&_svg]:size-4",
+ default: "h-14 w-14 rounded-full text-base [&_svg]:size-6",
+ lg: "h-16 w-16 rounded-full text-base [&_svg]:size-7",
+ } satisfies Record,
+ extended: {
+ true: "rounded-full w-auto",
+ false: "",
+ },
+ position: {
+ "bottom-right": "fixed bottom-6 right-6",
+ "bottom-left": "fixed bottom-6 left-6",
+ "top-right": "fixed top-6 right-6",
+ "top-left": "fixed top-6 left-6",
+ static: "",
+ } satisfies Record,
+ },
+ compoundVariants: [
+ { extended: true, size: "sm", class: "h-10 px-4" },
+ { extended: true, size: "default", class: "h-14 px-6" },
+ { extended: true, size: "lg", class: "h-16 px-8" },
+ ],
+ defaultVariants: {
+ variant: "primary",
+ size: "default",
+ extended: false,
+ position: "bottom-right",
+ },
+ },
+);
+
+export interface FloatingActionButtonProps
+ extends Omit, "children">,
+ VariantProps {
+ ref?: Ref;
+ /**
+ * Icon rendered inside the FAB. Required for icon-only mode.
+ */
+ icon: ReactNode;
+ /**
+ * Optional label rendered alongside the icon. When provided, the FAB
+ * switches to its extended layout (icon + text) unless `extended` is
+ * explicitly set to false.
+ */
+ label?: ReactNode;
+ /**
+ * Accessible label for the button. Strongly recommended in icon-only
+ * mode where no visible text describes the action.
+ */
+ "aria-label"?: string;
+ /**
+ * Render the button as a different element via Radix Slot (useful for
+ * wrapping with a Link component while preserving styles).
+ */
+ asChild?: boolean;
+}
+
+function FloatingActionButton({
+ className,
+ variant,
+ size,
+ position,
+ extended,
+ icon,
+ label,
+ asChild = false,
+ type = "button",
+ ref,
+ ...props
+}: FloatingActionButtonProps): ReactElement {
+ const Comp = asChild ? Slot : "button";
+ const isExtended = extended ?? Boolean(label);
+
+ return (
+
+
+ {icon}
+
+ {isExtended && label != null ? (
+
+ {label}
+
+ ) : null}
+
+ );
+}
+FloatingActionButton.displayName = "FloatingActionButton";
+
+export { FloatingActionButton, floatingActionButtonVariants };
+
+export default FloatingActionButton;
diff --git a/src/components/ui/floating-action-button/index.ts b/src/components/ui/floating-action-button/index.ts
new file mode 100644
index 0000000..ee68c4f
--- /dev/null
+++ b/src/components/ui/floating-action-button/index.ts
@@ -0,0 +1,4 @@
+export * from "./floating-action-button";
+export type * from "./floating-action-button";
+
+export { default } from "./floating-action-button";
diff --git a/src/components/ui/index.ts b/src/components/ui/index.ts
index 9484b88..67ca7b8 100644
--- a/src/components/ui/index.ts
+++ b/src/components/ui/index.ts
@@ -248,3 +248,6 @@ export type * from "./carousel";
export * from "./countdown";
export type * from "./countdown";
+
+export * from "./floating-action-button";
+export type * from "./floating-action-button";