diff --git a/docs/app/docs/components/popover/docs/anatomy.tsx b/docs/app/docs/components/popover/docs/anatomy.tsx
new file mode 100644
index 000000000..c2c6e28e0
--- /dev/null
+++ b/docs/app/docs/components/popover/docs/anatomy.tsx
@@ -0,0 +1,15 @@
+import Popover from "@radui/ui/Popover";
+
+export default () => {
+ return (
+
+
+ {/* Your trigger element */}
+
+
+
+ {/* Your popover content */}
+
+
+ );
+};
diff --git a/docs/app/docs/components/popover/docs/codeUsage.js b/docs/app/docs/components/popover/docs/codeUsage.js
new file mode 100644
index 000000000..245da3121
--- /dev/null
+++ b/docs/app/docs/components/popover/docs/codeUsage.js
@@ -0,0 +1,52 @@
+import { getSourceCodeFromPath } from '@/utils/parseSourceCode';
+import Kbd from '@radui/ui/Kbd';
+import Text from '@radui/ui/Text';
+
+const example_1_SourceCode = await getSourceCodeFromPath('docs/app/docs/components/popover/docs/examples/popover_example1.tsx');
+const anatomy_SourceCode = await getSourceCodeFromPath('docs/app/docs/components/popover/docs/anatomy.tsx');
+
+import root_api_SourceCode from './component_api/root.tsx';
+import trigger_api_SourceCode from './component_api/trigger.tsx';
+import content_api_SourceCode from './component_api/content.tsx';
+import arrow_api_SourceCode from './component_api/arrow.tsx';
+
+const code = {
+ javascript: {
+ code: example_1_SourceCode
+ },
+ css: {
+ code: `todo`
+ }
+};
+
+export const anatomy = { code: anatomy_SourceCode };
+
+const columns = [
+ { name: 'Prop', id: 'prop' },
+ { name: 'Type', id: 'type' },
+ { name: 'Default', id: 'default' },
+ { name: 'Description', id: 'description' }
+];
+
+export const api_documentation = {
+ root: root_api_SourceCode,
+ trigger: trigger_api_SourceCode,
+ content: content_api_SourceCode,
+ arrow: arrow_api_SourceCode
+};
+
+export const keyboardShortcuts = {
+ columns: [
+ { name: 'Shortcut', id: 'shortcut' },
+ { name: 'Description', id: 'description' }
+ ],
+ data: [
+ {
+ shortcut: Escape,
+ description: Closes the popover.,
+ id: 'escape'
+ }
+ ]
+};
+
+export default code;
diff --git a/docs/app/docs/components/popover/docs/component_api/arrow.tsx b/docs/app/docs/components/popover/docs/component_api/arrow.tsx
new file mode 100644
index 000000000..0dba502a1
--- /dev/null
+++ b/docs/app/docs/components/popover/docs/component_api/arrow.tsx
@@ -0,0 +1,12 @@
+const data = {
+ name: 'Arrow',
+ description: 'Optional arrow pointing to the trigger.',
+ columns: [
+ { name: 'Prop', id: 'prop' },
+ { name: 'Type', id: 'type' },
+ { name: 'Default', id: 'default' }
+ ],
+ data: []
+};
+
+export default data;
diff --git a/docs/app/docs/components/popover/docs/component_api/content.tsx b/docs/app/docs/components/popover/docs/component_api/content.tsx
new file mode 100644
index 000000000..662d163b7
--- /dev/null
+++ b/docs/app/docs/components/popover/docs/component_api/content.tsx
@@ -0,0 +1,18 @@
+const data = {
+ name: 'Content',
+ description: 'The popup content.',
+ columns: [
+ { name: 'Prop', id: 'prop' },
+ { name: 'Type', id: 'type' },
+ { name: 'Default', id: 'default' }
+ ],
+ data: [
+ {
+ prop: { name: 'portalled', info_tooltips: 'Render inside a portal.' },
+ type: 'boolean',
+ default: 'true'
+ }
+ ]
+};
+
+export default data;
diff --git a/docs/app/docs/components/popover/docs/component_api/root.tsx b/docs/app/docs/components/popover/docs/component_api/root.tsx
new file mode 100644
index 000000000..f65b48191
--- /dev/null
+++ b/docs/app/docs/components/popover/docs/component_api/root.tsx
@@ -0,0 +1,33 @@
+const data = {
+ name: 'Root',
+ description: 'Provides context for the popover.',
+ columns: [
+ { name: 'Prop', id: 'prop' },
+ { name: 'Type', id: 'type' },
+ { name: 'Default', id: 'default' }
+ ],
+ data: [
+ {
+ prop: { name: 'open', info_tooltips: 'Controlled open state.' },
+ type: 'boolean',
+ default: '--'
+ },
+ {
+ prop: { name: 'defaultOpen', info_tooltips: 'Whether the popover is open by default.' },
+ type: 'boolean',
+ default: 'false'
+ },
+ {
+ prop: { name: 'onOpenChange', info_tooltips: 'Callback when open state changes.' },
+ type: 'function',
+ default: '--'
+ },
+ {
+ prop: { name: 'placement', info_tooltips: 'Preferred placement of the popover.' },
+ type: 'Placement',
+ default: 'bottom'
+ }
+ ]
+};
+
+export default data;
diff --git a/docs/app/docs/components/popover/docs/component_api/trigger.tsx b/docs/app/docs/components/popover/docs/component_api/trigger.tsx
new file mode 100644
index 000000000..a103b6ab0
--- /dev/null
+++ b/docs/app/docs/components/popover/docs/component_api/trigger.tsx
@@ -0,0 +1,18 @@
+const data = {
+ name: 'Trigger',
+ description: 'Element that toggles the popover.',
+ columns: [
+ { name: 'Prop', id: 'prop' },
+ { name: 'Type', id: 'type' },
+ { name: 'Default', id: 'default' }
+ ],
+ data: [
+ {
+ prop: { name: 'asChild', info_tooltips: 'Render the trigger as child element.' },
+ type: 'boolean',
+ default: 'false'
+ }
+ ]
+};
+
+export default data;
diff --git a/docs/app/docs/components/popover/docs/examples/popover_example1.tsx b/docs/app/docs/components/popover/docs/examples/popover_example1.tsx
new file mode 100644
index 000000000..1c1cf4c55
--- /dev/null
+++ b/docs/app/docs/components/popover/docs/examples/popover_example1.tsx
@@ -0,0 +1,23 @@
+"use client";
+
+import Card from "@radui/ui/Card";
+import Popover from "@radui/ui/Popover";
+import Text from "@radui/ui/Text";
+
+const PopoverExample1 = () => {
+ return (
+
+
+
+ Open Popover
+
+
+
+
+ Hello from the popover!
+
+
+ );
+};
+
+export default PopoverExample1;
diff --git a/docs/app/docs/components/popover/page.mdx b/docs/app/docs/components/popover/page.mdx
new file mode 100644
index 000000000..b821e134a
--- /dev/null
+++ b/docs/app/docs/components/popover/page.mdx
@@ -0,0 +1,61 @@
+import PageDetails from '@/components/seo/PageDetails';
+import Documentation from "@/components/layout/Documentation/Documentation";
+import Popover from "@radui/ui/Popover";
+import codeUsage, { anatomy, api_documentation, keyboardShortcuts } from "./docs/codeUsage";
+import PopoverExample1 from "./docs/examples/popover_example1";
+import popoverMetadata from "./seo";
+
+export const metadata = popoverMetadata;
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/app/docs/components/popover/seo.ts b/docs/app/docs/components/popover/seo.ts
new file mode 100644
index 000000000..5da94726d
--- /dev/null
+++ b/docs/app/docs/components/popover/seo.ts
@@ -0,0 +1,8 @@
+import generateSeoMetadata from "@/utils/seo/generateSeoMetadata";
+
+const popoverMetadata = generateSeoMetadata({
+ title: 'Popover - Rad UI',
+ description: 'A headless React Popover component for floating panels triggered by user interaction.'
+});
+
+export default popoverMetadata;
diff --git a/docs/app/docs/docsNavigationSections.tsx b/docs/app/docs/docsNavigationSections.tsx
index d034c5f67..5c3f716b6 100644
--- a/docs/app/docs/docsNavigationSections.tsx
+++ b/docs/app/docs/docsNavigationSections.tsx
@@ -131,6 +131,10 @@ export const docsNavigationSections = [
title:"ToggleGroup",
path:"/docs/components/toggle-group"
},
+ {
+ title:"Popover",
+ path:"/docs/components/popover"
+ },
{
title:"Tooltip",
path:"/docs/components/tooltip"
diff --git a/src/components/ui/Popover/Popover.tsx b/src/components/ui/Popover/Popover.tsx
new file mode 100644
index 000000000..19bcb2a0b
--- /dev/null
+++ b/src/components/ui/Popover/Popover.tsx
@@ -0,0 +1,28 @@
+'use client';
+
+import React from 'react';
+import PopoverRoot from './fragments/PopoverRoot';
+import PopoverTrigger from './fragments/PopoverTrigger';
+import PopoverContent from './fragments/PopoverContent';
+import PopoverArrow from './fragments/PopoverArrow';
+
+interface PopoverComponent extends React.ForwardRefExoticComponent & React.RefAttributes>> {
+ Root: typeof PopoverRoot;
+ Trigger: typeof PopoverTrigger;
+ Content: typeof PopoverContent;
+ Arrow: typeof PopoverArrow;
+}
+
+const Popover = React.forwardRef, React.ComponentPropsWithoutRef<'div'>>((_, __) => {
+ console.warn('Direct usage of Popover is not supported. Use Popover.Root etc.');
+ return null;
+}) as PopoverComponent;
+
+Popover.displayName = 'Popover';
+
+Popover.Root = PopoverRoot;
+Popover.Trigger = PopoverTrigger;
+Popover.Content = PopoverContent;
+Popover.Arrow = PopoverArrow;
+
+export default Popover;
diff --git a/src/components/ui/Popover/context/PopoverContext.tsx b/src/components/ui/Popover/context/PopoverContext.tsx
new file mode 100644
index 000000000..fd2a0d216
--- /dev/null
+++ b/src/components/ui/Popover/context/PopoverContext.tsx
@@ -0,0 +1,14 @@
+import { createContext } from 'react';
+
+export type PopoverContextType = {
+ open: boolean;
+ setOpen: (open: boolean) => void;
+ data: any;
+ interactions: any;
+ context: any;
+ arrowRef: React.RefObject;
+};
+
+const PopoverContext = createContext(null);
+
+export default PopoverContext;
diff --git a/src/components/ui/Popover/fragments/PopoverArrow.tsx b/src/components/ui/Popover/fragments/PopoverArrow.tsx
new file mode 100644
index 000000000..1fb97e5b8
--- /dev/null
+++ b/src/components/ui/Popover/fragments/PopoverArrow.tsx
@@ -0,0 +1,20 @@
+import React, { useContext } from 'react';
+import { FloatingArrow, useMergeRefs } from '@floating-ui/react';
+import PopoverContext from '../context/PopoverContext';
+import clsx from 'clsx';
+
+export type PopoverArrowElement = React.ElementRef;
+export interface PopoverArrowProps extends Omit, 'context'> {}
+
+const PopoverArrow = React.forwardRef((props, ref) => {
+ const popover = useContext(PopoverContext);
+ if (!popover) {
+ throw new Error('PopoverArrow must be used within a PopoverRoot component');
+ }
+ const mergedRef = useMergeRefs([popover.arrowRef, ref]);
+ return ;
+});
+
+PopoverArrow.displayName = 'PopoverArrow';
+
+export default PopoverArrow;
diff --git a/src/components/ui/Popover/fragments/PopoverContent.tsx b/src/components/ui/Popover/fragments/PopoverContent.tsx
new file mode 100644
index 000000000..b731cba7c
--- /dev/null
+++ b/src/components/ui/Popover/fragments/PopoverContent.tsx
@@ -0,0 +1,72 @@
+import React, { useContext, useRef } from 'react';
+import { FloatingPortal, useMergeRefs } from '@floating-ui/react';
+import PopoverContext from '../context/PopoverContext';
+import Primitive from '~/core/primitives/Primitive';
+import composeEventHandlers from '~/core/hooks/composeEventHandlers';
+import useLayoutEffect from '~/core/hooks/useLayoutEffect';
+
+export type PopoverContentElement = React.ElementRef;
+
+export type PopoverContentProps = React.ComponentPropsWithoutRef & {
+ children: React.ReactNode;
+ portalled?: boolean;
+ container?: HTMLElement | null;
+};
+
+const PopoverContent = React.forwardRef(({ children, portalled = true, container, style, onKeyDown, ...props }, ref) => {
+ const popover = useContext(PopoverContext);
+ if (!popover) {
+ throw new Error('PopoverContent must be used within a PopoverRoot component');
+ }
+ const { open, data, interactions, context, setOpen } = popover;
+ const mergedRef = useMergeRefs([context.refs.setFloating, ref]);
+ const contentRef = useRef(null);
+ const allRefs = useMergeRefs([mergedRef, contentRef]);
+
+ useLayoutEffect(() => {
+ if (!open) return;
+ const root = contentRef.current;
+ if (!root) return;
+ const selector = ['button:not([disabled])','[href]','input:not([disabled])','select:not([disabled])','textarea:not([disabled])','[tabindex]:not([tabindex="-1"])'].join(',');
+ const first = root.querySelector(selector);
+ if (first) {
+ first.focus();
+ root.removeAttribute('tabindex');
+ } else {
+ root.focus({ preventScroll: true });
+ }
+ }, [open]);
+
+ if (!open) return null;
+
+ const side = data.placement.split('-')[0];
+ const align = data.placement.split('-')[1] ?? 'center';
+
+ const element = (
+ {
+ if (e.key === 'Escape') {
+ setOpen(false);
+ e.stopPropagation();
+ }
+ })
+ })}
+ >
+ {children}
+
+ );
+
+ return portalled ? {element} : element;
+});
+
+PopoverContent.displayName = 'PopoverContent';
+
+export default PopoverContent;
diff --git a/src/components/ui/Popover/fragments/PopoverRoot.tsx b/src/components/ui/Popover/fragments/PopoverRoot.tsx
new file mode 100644
index 000000000..04be1f6e6
--- /dev/null
+++ b/src/components/ui/Popover/fragments/PopoverRoot.tsx
@@ -0,0 +1,70 @@
+'use client';
+
+import React, { useRef } from 'react';
+import { useFloating, flip, shift, offset, arrow, autoUpdate, useClick, useDismiss, useRole, useInteractions, Placement } from '@floating-ui/react';
+import useControllableState from '~/core/hooks/useControllableState';
+import useLayoutEffect from '~/core/hooks/useLayoutEffect';
+import PopoverContext from '../context/PopoverContext';
+import { useCreateDataAttribute } from '~/core/hooks/createDataAttribute';
+
+const COMPONENT_NAME = 'Popover';
+
+export type PopoverRootProps = React.ComponentPropsWithoutRef<'div'> & {
+ children: React.ReactNode;
+ open?: boolean;
+ defaultOpen?: boolean;
+ onOpenChange?: (open: boolean) => void;
+ placement?: Placement;
+}
+
+export type PopoverRootElement = React.ElementRef<'div'>;
+
+const PopoverRoot = React.forwardRef(({ children, open, defaultOpen = false, onOpenChange, placement = 'bottom', ...props }, ref) => {
+ const arrowRef = useRef(null);
+
+ const [isOpen, setIsOpen] = useControllableState(open, defaultOpen, onOpenChange);
+
+ const data = useFloating({
+ placement,
+ open: isOpen,
+ onOpenChange: setIsOpen,
+ middleware: [offset(4), flip(), shift(), arrow({ element: arrowRef })],
+ whileElementsMounted: autoUpdate
+ });
+
+ const context = data.context;
+
+ const click = useClick(context);
+ const dismiss = useDismiss(context, { escapeKey: true, outsidePress: true });
+ const role = useRole(context, { role: 'dialog' });
+
+ const interactions = useInteractions([click, dismiss, role]);
+
+ const previouslyFocused = useRef(null);
+
+ useLayoutEffect(() => {
+ if (isOpen) {
+ previouslyFocused.current = document.activeElement as HTMLElement;
+ }
+ }, [isOpen]);
+
+ useLayoutEffect(() => {
+ if (!isOpen && previouslyFocused.current) {
+ previouslyFocused.current.focus({ preventScroll: true });
+ }
+ }, [isOpen]);
+
+ const dataAttributes = useCreateDataAttribute('popover', null);
+
+ return (
+
+
+ {children}
+
+
+ );
+});
+
+PopoverRoot.displayName = COMPONENT_NAME + 'Root';
+
+export default PopoverRoot;
diff --git a/src/components/ui/Popover/fragments/PopoverTrigger.tsx b/src/components/ui/Popover/fragments/PopoverTrigger.tsx
new file mode 100644
index 000000000..e02683087
--- /dev/null
+++ b/src/components/ui/Popover/fragments/PopoverTrigger.tsx
@@ -0,0 +1,36 @@
+import React, { useContext } from 'react';
+import ButtonPrimitive from '~/core/primitives/Button';
+import { useMergeRefs } from '@floating-ui/react';
+import PopoverContext from '../context/PopoverContext';
+
+export type PopoverTriggerElement = React.ElementRef;
+
+export interface PopoverTriggerProps extends React.ComponentPropsWithoutRef {
+ asChild?: boolean;
+ children: React.ReactNode;
+}
+
+const PopoverTrigger = React.forwardRef(({ asChild, children, ...props }, ref) => {
+ const popover = useContext(PopoverContext);
+ if (!popover) {
+ throw new Error('PopoverTrigger must be used within a PopoverRoot component');
+ }
+ const { open, interactions, context } = popover;
+ const { getReferenceProps } = interactions;
+ const childrenRef = (children as any).ref;
+ const mergedRef = useMergeRefs([context.refs.setReference, ref, childrenRef]);
+ return (
+
+ {children}
+
+ );
+});
+
+PopoverTrigger.displayName = 'PopoverTrigger';
+
+export default PopoverTrigger;
diff --git a/src/components/ui/Popover/stories/Popover.stories.tsx b/src/components/ui/Popover/stories/Popover.stories.tsx
new file mode 100644
index 000000000..4566e89f5
--- /dev/null
+++ b/src/components/ui/Popover/stories/Popover.stories.tsx
@@ -0,0 +1,29 @@
+import React from 'react';
+import type { Meta, StoryObj } from '@storybook/react';
+import Popover from '../Popover';
+import SandboxEditor from '~/components/tools/SandboxEditor/SandboxEditor';
+
+const meta: Meta = {
+ title: 'Components/Popover',
+ component: Popover
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Basic: Story = {
+ render: () => (
+
+
+
+
+
+
+
+ Popover content
+
+
+
+ )
+};
diff --git a/src/components/ui/Popover/tests/Popover.behavior.test.tsx b/src/components/ui/Popover/tests/Popover.behavior.test.tsx
new file mode 100644
index 000000000..5c7588da8
--- /dev/null
+++ b/src/components/ui/Popover/tests/Popover.behavior.test.tsx
@@ -0,0 +1,65 @@
+import React from 'react';
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { renderWithPortal, assertFocusReturn } from '~/test-utils/portal';
+import { axe, keyboard } from "test-utils";
+import Popover from '../Popover';
+
+describe('Popover interactions', () => {
+ test('opens on click and closes on escape with proper data attributes', async () => {
+ const user = userEvent.setup();
+ render(
+
+ Trigger
+
+
+ Content
+
+
+ );
+
+ const trigger = screen.getByText('Trigger');
+ expect(trigger).toHaveAttribute('data-state', 'closed');
+ await user.click(trigger);
+ const content = await screen.findByText("Content");
+ const contentWrapper = content.closest("[data-state]") as HTMLElement;
+ expect(contentWrapper).toHaveAttribute("data-state", "open");
+ expect(trigger).toHaveAttribute('data-state', 'open');
+ await user.keyboard('{Escape}');
+ await waitFor(() => expect(screen.queryByText('Content')).toBeNull());
+ expect(trigger).toHaveAttribute('data-state', 'closed');
+ });
+
+ test('portal renders content and focus returns to trigger on escape', async () => {
+ const user = userEvent.setup();
+ const { getByText, cleanup } = renderWithPortal(
+
+ Trigger
+
+ Portalled
+
+
+ );
+ const trigger = getByText('Trigger');
+ await user.click(trigger);
+ await screen.findByText('Portalled');
+ await user.keyboard('{Escape}');
+ await waitFor(() => assertFocusReturn(trigger));
+ cleanup();
+ });
+
+ test('has no a11y violations when open', async () => {
+ const { container } = render(
+
+ Trigger
+
+ Accessible
+
+
+ );
+ const user = keyboard();
+ await user.click(screen.getByText('Trigger'));
+ const results = await axe(container);
+ expect(results.violations).toHaveLength(0);
+});
+});
diff --git a/src/components/ui/Popover/tests/Popover.test.tsx b/src/components/ui/Popover/tests/Popover.test.tsx
new file mode 100644
index 000000000..471344e71
--- /dev/null
+++ b/src/components/ui/Popover/tests/Popover.test.tsx
@@ -0,0 +1,45 @@
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import Popover from '../Popover';
+
+describe('Popover', () => {
+ test('renders trigger and toggles content on click', async () => {
+ render(
+
+ Trigger
+
+ Content
+
+
+ );
+
+ const trigger = screen.getByText('Trigger');
+ expect(screen.queryByText('Content')).toBeNull();
+ await userEvent.click(trigger);
+ expect(screen.getByText('Content')).toBeInTheDocument();
+ await userEvent.click(trigger);
+ expect(screen.queryByText('Content')).toBeNull();
+ });
+
+ test('forwards refs to subcomponents', async () => {
+ const rootRef = React.createRef();
+ const triggerRef = React.createRef();
+ const contentRef = React.createRef();
+
+ render(
+
+ Trigger
+
+ Content
+
+
+ );
+
+ expect(rootRef.current).toBeInstanceOf(HTMLDivElement);
+ expect(triggerRef.current).toBeInstanceOf(HTMLButtonElement);
+ expect(contentRef.current).toBeNull();
+ await userEvent.click(screen.getByText('Trigger'));
+ expect(contentRef.current).toBeInstanceOf(HTMLDivElement);
+ });
+});
diff --git a/styles/themes/components/popover.scss b/styles/themes/components/popover.scss
new file mode 100644
index 000000000..09effd38e
--- /dev/null
+++ b/styles/themes/components/popover.scss
@@ -0,0 +1,10 @@
+.rad-ui-popover {
+ background-color: var(--rad-ui-color-gray-50);
+ padding: 12px;
+ box-shadow: 0 10px 38px -10px #0e121659, 0 10px 20px -15px #0e121633;
+ border-radius: 8px;
+
+ .rad-ui-popover-arrow {
+ fill: var(--rad-ui-color-gray-50);
+ }
+}
diff --git a/styles/themes/default.scss b/styles/themes/default.scss
index bddf14cfc..c43caede3 100644
--- a/styles/themes/default.scss
+++ b/styles/themes/default.scss
@@ -40,6 +40,7 @@
@use "components/navigation-menu";
@use "components/dropdown-menu";
@use "components/number-field";
+@use "components/popover";
@use "components/context-menu";
@use "components/menubar";
@use "components/splitter";