Agents (llms.txt)
Octocat

Popover

A floating panel that is attached to a trigger element.

import { Button } from '@/components/button';
import { Popover } from '@/components/popover';

export default function PopoverPreview() {
  return (
    <Popover>
      <Popover.Trigger asChild>
        <Button variant="outline">Open Popover</Button>
      </Popover.Trigger>
      <Popover.Content>
        <p>This is the content of the popover.</p>
      </Popover.Content>
    </Popover>
  );
}

Dependencies

Source Code

import {
  autoUpdate,
  type FloatingContext,
  FloatingFocusManager,
  flip,
  hide,
  offset as offsetMiddleware,
  type Placement,
  shift,
  size,
  type UseFloatingOptions,
  type UseInteractionsReturn,
  useClick,
  useDismiss,
  useFloating,
  useInteractions,
  useMergeRefs,
  useRole,
  useTransitionStatus,
} from '@floating-ui/react';
import { MagnifyingGlassIcon } from '@phosphor-icons/react/dist/ssr';
import {
  createContext,
  use,
  useCallback,
  useEffect,
  useMemo,
  useState,
} from 'react';

import { Slot } from '@/components/slot';
import { useTopLayer } from '@/foundations/hooks/use-top-layer';
import { Spinner } from '@/components/spinner';
import { cn } from '@/lib/utils/classnames';

type PopoverOrigin = 'trigger' | 'pointer' | [number, number];

interface UsePopoverFloatingOptions {
  open?: boolean;
  onOpenChange?: UseFloatingOptions['onOpenChange'];
  placement?: Placement;
  offset?: number;
  origin?: PopoverOrigin;
  // Cross-axis fallbacks for `flip()`. Useful for nested menus on narrow
  // viewports where neither side has horizontal room — e.g. ['left-start',
  // 'bottom-start', 'top-start'] lets a `right-start` submenu fall back below
  // the trigger.
  flipFallbackPlacements?: Placement[];
  // FloatingTree integration — used by Menu for nested submenu coordination.
  nodeId?: string;
}

const usePopoverFloating = ({
  open: propsOpen,
  onOpenChange: propsOnOpenChange,
  placement = 'bottom',
  offset = 4,
  origin = 'trigger',
  flipFallbackPlacements,
  nodeId,
}: UsePopoverFloatingOptions) => {
  const [internalOpen, setInternalOpen] = useState(false);
  const open = propsOpen ?? internalOpen;

  const setOpen = useCallback<NonNullable<UseFloatingOptions['onOpenChange']>>(
    (open, event, reason) => {
      setInternalOpen(open);
      propsOnOpenChange?.(open, event, reason);
    },
    [propsOnOpenChange]
  );

  const floating = useFloating({
    nodeId,
    placement,
    open,
    onOpenChange: setOpen,
    // Pause `update()` while a text-input descendant of the floating element
    // has focus. iOS fires scroll/resize on `window.visualViewport` when the
    // soft keyboard opens — without this guard, those events trigger
    // `flip()` to re-evaluate against the shrunken viewport, the panel
    // re-renders with a new placement, and the focused input loses focus,
    // dismissing the keyboard in a loop. The listeners live in
    // `whileElementsMounted` so they're tied to floating-ui's lifecycle and
    // don't need a separate `useEffect`.
    whileElementsMounted: (reference, floatingEl, update) => {
      let paused = false;
      const isTextInput = (n: EventTarget | null) =>
        n instanceof HTMLElement &&
        (n.tagName === 'INPUT' ||
          n.tagName === 'TEXTAREA' ||
          n.isContentEditable);
      const onFocusIn = (e: FocusEvent) => {
        if (isTextInput(e.target)) paused = true;
      };
      const onFocusOut = (e: FocusEvent) => {
        if (isTextInput(e.target)) paused = false;
      };
      floatingEl.addEventListener('focusin', onFocusIn);
      floatingEl.addEventListener('focusout', onFocusOut);

      const cleanup = autoUpdate(
        reference,
        floatingEl,
        () => {
          if (paused) return;
          update();
        },
        { layoutShift: false }
      );

      return () => {
        cleanup();
        floatingEl.removeEventListener('focusin', onFocusIn);
        floatingEl.removeEventListener('focusout', onFocusOut);
      };
    },
    middleware: [
      flip({ padding: 8, fallbackPlacements: flipFallbackPlacements }),
      shift({ padding: 8 }),
      offsetMiddleware(offset),
      size({
        apply({ rects, elements, availableHeight }) {
          elements.floating.style.setProperty(
            '--max-height',
            `${availableHeight}px`
          );

          elements.floating.style.setProperty(
            '--width',
            `${rects.reference.width}px`
          );
        },
        padding: 4,
      }),
      hide(),
    ],
  });

  // When origin is an explicit [x, y], pin the floating element to that point
  // via Floating UI's virtual reference pattern. The trigger element stays the
  // interaction reference (focus, dismiss); only positioning changes. We don't
  // reset to null in the 'trigger' / 'pointer' branches: setPositionReference
  // is wired into Floating UI's lower-level setReference, and clearing it
  // after the trigger's ref callback has already registered would break
  // positioning entirely.
  useEffect(() => {
    if (Array.isArray(origin)) {
      const [x, y] = origin;
      floating.refs.setPositionReference({
        getBoundingClientRect: () => ({
          width: 0,
          height: 0,
          x,
          y,
          top: y,
          right: x,
          bottom: y,
          left: x,
        }),
      });
    }
  }, [origin, floating.refs]);

  return useMemo(
    () => ({
      open,
      setOpen,
      origin,
      ...floating,
    }),
    [open, setOpen, origin, floating]
  );
};

// Context

interface ContextType
  extends ReturnType<typeof usePopoverFloating>,
    UseInteractionsReturn {
  modal: boolean;
}

const PopoverContext = createContext<ContextType | null>(null);

const usePopoverContext = () => {
  const context = use(PopoverContext);

  if (context == null) {
    throw new Error('Popover components must be wrapped in <Popover />');
  }

  return context;
};

// Components

interface PopoverProps extends UsePopoverFloatingOptions {
  modal?: boolean;
  children: React.ReactNode;
}
/**
 * Popover allows you to open a floating panel that is attached to a trigger element.
 *
 * Set `modal` to `true` to focus trap the popover.
 *
 * @example
 * ```
 * <Popover>
 *   <Popover.Trigger>
 *     <Button>Open Popover</Button>
 *   </Popover.Trigger>
 *   <Popover.Content>
 *     <p>Popover Content</p>
 *   </Popover.Content>
 * </Popover>
 * ```
 */
const Popover = ({ children, modal = false, ...props }: PopoverProps) => {
  const floating = usePopoverFloating(props);

  const click = useClick(floating.context);
  const dismiss = useDismiss(floating.context);
  const role = useRole(floating.context);

  const interactions = useInteractions([click, dismiss, role]);

  const popoverContextValue = useMemo(
    () => ({
      ...floating,
      ...interactions,
      modal,
    }),
    [floating, interactions, modal]
  );

  return (
    <PopoverContext value={popoverContextValue}>{children}</PopoverContext>
  );
};

interface PopoverTriggerProps extends React.ComponentPropsWithRef<'button'> {
  asChild?: boolean;
}

/**
 * Will open the popover when clicked.
 *
 * Use `asChild` to render as your child element.
 *
 * @example
 * ```
 * <Popover.Trigger>
 *   <Button>Open Popover</Button>
 * </Popover.Trigger>
 *
 * <Popover.Trigger asChild={false}>
 *   Open Popover
 * </Popover.Trigger>
 * ```
 */
const PopoverTrigger = ({
  ref: refProp,
  children,
  asChild = false,
  className,
  ...props
}: PopoverTriggerProps) => {
  const context = usePopoverContext();
  const Comp = asChild ? Slot : 'button';

  const ref = useMergeRefs([context.refs.setReference, refProp]);
  const referenceProps = context.getReferenceProps(props);

  return (
    <Comp
      ref={ref}
      type={asChild ? undefined : 'button'}
      className={cn(!asChild && 'disabled:opacity-40', className)}
      data-state={context.open ? 'open' : 'closed'}
      {...referenceProps}
      onClick={(event: React.MouseEvent<HTMLButtonElement>) => {
        // Pointer mode: capture the click coordinates and pin the floating
        // panel there. Keyboard-triggered clicks have clientX/Y of 0 — we
        // skip in that case and let positioning fall back to the trigger.
        if (
          context.origin === 'pointer' &&
          !context.open &&
          event.clientX &&
          event.clientY
        ) {
          const x = event.clientX;
          const y = event.clientY;
          context.refs.setPositionReference({
            getBoundingClientRect: () => ({
              width: 0,
              height: 0,
              x,
              y,
              top: y,
              right: x,
              bottom: y,
              left: x,
            }),
          });
        }
        if (typeof referenceProps.onClick === 'function') {
          referenceProps.onClick(event);
        }
      }}
    >
      {children}
    </Comp>
  );
};

/**
 * Will render the popover content.
 *
 * @example
 * ```
 * <Popover.Content>
 *   <p>Popover Content</p>
 * </Popover.Content>
 * ```
 */
const PopoverContent = ({
  ref: refProp,
  className,
  children,
  ...props
}: React.ComponentPropsWithRef<'div'>) => {
  const { context, refs, getFloatingProps, modal, isPositioned } =
    usePopoverContext();

  const ref = useMergeRefs([refs.setFloating, refProp]);

  return (
    <PopoverPanel
      context={context}
      modal={modal}
      isPositioned={isPositioned}
      ref={ref}
      className={cn(
        'z-50 max-h-(--max-height) w-72 overflow-auto rounded-xl border border-border bg-background p-3 font-medium text-foreground shadow-lg outline-none',
        className
      )}
      {...getFloatingProps(props)}
    >
      {children}
    </PopoverPanel>
  );
};

interface PopoverPanelProps extends React.ComponentPropsWithRef<'div'> {
  context: FloatingContext;
  modal?: boolean;
  isPositioned?: boolean;
  initialFocus?: number | React.RefObject<HTMLElement | null>;
  returnFocus?: boolean;
  animate?: boolean;
}

/**
 *
 * PopoverPanel is the actual floating panel that will be positioned relative to the trigger.
 * It's exported for internal purposes only, to avoid duplicating the logic of positioning and transitions.
 * It is not part of the public API as it is included already in the PopoverContent component.
 * @returns
 */
const PopoverPanel = ({
  ref,
  context,
  modal,
  isPositioned = true,
  initialFocus,
  returnFocus,
  animate = true,
  className,
  style,
  ...props
}: PopoverPanelProps) => {
  const { isMounted, status } = useTransitionStatus(context, {
    duration: animate ? 150 : 0,
  });
  const topLayerRef = useTopLayer<HTMLDivElement>(isMounted);

  const mergedRef = useMergeRefs([ref, topLayerRef]);

  if (!isMounted) return null;

  // Hide until floating-ui has computed the position. Otherwise the panel
  // renders at (0, 0) on the first frame and FloatingFocusManager's autofocus
  // makes the browser scroll the document toward that point before the real
  // position is applied.
  const hidden = !isPositioned || context.middlewareData.hide?.referenceHidden;

  return (
    <FloatingFocusManager
      context={context}
      modal={modal}
      initialFocus={initialFocus}
      returnFocus={returnFocus}
    >
      <div
        ref={mergedRef}
        data-state={['open', 'initial'].includes(status) ? 'open' : 'closed'}
        data-side={context.placement.split('-')[0]}
        className={cn(
          animate && [
            'origin-(--transform-origin) transition duration-300 ease-out',
            'data-[state=closed]:data-[side=left]:translate-x-2 data-[state=closed]:data-[side=right]:-translate-x-2 data-[state=closed]:data-[side=bottom]:-translate-y-2 data-[state=closed]:data-[side=top]:translate-y-2',
            'data-[state=closed]:scale-95 data-[state=closed]:opacity-0 data-[state=closed]:duration-150',
            'data-[state=open]:translate-x-0 data-[state=open]:translate-y-0 data-[state=open]:scale-100',
          ],
          className
        )}
        style={{
          position: context.strategy,
          top: context.y ?? 0,
          left: context.x ?? 0,
          '--transform-origin': placementToTransformOrigin(context.placement),
          visibility: hidden ? 'hidden' : 'visible',
          ...style,
        }}
        {...props}
      />
    </FloatingFocusManager>
  );
};

// ugly and verbose but easy to reason about and maintain
const placementToTransformOrigin = (placement: Placement) => {
  switch (placement) {
    case 'top':
      return 'bottom';
    case 'bottom':
      return 'top';
    case 'left':
      return 'right';
    case 'right':
      return 'left';
    case 'top-start':
      return 'bottom left';
    case 'top-end':
      return 'bottom right';
    case 'bottom-start':
      return 'top left';
    case 'bottom-end':
      return 'top right';
    case 'left-start':
      return 'right top';
    case 'left-end':
      return 'right bottom';
    case 'right-start':
      return 'left top';
    case 'right-end':
      return 'left bottom';
  }
};

interface PopoverCloseProps extends React.ComponentPropsWithRef<'button'> {
  asChild?: boolean;
}

/**
 * Will close the popover when clicked.
 *
 * Useful to dismiss the popover from within (e.g.: popover with a form and a cancel button).
 *
 * Use `asChild` to render as your child element.
 *
 * @example
 * ```
 * <Popover>
 *   <Popover.Trigger>
 *     <Button>Open Popover</Button>
 *   </Popover.Trigger>
 *   <Popover.Content>
 *     <Popover.Close>
 *       <Button>Cancel</Button>
 *     </Popover.Close>
 *   </Popover.Content>
 * </Popover>
 * ```
 */
const PopoverClose = ({
  asChild = false,
  children,
  ...props
}: PopoverCloseProps) => {
  const { setOpen } = usePopoverContext();
  const Comp = asChild ? Slot : 'button';

  return (
    <Comp
      {...props}
      onClick={(event: React.MouseEvent<HTMLElement>) => {
        props.onClick?.(event as React.MouseEvent<HTMLButtonElement>);
        setOpen(false);
      }}
    >
      {children}
    </Comp>
  );
};

interface PopoverSearchInputProps extends React.ComponentPropsWithRef<'input'> {
  isLoading?: boolean;
}

const PopoverSearchInput = ({
  className,
  isLoading,
  ...props
}: PopoverSearchInputProps) => {
  return (
    <div className="relative flex items-center rounded-t-lg border-border border-b bg-transparent">
      {isLoading ? (
        <Spinner size="sm" className="absolute left-4 text-foreground" />
      ) : (
        <MagnifyingGlassIcon
          weight="bold"
          className="absolute left-4 size-4 shrink-0 text-foreground"
        />
      )}
      <input
        className={cn(
          'h-10 w-full border-0 bg-transparent p-4 pl-10 font-medium text-base outline-none transition-colors placeholder:text-foreground-secondary focus:ring-0',
          className
        )}
        {...props}
      />
    </div>
  );
};

const PopoverEmpty = ({
  children,
  className,
  ...props
}: React.ComponentPropsWithRef<'div'>) => {
  return (
    <div
      className={cn(
        'my-4 text-center text-base text-foreground-secondary',
        className
      )}
      {...props}
    >
      {children}
    </div>
  );
};

const CompoundPopover = Object.assign(Popover, {
  Trigger: PopoverTrigger,
  Content: PopoverContent,
  Close: PopoverClose,
  SearchInput: PopoverSearchInput,
  Empty: PopoverEmpty,
  Panel: PopoverPanel,
});

export type { PopoverOrigin };
export {
  CompoundPopover as Popover,
  PopoverContext,
  usePopoverContext,
  // internal use only
  usePopoverFloating,
};

Features

  • Smart Positioning: Automatically adjusts position to stay in view
  • Focus Management: Optional modal mode with focus trapping
  • Search Support: Built-in search input component
  • Empty States: Dedicated component for empty state messages
  • Flexible Triggers: Support for custom trigger elements

Anatomy


          <Popover>
  <Popover.Trigger />
  <Popover.Content>
    <Popover.Close />
    <Popover.SearchInput />
    <Popover.Empty />
  </Popover.Content>
</Popover>
        

API Reference

Popover

Prop Default Type Description
open - boolean Whether the popover is open.
onOpenChange - (open: boolean) => void Callback fired when the open state changes.
placement "bottom" Placement The placement of the popover relative to its trigger.
offset 4 number The distance between the popover and its trigger.
origin "trigger" "trigger" | "pointer" | [number, number] How the popover is anchored. `"trigger"` (default) anchors to the trigger element. `"pointer"` captures the click coordinates on the trigger and anchors there. A `[clientX, clientY]` tuple anchors to that explicit point — useful for right-click context menus.
modal false boolean Whether to trap focus inside the popover.

Popover.Trigger

Extends the button element.

Prop Default Type Description
asChild - boolean Whether to render the trigger as its child element.

Popover.Content

Extends the div element.

The content will be rendered in a portal and will be positioned relative to the trigger.

Popover.Close

Extends the button element.

Prop Default Type Description
asChild - boolean Whether to render the close button as its child element.

Popover.SearchInput

Extends the input element.

A styled input with a search icon, useful for filtering content inside the popover.

Prop Default Type Description
isLoading false boolean When true, replaces the leading magnifying-glass icon with a spinner. Useful for async-filtered content.

Popover.Empty

Extends the div element.

A styled container for empty state messages.

Examples

Simple

Basic usage of the popover component.

import { Button } from '@/components/button';
import { Popover } from '@/components/popover';

export default function PopoverPreview() {
  return (
    <Popover>
      <Popover.Trigger asChild>
        <Button variant="outline">Open Popover</Button>
      </Popover.Trigger>
      <Popover.Content>
        <p>This is the content of the popover.</p>
      </Popover.Content>
    </Popover>
  );
}

Placement

Controlling the position of the popover relative to its trigger.

import { Button } from '@/components/button';
import { Popover } from '@/components/popover';

export default function PopoverPlacementPreview() {
  return (
    <div className="flex flex-wrap items-center justify-center gap-4">
      <Popover placement="top">
        <Popover.Trigger asChild>
          <Button variant="outline">Top</Button>
        </Popover.Trigger>
        <Popover.Content>
          <p>This popover appears on top.</p>
        </Popover.Content>
      </Popover>

      <Popover placement="bottom">
        <Popover.Trigger asChild>
          <Button variant="outline">Bottom</Button>
        </Popover.Trigger>
        <Popover.Content>
          <p>This popover appears at the bottom.</p>
        </Popover.Content>
      </Popover>

      <Popover placement="left">
        <Popover.Trigger asChild>
          <Button variant="outline">Left</Button>
        </Popover.Trigger>
        <Popover.Content>
          <p>This popover appears on the left.</p>
        </Popover.Content>
      </Popover>

      <Popover placement="right">
        <Popover.Trigger asChild>
          <Button variant="outline">Right</Button>
        </Popover.Trigger>
        <Popover.Content>
          <p>This popover appears on the right.</p>
        </Popover.Content>
      </Popover>
    </div>
  );
}

Anchored to the cursor

Set origin="pointer" to open the popover at the position where the trigger was clicked instead of next to the trigger element. For right-click context menus, pass origin={[clientX, clientY]} directly.

import { Button } from '@/components/button';
import { Popover } from '@/components/popover';

export default function PopoverPointerPreview() {
  return (
    <Popover origin="pointer">
      <Popover.Trigger asChild>
        <Button variant="outline">Click anywhere on me</Button>
      </Popover.Trigger>
      <Popover.Content className="w-min whitespace-nowrap">
        <p>Here I am</p>
      </Popover.Content>
    </Popover>
  );
}

A modal popover will trap focus inside, making it ideal for forms and other interactive content.

import { Button } from '@/components/button';
import { Popover } from '@/components/popover';

export default function PopoverModalPreview() {
  return (
    <Popover modal>
      <Popover.Trigger asChild>
        <Button variant="outline">Open Modal Popover</Button>
      </Popover.Trigger>
      <Popover.Content className="flex flex-col gap-4">
        <div>
          <h3 className="mb-1 font-medium text-sm">This is a modal popover</h3>
          <p className="text-foreground-secondary text-sm">
            It will trap focus inside. Very useful for popovers with advanced
            interactions inside (like forms)
          </p>
        </div>
        <div className="flex items-center gap-2">
          <Popover.Close asChild>
            <Button variant="outline" type="button">
              Cancel
            </Button>
          </Popover.Close>
          <Button type="submit">Submit</Button>
        </div>
      </Popover.Content>
    </Popover>
  );
}

Custom Width

Example showing how to customize the popover width.

import { Button } from '@/components/button';
import { Popover } from '@/components/popover';

export default function PopoverCustomWidthPreview() {
  return (
    <Popover>
      <Popover.Trigger asChild>
        <Button variant="outline">Custom Width</Button>
      </Popover.Trigger>
      <Popover.Content className="w-96">
        <p>This popover has a custom width of 24rem (w-96).</p>
        <p className="mt-2 text-foreground-secondary text-sm">
          You can customize the width of the popover by adding a width utility
          class to the Popover.Content component.
        </p>
      </Popover.Content>
    </Popover>
  );
}

Search Input

Using the built-in search input for filtering content.

import { Button } from '@/components/button';
import { Popover } from '@/components/popover';

export default function PopoverSearchPreview() {
  return (
    <Popover>
      <Popover.Trigger asChild>
        <Button variant="outline">Search</Button>
      </Popover.Trigger>
      <Popover.Content className="p-0">
        <Popover.SearchInput placeholder="Search items..." />
        <div className="p-1">Items would go here</div>
      </Popover.Content>
    </Popover>
  );
}

Empty State

Displaying a message when no content is available.

import { Button } from '@/components/button';
import { Popover } from '@/components/popover';

export default function PopoverEmptyPreview() {
  return (
    <Popover>
      <Popover.Trigger asChild>
        <Button variant="outline">Empty State</Button>
      </Popover.Trigger>
      <Popover.Content className="p-0">
        <Popover.SearchInput placeholder="Search items..." />
        <Popover.Empty>No items found</Popover.Empty>
      </Popover.Content>
    </Popover>
  );
}

Best Practices

  1. Positioning:

    • Consider available screen space
    • Adjust offset based on content
    • Test on different screen sizes
  2. Focus Management:

    • Use modal mode for complex interactions
    • Ensure keyboard navigation works
    • Provide clear focus indicators
  3. Content:

    • Keep content concise
    • Use appropriate width for content
    • Consider mobile interactions

Previous

Pagination

Next

Portal