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.5% of the 2,062 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/ caseroom/ r/ popover.json - Astro
- npx shadcn@
latest add https:// www.themes.dev/ caseroom/ r/ astro/ popover.json
Or copy the files below into your project. They need clsx, tailwind-merge, and the Caseroom theme. The HTML needs only the compiled CSS and the script.
// 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 {};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>
);
}/** 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';---
import { cn } from '@/lib/utils';
import { buttonVariants, type ButtonVariants } from '../button/button.variants';
import { CHEVRON_PATH, popover } from './popover.variants';
interface Props {
/** 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'];
class?: string;
}
const { id, label, title, align = 'start', variant = 'outline', class: className } = Astro.props;
---
<div class={cn(popover.root, className)}>
<button type="button" popovertarget={id} aria-haspopup="dialog" aria-expanded="false" class={cn(buttonVariants({ variant }), 'group')} style={`anchor-name: --${id}`}>
{label}
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class={popover.chevron}><path d={CHEVRON_PATH} /></svg>
</button>
<div id={id} popover="auto" role="dialog" aria-labelledby={`${id}-title`} tabindex="-1" data-align={align} class={popover.content} style={`position-anchor: --${id}`}>
<p id={`${id}-title`} class={popover.title}>{title}</p>
<div class={popover.body}><slot /></div>
</div>
</div>
<script>
import './popover.client';
</script>// 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 {};/** 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';<div class="flex min-h-80 flex-wrap items-start justify-center gap-3">
<div class="inline-block">
<button type="button" popovertarget="ex-share" aria-haspopup="dialog" aria-expanded="false" class="inline-flex shrink-0 cursor-pointer items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-[color,background-color,border-color,transform] select-none active:scale-[0.97] aria-busy:cursor-progress disabled:pointer-events-none disabled:opacity-50 aria-busy:disabled:opacity-80 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 border border-input bg-background hover:bg-accent hover:text-accent-foreground active:bg-pressed h-9 px-4 group" style="anchor-name: --ex-share">
Share
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="size-3.5 text-muted-foreground transition-transform duration-150 group-aria-expanded:rotate-180"><path d="M4 6l4 4 4-4"></path></svg>
</button>
<div id="ex-share" popover="auto" role="dialog" aria-labelledby="ex-share-title" tabindex="-1" data-align="start" class="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)]" style="position-anchor: --ex-share">
<p id="ex-share-title" class="text-sm font-semibold">Share the preview</p>
<div class="mt-1 text-sm text-pretty text-muted-foreground">
<p>Anyone with the link can view this deploy until it expires.</p>
<div class="mt-3 grid gap-2">
<label class="flex items-center gap-2 text-sm leading-none font-medium has-[input]:items-start has-[input]:leading-snug has-[input]:[&>*:first-child]:mt-px select-none peer-disabled:cursor-not-allowed peer-disabled:opacity-50 text-foreground" for="ex-share-link">
Link
</label>
<input type="text" class="w-full min-w-0 rounded-md border border-input bg-background px-3 text-sm text-foreground transition-colors placeholder:text-muted-foreground enabled:hover:border-foreground/85 enabled:active:bg-muted aria-invalid:enabled:hover:border-destructive aria-invalid:enabled:hover:bg-[color-mix(in_oklab,var(--destructive)_6%,var(--background))] aria-invalid:enabled:active:bg-[color-mix(in_oklab,var(--destructive)_10%,var(--background))] focus-visible:border-ring focus-visible:outline-2 focus-visible:outline-offset-0 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:outline-destructive h-9 py-1 file:mr-3 file:border-0 file:bg-transparent file:text-sm file:font-medium" id="ex-share-link" value="northwind-4f2a.preview.dev" readonly>
</div>
<button type="button" class="inline-flex shrink-0 cursor-pointer items-center justify-center gap-2 rounded-md font-medium whitespace-nowrap transition-[color,background-color,border-color,transform] select-none active:scale-[0.97] aria-busy:cursor-progress disabled:pointer-events-none disabled:opacity-50 aria-busy:disabled:opacity-80 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 bg-primary text-primary-foreground hover:bg-[color-mix(in_oklab,var(--primary)_var(--hover-amount),var(--background))] active:bg-[color-mix(in_oklab,var(--primary)_var(--press-amount),var(--press-mix))] h-8 px-3 text-[13px] mt-3 w-full">
Copy link
</button>
</div>
</div>
</div>
<div class="inline-block">
<button type="button" popovertarget="ex-info" aria-haspopup="dialog" aria-expanded="false" class="inline-flex shrink-0 cursor-pointer items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-[color,background-color,border-color,transform] select-none active:scale-[0.97] aria-busy:cursor-progress disabled:pointer-events-none disabled:opacity-50 aria-busy:disabled:opacity-80 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 hover:bg-accent hover:text-accent-foreground active:bg-pressed h-9 px-4 group" style="anchor-name: --ex-info">
Build details
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="size-3.5 text-muted-foreground transition-transform duration-150 group-aria-expanded:rotate-180"><path d="M4 6l4 4 4-4"></path></svg>
</button>
<div id="ex-info" popover="auto" role="dialog" aria-labelledby="ex-info-title" tabindex="-1" data-align="end" class="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)]" style="position-anchor: --ex-info">
<p id="ex-info-title" class="text-sm font-semibold">Build 1842</p>
<div class="mt-1 text-sm text-pretty text-muted-foreground">
<p>Built from main in 48 seconds. Node 22, 214 files, 1.3 MB.</p>
</div>
</div>
</div>
</div><script type="module" src="/caseroom/r/html/popover.js"></script>