Popover
ui
Preview
Filter
Tag and status.
<button popovertarget="ex-popover" class=${buttonClass({ variant: 'outline' })}>Open popover</button>
<div popover id="ex-popover" class=${popoverContentClass({ side: 'bottom', align: 'start', sideOffset: 4 })}>
<div class=${popoverHeaderClass()}>
<h3 class=${popoverTitleClass()}>Filter</h3>
<p class=${popoverDescriptionClass()}>Tag and status.</p>
</div>
<div class="${stackClass({ gap: 'sm' })} mt-3">
<label class=${labelClass()}>Status</label>
<select class=${nativeSelectClass()}><option>Open</option><option>Closed</option></select>
</div>
</div>Installation
webjs ui add popoverAPI Reference
Parts
| Name | Description |
|---|---|
<button popovertarget="id"> |
Invoker. The browser toggles the matching popover on click and restores focus on close. |
<div popover id="id"> |
Floating panel. Native UA hides it until the invoker fires. |
popoverContentClass({ side, align, sideOffset }) |
Applied to the popover element. Same shape as shadcn `<PopoverContent>` placement props, side / align / sideOffset are encoded into CSS Anchor Positioning utilities. |
popoverHeaderClass() / TitleClass() / DescriptionClass() |
Class helpers for prose inside the content. |
positionFloating(trigger, content, opts) |
Imperative positioning utility for callers that cannot yet rely on CSS anchor positioning. Also used internally by tooltip / hover-card / dropdown-menu. |
Props
| Prop | Type | Default | Description |
|---|---|---|---|
side |
"top" | "right" | "bottom" | "left" |
"bottom" | Argument to popoverContentClass. Encoded via CSS `position-area`. |
align |
"start" | "center" | "end" |
"center" | Argument to popoverContentClass. Encoded via CSS `position-area`. |
sideOffset |
0 | 2 | 4 | 6 | 8 | 12 | 16 | 20 | 24 |
4 | Argument to popoverContentClass. Pixels between the trigger and the popover. For other values, override margin-{top|bottom|left|right} via inline style. |
alignOffset |
0 | 2 | 4 | 6 | 8 | 12 | 16 | 20 | 24 |
0 | Argument to popoverContentClass. Pixels offset along the align axis (no-op for align="center"). Emitted as a Tailwind translate utility. |
popover |
"auto" | "manual" (HTML attribute) |
"auto" | Auto gets light dismiss + Escape close for free. Manual is JS-driven. |
popovertarget |
string (HTML attribute on the invoker) |
id of the popover element to toggle. Also wires the invoker as the implicit anchor, no anchor-name / position-anchor needed for the common case. | |
anchor-name / position-anchor |
CSS properties (inline style, optional) |
Only needed when you have multiple invokers for the same popover or want to anchor to a different element than the invoker. |
Source: components/ui/popover.ts
/**
* Popover: floating panel anchored to a trigger button, built on the
* native HTML Popover API (`popover` attribute + `popovertarget`).
*
* Tier-1 (no custom element). The browser handles open / close state,
* top-layer rendering, light-dismiss on outside click (with
* `popover="auto"`), Escape-to-close, focus restoration to the invoker,
* and CSS Anchor Positioning's implicit anchor (a popover invoked via
* `popovertarget` is automatically anchored to its invoker, no
* `anchor-name` / `position-anchor` inline style needed for the common
* case). CSS Anchor Positioning ships in Chrome 125+, Safari 26+,
* Firefox 134+.
*
* shadcn parity:
* Popover → <button popovertarget="id"> + <div id="id" popover>
* PopoverContent → popoverContentClass({ side, align, sideOffset, alignOffset })
* PopoverHeader → popoverHeaderClass()
* PopoverTitle → popoverTitleClass()
* PopoverDescription → popoverDescriptionClass()
*
* The `positionFloating` helper is also exported (used by the Tier-2
* tooltip / hover-card / dropdown-menu components for imperative
* positioning where CSS Anchor Positioning isn't enough). Migrated from
* the prior <ui-popover> custom element set.
*
* Design tokens used: --popover, --popover-foreground, --border.
*
* @example
* ```html
* <!-- Single invoker, implicit anchor, zero inline style. -->
* <button popovertarget="filter" class=${buttonClass({ variant: 'outline' })}>Filter</button>
* <div id="filter" popover
* class=${popoverContentClass({ side: 'bottom', align: 'start', sideOffset: 4 })}>
* <div class=${popoverHeaderClass()}>
* <h3 class=${popoverTitleClass()}>Filter posts</h3>
* <p class=${popoverDescriptionClass()}>By tag and status.</p>
* </div>
* </div>
*
* <!-- Explicit anchor, for multiple invokers or anchoring to a different element. -->
* <span style="anchor-name: --picker">@vivek</span>
* <button popovertarget="profile">Show</button>
* <div id="profile" popover style="position-anchor: --picker"
* class=${popoverContentClass({ side: 'bottom' })}>
* <div class=${popoverHeaderClass()}>
* <h3 class=${popoverTitleClass()}>Vivek Khandelwal</h3>
* <p class=${popoverDescriptionClass()}>@vivek</p>
* </div>
* </div>
* ```
*/
// --------------------------------------------------------------------------
// Class helpers
// --------------------------------------------------------------------------
/**
* Popover content options, mirror shadcn's `<PopoverContent>` props.
* `side` and `align` map to CSS Anchor Positioning's `position-area`;
* `sideOffset` maps to a directional margin so the popover sits a few
* pixels off the anchor's edge.
*
* `sideOffset` is restricted to a discrete set so Tailwind 4's static
* scanner sees each emitted class literal. For arbitrary values,
* override the margin via inline style on the popover element.
*/
export type PopoverSideOffset = 0 | 2 | 4 | 6 | 8 | 12 | 16 | 20 | 24;
export type PopoverAlignOffset = 0 | 2 | 4 | 6 | 8 | 12 | 16 | 20 | 24;
export interface PopoverContentOptions {
/** Which side of the trigger the popover appears on. Default 'bottom'. */
side?: PopoverSide;
/** Alignment along the chosen side. Default 'center'. */
align?: PopoverAlign;
/** Pixels between the trigger and the popover. Default 4 (shadcn default). */
sideOffset?: PopoverSideOffset;
/**
* Pixels offset along the align axis. Positive values push away from
* the aligned edge toward the opposite edge. No-op for align='center'.
* Default 0 (matches shadcn).
*/
alignOffset?: PopoverAlignOffset;
}
// position-area combinations baked as literal class strings so Tailwind 4's
// scanner generates CSS for every (side, align) pair the helper can return.
// Underscores become spaces in arbitrary CSS values.
const POSITION_AREA_CLASS: Record<string, string> = {
'top-start': '[position-area:top_span-right]',
'top-center': '[position-area:top]',
'top-end': '[position-area:top_span-left]',
'bottom-start': '[position-area:bottom_span-right]',
'bottom-center': '[position-area:bottom]',
'bottom-end': '[position-area:bottom_span-left]',
'left-start': '[position-area:left_span-bottom]',
'left-center': '[position-area:left]',
'left-end': '[position-area:left_span-top]',
'right-start': '[position-area:right_span-bottom]',
'right-center': '[position-area:right]',
'right-end': '[position-area:right_span-top]',
};
// Align-axis translate classes. For align='start', positive offset moves
// the popover AWAY from the start edge (toward the end edge); align='end'
// reverses. align='center' is a no-op. The axis is perpendicular to the
// side: top/bottom sides translate X; left/right sides translate Y.
// All 36 (4 axis-direction combos × 9 offset values) appear literally so
// Tailwind 4 generates each class.
const ALIGN_OFFSET_CLASS: Record<string, Record<PopoverAlignOffset, string>> = {
'horizontal-start': {
0: 'translate-x-[0px]', 2: 'translate-x-[2px]', 4: 'translate-x-[4px]',
6: 'translate-x-[6px]', 8: 'translate-x-[8px]', 12: 'translate-x-[12px]',
16: 'translate-x-[16px]', 20: 'translate-x-[20px]', 24: 'translate-x-[24px]',
},
'horizontal-end': {
0: 'translate-x-[0px]', 2: 'translate-x-[-2px]', 4: 'translate-x-[-4px]',
6: 'translate-x-[-6px]', 8: 'translate-x-[-8px]', 12: 'translate-x-[-12px]',
16: 'translate-x-[-16px]', 20: 'translate-x-[-20px]', 24: 'translate-x-[-24px]',
},
'vertical-start': {
0: 'translate-y-[0px]', 2: 'translate-y-[2px]', 4: 'translate-y-[4px]',
6: 'translate-y-[6px]', 8: 'translate-y-[8px]', 12: 'translate-y-[12px]',
16: 'translate-y-[16px]', 20: 'translate-y-[20px]', 24: 'translate-y-[24px]',
},
'vertical-end': {
0: 'translate-y-[0px]', 2: 'translate-y-[-2px]', 4: 'translate-y-[-4px]',
6: 'translate-y-[-6px]', 8: 'translate-y-[-8px]', 12: 'translate-y-[-12px]',
16: 'translate-y-[-16px]', 20: 'translate-y-[-20px]', 24: 'translate-y-[-24px]',
},
};
// Per-side offset margin classes. Side 'bottom' wants margin-top, etc.
// Each value of PopoverSideOffset appears literally for Tailwind's scanner.
const MARGIN_OFFSET_CLASS: Record<PopoverSide, Record<PopoverSideOffset, string>> = {
top: {
0: '[margin-bottom:0px]', 2: '[margin-bottom:2px]', 4: '[margin-bottom:4px]',
6: '[margin-bottom:6px]', 8: '[margin-bottom:8px]', 12: '[margin-bottom:12px]',
16: '[margin-bottom:16px]', 20: '[margin-bottom:20px]', 24: '[margin-bottom:24px]',
},
bottom: {
0: '[margin-top:0px]', 2: '[margin-top:2px]', 4: '[margin-top:4px]',
6: '[margin-top:6px]', 8: '[margin-top:8px]', 12: '[margin-top:12px]',
16: '[margin-top:16px]', 20: '[margin-top:20px]', 24: '[margin-top:24px]',
},
left: {
0: '[margin-right:0px]', 2: '[margin-right:2px]', 4: '[margin-right:4px]',
6: '[margin-right:6px]', 8: '[margin-right:8px]', 12: '[margin-right:12px]',
16: '[margin-right:16px]', 20: '[margin-right:20px]', 24: '[margin-right:24px]',
},
right: {
0: '[margin-left:0px]', 2: '[margin-left:2px]', 4: '[margin-left:4px]',
6: '[margin-left:6px]', 8: '[margin-left:8px]', 12: '[margin-left:12px]',
16: '[margin-left:16px]', 20: '[margin-left:20px]', 24: '[margin-left:24px]',
},
};
/**
* Popover content class. `side` and `align` cover the shadcn
* `<PopoverContent>` placement props; `sideOffset` sets the gap to
* the anchor. The visual layer (border, bg, padding, shadow) is
* fixed to match shadcn's default; width is opinionated at `w-72`
* (override at the call site).
*
* `m-0` clears the UA `margin: auto` so anchor positioning isn't
* fighting auto-centering, then a single directional margin sets the
* sideOffset gap.
*/
export function popoverContentClass(opts: PopoverContentOptions = {}): string {
const side = opts.side ?? 'bottom';
const align = opts.align ?? 'center';
const sideOffset = opts.sideOffset ?? 4;
const alignOffset = opts.alignOffset ?? 0;
// align='center' has no align axis to offset along, skip the translate.
let alignClass = '';
if (align !== 'center' && alignOffset !== 0) {
const axis = side === 'top' || side === 'bottom' ? 'horizontal' : 'vertical';
alignClass = ALIGN_OFFSET_CLASS[`${axis}-${align}`][alignOffset];
}
return [
'w-72 m-0 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-hidden',
POSITION_AREA_CLASS[`${side}-${align}`],
MARGIN_OFFSET_CLASS[side][sideOffset],
alignClass,
].filter(Boolean).join(' ');
}
export const popoverHeaderClass = (): string => 'flex flex-col gap-1 text-sm';
export const popoverTitleClass = (): string => 'font-medium';
export const popoverDescriptionClass = (): string => 'text-muted-foreground';
// --------------------------------------------------------------------------
// Imperative positioning helper. Still exported for the tier-2 tooltip /
// hover-card / dropdown-menu components, which need exact placement before
// CSS anchor positioning is universally available.
// --------------------------------------------------------------------------
export type PopoverSide = 'top' | 'bottom' | 'left' | 'right';
export type PopoverAlign = 'start' | 'center' | 'end';
export function positionFloating(
trigger: HTMLElement,
content: HTMLElement,
opts: {
side?: PopoverSide;
align?: PopoverAlign;
sideOffset?: number;
alignOffset?: number;
} = {},
): void {
const side = opts.side ?? 'bottom';
const align = opts.align ?? 'center';
const sideOffset = opts.sideOffset ?? 4;
const alignOffset = opts.alignOffset ?? 0;
const tr = trigger.getBoundingClientRect();
const cr = content.getBoundingClientRect();
const vw = window.innerWidth;
const vh = window.innerHeight;
let top = 0;
let left = 0;
let actualSide = side;
const fitsBottom = tr.bottom + sideOffset + cr.height <= vh;
const fitsTop = tr.top - sideOffset - cr.height >= 0;
const fitsRight = tr.right + sideOffset + cr.width <= vw;
const fitsLeft = tr.left - sideOffset - cr.width >= 0;
if (side === 'bottom' && !fitsBottom && fitsTop) actualSide = 'top';
else if (side === 'top' && !fitsTop && fitsBottom) actualSide = 'bottom';
else if (side === 'right' && !fitsRight && fitsLeft) actualSide = 'left';
else if (side === 'left' && !fitsLeft && fitsRight) actualSide = 'right';
if (actualSide === 'bottom') top = tr.bottom + sideOffset;
else if (actualSide === 'top') top = tr.top - sideOffset - cr.height;
else if (actualSide === 'right' || actualSide === 'left') {
if (align === 'start') top = tr.top;
else if (align === 'end') top = tr.bottom - cr.height;
else top = tr.top + (tr.height - cr.height) / 2;
}
if (actualSide === 'right') left = tr.right + sideOffset;
else if (actualSide === 'left') left = tr.left - sideOffset - cr.width;
else {
if (align === 'start') left = tr.left;
else if (align === 'end') left = tr.right - cr.width;
else left = tr.left + (tr.width - cr.width) / 2;
}
// alignOffset shifts the popover along the align axis. For align='start',
// positive shifts AWAY from the start edge; align='end' reverses; center
// is a no-op. Axis is perpendicular to the chosen side.
if (align !== 'center' && alignOffset !== 0) {
const dir = align === 'start' ? 1 : -1;
if (actualSide === 'top' || actualSide === 'bottom') {
left += dir * alignOffset;
} else {
top += dir * alignOffset;
}
}
left = Math.max(8, Math.min(left, vw - cr.width - 8));
top = Math.max(8, Math.min(top, vh - cr.height - 8));
content.style.top = `${top}px`;
content.style.left = `${left}px`;
content.setAttribute('data-side', actualSide);
content.setAttribute('data-align', align);
}