Skip to content
    Style: Wireframe

    Components · Overlays

    Popover

    A non-modal panel next to its trigger on the native popover: focus moves in and back, Escape and outside clicks close it.

    1.4% of the 2,073 sites in The UI Index have a dropdown (30 sites).

    Both previews render the same markup; the HTML tab below is that markup without a framework.

    Install

    React
    npx shadcn@latest add https://www.themes.dev/ui/r/popover.json
    Astro
    npx shadcn@latest add https://www.themes.dev/ui/r/astro/popover.json

    Or copy the files below into your project. They need clsx, tailwind-merge, and the themes.dev UI theme. The HTML needs only the compiled CSS and the script.

    popover.client.ts
    // Popover behaviour, shared by the Astro, React and HTML versions. The trigger has popovertarget and
    // aria-haspopup="dialog"; the panel is [popover][role=dialog]. On open: aria-expanded, placement where anchor
    // positioning is missing, and focus to [autofocus] or the first field or button inside, else the panel itself.
    // On close: focus back to the trigger when it was inside the panel.
    declare global {
      interface Window {
        __caseroomPopover?: boolean;
      }
    }
    
    const anchored = typeof CSS !== 'undefined' && CSS.supports('anchor-name: --a');
    
    function place(panel: HTMLElement, trigger: HTMLElement) {
      if (anchored) return;
      const t = trigger.getBoundingClientRect();
      const m = panel.getBoundingClientRect();
      const end = panel.dataset.align === 'end';
      const below = t.bottom + 8 + m.height <= innerHeight || t.top < m.height;
      panel.style.position = 'fixed';
      panel.style.top = `${below ? t.bottom + 8 : t.top - 8 - m.height}px`;
      panel.style.left = `${Math.max(8, Math.min(end ? t.right - m.width : t.left, innerWidth - m.width - 8))}px`;
    }
    
    if (typeof window !== 'undefined' && !window.__caseroomPopover) {
      window.__caseroomPopover = true;
      document.addEventListener(
        'toggle',
        (e) => {
          const panel = e.target as HTMLElement;
          if (!(panel instanceof HTMLElement) || panel.getAttribute('role') !== 'dialog' || !panel.hasAttribute('popover')) return;
          const trigger = document.querySelector<HTMLElement>(`[popovertarget="${CSS.escape(panel.id)}"]`);
          const open = (e as ToggleEvent).newState === 'open';
          trigger?.setAttribute('aria-expanded', String(open));
          if (open) {
            if (trigger) place(panel, trigger);
            const first = panel.querySelector<HTMLElement>('[autofocus], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), button:not([disabled]), a[href]');
            (first ?? panel).focus();
          } else if (panel.contains(document.activeElement) || document.activeElement === document.body) trigger?.focus();
        },
        true,
      );
    }
    
    export {};
    popover.tsx
    import type { CSSProperties, ReactNode } from 'react';
    import { cn } from '@/lib/utils';
    import { buttonVariants, type ButtonVariants } from '../button/button.variants';
    import './popover.client';
    import { CHEVRON_PATH, popover } from './popover.variants';
    
    export interface PopoverProps {
      /** Unique on the page: the panel's id. */
      id: string;
      /** The trigger's text. */
      label: string;
      /** The panel's title, also its accessible name. */
      title: string;
      align?: 'start' | 'end';
      variant?: ButtonVariants['variant'];
      className?: string;
      children?: ReactNode;
    }
    
    /** A non-modal dialog next to its trigger, on the native popover. Focus moves in on open and back on close. */
    export function Popover({ id, label, title, align = 'start', variant = 'outline', className, children }: PopoverProps) {
      return (
        <div className={cn(popover.root, className)}>
          <button type="button" popoverTarget={id} aria-haspopup="dialog" aria-expanded="false" className={cn(buttonVariants({ variant }), 'group')} style={{ anchorName: `--${id}` } as CSSProperties}>
            {label}
            <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className={popover.chevron}><path d={CHEVRON_PATH} /></svg>
          </button>
          <div id={id} popover="auto" role="dialog" aria-labelledby={`${id}-title`} tabIndex={-1} data-align={align} className={popover.content} style={{ positionAnchor: `--${id}` } as CSSProperties}>
            <p id={`${id}-title`} className={popover.title}>{title}</p>
            <div className={popover.body}>{children}</div>
          </div>
        </div>
      );
    }
    popover.variants.ts
    /** A non-modal panel next to its trigger, on the native popover: light dismiss, Escape, top layer. It is a dialog
     *  (role=dialog, named by its title): focus moves in when it opens and back to the trigger when it closes. Placed
     *  under the trigger with CSS anchor positioning (flipped when there is no room), by script where that is missing.
     *  It fades in; with reduced motion it only appears. */
    export const popover = {
      root: 'inline-block',
      content:
        'm-0 [inset:auto] w-72 max-w-[calc(100vw-1rem)] rounded-lg border bg-popover p-4 text-popover-foreground shadow-lg dark:shadow-[0_12px_32px_rgb(0_0_0/0.65)] opacity-0 transition-[opacity,display,overlay] transition-discrete duration-100 open:opacity-100 starting:open:opacity-0 supports-[anchor-name:--a]:mt-2 supports-[anchor-name:--a]:[top:anchor(bottom)] supports-[anchor-name:--a]:[left:anchor(left)] supports-[anchor-name:--a]:[position-try-fallbacks:flip-block,flip-inline] supports-[anchor-name:--a]:data-[align=end]:[left:auto] supports-[anchor-name:--a]:data-[align=end]:[right:anchor(right)]',
      title: 'text-sm font-semibold',
      body: 'mt-1 text-sm text-pretty text-muted-foreground',
      chevron: 'size-3.5 text-muted-foreground transition-transform duration-150 group-aria-expanded:rotate-180',
    };
    export const CHEVRON_PATH = 'M4 6l4 4 4-4';