Popover
Popover is Klean UI's non-modal floating surface. The browser owns top-layer display, the native popovertarget relationship, light dismissal, and Escape behavior. Klean fills the remaining gaps: collision-aware placement, an older-browser fallback, reliable focus return, and framework-native observable state.
The application owns the truthful content and every visual decision. There is no PopoverTrigger, asChild, triggerClass, visual variant, provider, or theme object.
Installation
Run the same command in Vue, React, or Svelte. Klean detects the framework and conventional destination, writes one source file, and installs its direct dependencies.
Run one command from a Boring Stack application. Klean detects the framework and conventional destination, then adds the framework-native source and its direct dependencies.
npx klean-ui add popover- No initializer or configuration file
- No framework, alias, or theme questions
- No Klean runtime dependency
@floating-ui/dom performs geometry only: logical placement, collision flipping, viewport shifting, and position updates. It is not a component runtime or styling system.
Usage
Vue
<script setup>
import Button from '~/components/ui/button/Button.vue'
import Popover from '~/components/ui/popover/Popover.vue'
</script>
<template>
<Button popovertarget="filters">Filters</Button>
<Popover id="filters" class="w-72">
<section aria-labelledby="filters-title">
<h2 id="filters-title" class="font-semibold">Visible records</h2>
<p class="mt-1 text-sm text-gray-600">
Choose which records appear in this view.
</p>
<label class="mt-4 flex items-center gap-3 text-sm">
<input type="checkbox" checked class="size-4 accent-gray-950" />
Active projects
</label>
</section>
</Popover>
</template>
React
import Button from '~/components/ui/button/Button.jsx'
import Popover from '~/components/ui/popover/Popover.jsx'
export default function Filters() {
return (
<>
<Button popoverTarget="filters">Filters</Button>
<Popover id="filters" className="w-72">
{({ close }) => (
<section aria-labelledby="filters-title">
<h2 id="filters-title" className="font-semibold">
Visible records
</h2>
<p className="mt-1 text-sm leading-6 text-gray-600">
Choose which records appear in this view.
</p>
<Button onClick={close} className="mt-5 w-full">
Done
</Button>
</section>
)}
</Popover>
</>
)
}
Svelte
<script>
import Button from '$lib/components/ui/button/Button.svelte'
import Popover from '$lib/components/ui/popover/Popover.svelte'
</script>
{#snippet content({ close })}
<section aria-labelledby="filters-title">
<h2 id="filters-title" class="font-semibold">Visible records</h2>
<p class="mt-1 text-sm leading-6 text-gray-600">
Choose which records appear in this view.
</p>
<Button onclick={close} class="mt-5 w-full">Done</Button>
</section>
{/snippet}
<Button popovertarget="filters">Filters</Button>
<Popover id="filters" class="w-72" children={content} />
popovertarget and id are native HTML. A native button works too:
<button type="button" popovertarget="filters">Filters</button>Use a real button because opening interface content is an action. An anchor remains navigation and should not become a Popover invoker merely because it can be styled like a button.
API
| Input | Default | Purpose |
|---|---|---|
id | generated | Native target identifier. Supply a stable value when a button invokes the Popover. |
placement | bottom-start | Preferred logical placement. It may flip or shift to remain visible. |
offset | 8 | Pixel distance between the invoker and surface. |
| framework open binding | uncontrolled | Observe or control visibility only when application behavior genuinely needs it. |
defaultOpen | false | Initial uncontrolled state, useful for composition and testing. |
class / className | — | Ordinary Tailwind classes merged last on the surface. |
| default content | — | Ordinary semantic application markup with framework-native access to open and close. |
Vue uses v-model:open, React uses open with onOpenChange, and Svelte uses bind:open. The native uncontrolled relationship remains the default in every framework.
Placement and offset describe geometry, not appearance. Popover has no variant, tone, size, radius, elevation, or animation props.
Complete framework source
Vue
<script setup>
import {
autoUpdate,
computePosition,
flip,
offset as floatingOffset,
shift
} from '@floating-ui/dom'
import {
computed,
nextTick,
onBeforeUnmount,
onMounted,
ref,
useAttrs,
useId,
watch
} from 'vue'
import { twMerge } from 'tailwind-merge'
defineOptions({ inheritAttrs: false })
const props = defineProps({
/** Matches the native button's `popovertarget`. */
id: { type: String, default: undefined },
/** Framework-native controlled state. Omit for native uncontrolled use. */
open: { type: Boolean, default: undefined },
/** Initial state when `open` is not controlled. */
defaultOpen: { type: Boolean, default: false },
/** Preferred logical placement. Collision handling may flip it. */
placement: {
type: String,
default: 'bottom-start',
validator: (value) =>
[
'top',
'top-start',
'top-end',
'right',
'right-start',
'right-end',
'bottom',
'bottom-start',
'bottom-end',
'left',
'left-start',
'left-end'
].includes(value)
},
/** Space in pixels between the invoker and floating surface. */
offset: { type: Number, default: 8 }
})
const emit = defineEmits(['update:open'])
const attrs = useAttrs()
const generatedId = useId()
const content = ref()
const activeInvoker = ref()
const internalOpen = ref(props.defaultOpen)
const nativePopover = ref(false)
const resolvedPlacement = ref(props.placement)
const positionStyle = ref({ position: 'fixed', left: '0px', top: '0px' })
let cleanupPosition = () => {}
const isControlled = computed(() => props.open !== undefined)
const isOpen = computed(() =>
isControlled.value ? props.open : internalOpen.value
)
const contentId = computed(
() =>
props.id ?? `klean-popover-${generatedId.replace(/[^a-zA-Z0-9_-]/g, '')}`
)
const contentAttrs = computed(() => {
const {
class: _class,
style: _style,
hidden: _hidden,
popover: _popover,
'data-slot': _dataSlot,
...rest
} = attrs
return rest
})
const contentClasses = computed(() =>
twMerge(
[
'z-50 m-0 w-max max-w-[calc(100vw-1rem)] rounded-md border border-gray-200 bg-white p-4 text-gray-950 shadow-lg outline-none',
'dark:border-gray-700 dark:bg-gray-950 dark:text-white'
],
attrs.class
)
)
const dataSlot = computed(() => attrs['data-slot'] ?? 'popover-content')
function invokers() {
const root = content.value?.getRootNode?.() ?? document
return [...(root.querySelectorAll?.('[popovertarget]') ?? [])].filter(
(element) => element.getAttribute('popovertarget') === contentId.value
)
}
function eventPath(event) {
return event.composedPath?.() ?? [event.target]
}
function resolveInvoker(candidate) {
if (candidate?.isConnected) {
activeInvoker.value = candidate
}
if (!activeInvoker.value?.isConnected) {
activeInvoker.value = invokers()[0]
}
return activeInvoker.value
}
function syncInvokerAria() {
for (const invoker of invokers()) {
invoker.setAttribute('aria-controls', contentId.value)
invoker.setAttribute('aria-expanded', String(isOpen.value))
}
}
function popoverIsShowing() {
if (!content.value || !nativePopover.value) return false
try {
return content.value.matches(':popover-open')
} catch {
return false
}
}
function syncNativePopover() {
if (!content.value || !nativePopover.value) return
const showing = popoverIsShowing()
try {
if (isOpen.value && !showing) {
content.value.showPopover({ source: resolveInvoker() })
} else if (!isOpen.value && showing) {
content.value.hidePopover()
}
} catch {
// A rapid native toggle can briefly make the requested state redundant.
}
}
function setOpen(nextOpen, { restoreFocus = false } = {}) {
if (!isControlled.value) internalOpen.value = nextOpen
emit('update:open', nextOpen)
nextTick(() => {
syncInvokerAria()
syncNativePopover()
const invoker = resolveInvoker()
if (!nextOpen && restoreFocus && invoker?.isConnected) {
invoker.focus({ preventScroll: true })
}
})
}
function close({ restoreFocus = isOpen.value } = {}) {
setOpen(false, { restoreFocus })
}
function open(source) {
resolveInvoker(source)
setOpen(true)
}
function handleNativeToggle(event) {
const nextOpen = event.newState === 'open'
const shouldRestoreFocus =
!nextOpen && event.source?.getAttribute?.('popovertargetaction') === 'hide'
if (nextOpen) resolveInvoker(event.source)
if (nextOpen === isOpen.value) return
if (!isControlled.value) internalOpen.value = nextOpen
emit('update:open', nextOpen)
syncInvokerAria()
if (isControlled.value) nextTick(syncNativePopover)
if (shouldRestoreFocus) {
nextTick(() => {
const invoker = resolveInvoker()
if (invoker?.isConnected) invoker.focus({ preventScroll: true })
})
}
}
function matchingInvokerFromEvent(event) {
const candidate = eventPath(event).find(
(element) => element?.getAttribute?.('popovertarget') === contentId.value
)
return candidate?.getAttribute('popovertarget') === contentId.value
? candidate
: undefined
}
function handleFallbackInvokerClick(event) {
const invoker = matchingInvokerFromEvent(event)
if (!invoker) return
resolveInvoker(invoker)
const action = invoker.getAttribute('popovertargetaction') ?? 'toggle'
if (action === 'show') setOpen(true)
else if (action === 'hide') setOpen(false, { restoreFocus: true })
else setOpen(!isOpen.value)
}
function handleOutsidePointer(event) {
const path = eventPath(event)
const reference = resolveInvoker()
if (
path.includes(content.value) ||
(reference &&
(path.includes(reference) || reference.contains?.(event.target))) ||
invokers().some(
(invoker) => path.includes(invoker) || invoker.contains(event.target)
)
) {
return
}
setOpen(false)
}
function handleEscape(event) {
if (event.key !== 'Escape') return
if (nativePopover.value) {
const openPopovers = [...document.querySelectorAll(':popover-open')]
if (openPopovers.at(-1) !== content.value) return
}
event.preventDefault()
setOpen(false, { restoreFocus: true })
}
async function updatePosition() {
const invoker = resolveInvoker()
if (!isOpen.value || !invoker || !content.value) return
const { x, y, placement } = await computePosition(invoker, content.value, {
placement: props.placement,
strategy: 'fixed',
middleware: [floatingOffset(props.offset), flip(), shift({ padding: 8 })]
})
resolvedPlacement.value = placement
positionStyle.value = {
position: 'fixed',
left: `${x}px`,
top: `${y}px`
}
}
function stopOpenEffects() {
cleanupPosition()
cleanupPosition = () => {}
document.removeEventListener('pointerdown', handleOutsidePointer, true)
document.removeEventListener('keydown', handleEscape)
}
async function syncOpenEffects() {
stopOpenEffects()
await nextTick()
syncInvokerAria()
syncNativePopover()
const invoker = resolveInvoker()
if (!isOpen.value || !invoker || !content.value) return
cleanupPosition = autoUpdate(invoker, content.value, updatePosition)
document.addEventListener('keydown', handleEscape)
document.addEventListener('pointerdown', handleOutsidePointer, true)
}
watch(
() => [isOpen.value, props.placement, props.offset, activeInvoker.value],
syncOpenEffects,
{ flush: 'post' }
)
onMounted(() => {
nativePopover.value =
typeof content.value?.showPopover === 'function' &&
typeof content.value?.hidePopover === 'function'
resolveInvoker()
syncInvokerAria()
if (!nativePopover.value) {
document.addEventListener('click', handleFallbackInvokerClick)
}
syncOpenEffects()
})
onBeforeUnmount(() => {
stopOpenEffects()
document.removeEventListener('click', handleFallbackInvokerClick)
})
defineExpose({ content, close, open })
</script>
<template>
<div
ref="content"
v-bind="contentAttrs"
:id="contentId"
popover="auto"
:hidden="!nativePopover && !isOpen"
:data-slot="dataSlot"
:data-state="isOpen ? 'open' : 'closed'"
:data-placement="resolvedPlacement"
:class="contentClasses"
:style="[positionStyle, attrs.style]"
@toggle="handleNativeToggle"
>
<slot :open="isOpen" :close="close" />
</div>
</template>
React
import {
autoUpdate,
computePosition,
flip,
offset as floatingOffset,
shift
} from '@floating-ui/dom'
import {
forwardRef,
useCallback,
useEffect,
useId,
useImperativeHandle,
useRef,
useState
} from 'react'
import { twMerge } from 'tailwind-merge'
const BASE_CLASSES = [
'z-50 m-0 w-max max-w-[calc(100vw-1rem)] rounded-md border border-gray-200 bg-white p-4 text-gray-950 shadow-lg outline-none',
'dark:border-gray-700 dark:bg-gray-950 dark:text-white'
]
const Popover = forwardRef(function Popover(
{
id,
open: controlledOpen,
defaultOpen = false,
onOpenChange,
placement = 'bottom-start',
offset = 8,
className,
style,
'data-slot': dataSlot = 'popover-content',
children,
...contentProps
},
forwardedRef
) {
const generatedId = useId().replace(/[^a-zA-Z0-9_-]/g, '')
const contentId = id ?? `klean-popover-${generatedId}`
const contentRef = useRef(null)
const activeInvoker = useRef(null)
const [referenceVersion, setReferenceVersion] = useState(0)
const [internalOpen, setInternalOpen] = useState(defaultOpen)
const [supportsNative, setSupportsNative] = useState(false)
const [resolvedPlacement, setResolvedPlacement] = useState(placement)
const [positionStyle, setPositionStyle] = useState({
position: 'fixed',
left: 0,
top: 0
})
const isControlled = controlledOpen !== undefined
const isOpen = isControlled ? controlledOpen : internalOpen
const latestOpen = useRef(isOpen)
latestOpen.current = isOpen
const invokers = useCallback(() => {
const root = contentRef.current?.getRootNode?.() ?? document
return [...(root.querySelectorAll?.('[popovertarget]') ?? [])].filter(
(element) => element.getAttribute('popovertarget') === contentId
)
}, [contentId])
const eventPath = (event) =>
event.nativeEvent?.composedPath?.() ??
event.composedPath?.() ?? [event.target]
const resolveInvoker = useCallback(
(candidate) => {
if (candidate?.isConnected && activeInvoker.current !== candidate) {
activeInvoker.current = candidate
setReferenceVersion((version) => version + 1)
}
if (!activeInvoker.current?.isConnected) {
const fallback = invokers()[0]
if (activeInvoker.current !== fallback) {
activeInvoker.current = fallback
setReferenceVersion((version) => version + 1)
}
}
return activeInvoker.current
},
[contentId, invokers]
)
const syncInvokerAria = useCallback(() => {
for (const invoker of invokers()) {
invoker.setAttribute('aria-controls', contentId)
invoker.setAttribute('aria-expanded', String(latestOpen.current))
}
}, [contentId, invokers])
const popoverIsShowing = useCallback(() => {
if (!supportsNative || !contentRef.current) return false
try {
return contentRef.current.matches(':popover-open')
} catch {
return false
}
}, [supportsNative])
const syncNativePopover = useCallback(() => {
if (!supportsNative || !contentRef.current) return
const showing = popoverIsShowing()
try {
if (latestOpen.current && !showing) {
contentRef.current.showPopover({ source: resolveInvoker() })
} else if (!latestOpen.current && showing) {
contentRef.current.hidePopover()
}
} catch {
// A rapid native toggle can briefly make the requested state redundant.
}
}, [popoverIsShowing, resolveInvoker, supportsNative])
const requestOpen = useCallback(
(nextOpen, { restoreFocus = false } = {}) => {
if (!isControlled) setInternalOpen(nextOpen)
onOpenChange?.(nextOpen)
queueMicrotask(() => {
syncInvokerAria()
syncNativePopover()
const invoker = resolveInvoker()
if (!nextOpen && restoreFocus && invoker?.isConnected) {
invoker.focus({ preventScroll: true })
}
})
},
[
isControlled,
onOpenChange,
resolveInvoker,
syncInvokerAria,
syncNativePopover
]
)
useImperativeHandle(
forwardedRef,
() => ({
content: contentRef.current,
open: (source) => {
resolveInvoker(source)
requestOpen(true)
},
close: ({ restoreFocus = latestOpen.current } = {}) =>
requestOpen(false, { restoreFocus })
}),
[requestOpen, resolveInvoker]
)
useEffect(() => {
const native =
typeof contentRef.current?.showPopover === 'function' &&
typeof contentRef.current?.hidePopover === 'function'
setSupportsNative(native)
resolveInvoker()
syncInvokerAria()
if (native) return
function handleFallbackInvokerClick(event) {
const candidate = eventPath(event).find(
(element) => element?.getAttribute?.('popovertarget') === contentId
)
if (candidate?.getAttribute('popovertarget') !== contentId) return
resolveInvoker(candidate)
const action = candidate.getAttribute('popovertargetaction') ?? 'toggle'
if (action === 'show') requestOpen(true)
else if (action === 'hide') {
requestOpen(false, { restoreFocus: true })
} else requestOpen(!latestOpen.current)
}
document.addEventListener('click', handleFallbackInvokerClick)
return () =>
document.removeEventListener('click', handleFallbackInvokerClick)
}, [contentId, requestOpen, resolveInvoker, syncInvokerAria])
useEffect(() => {
syncInvokerAria()
syncNativePopover()
const invoker = resolveInvoker()
if (!isOpen || !invoker || !contentRef.current) return
const updatePosition = async () => {
const result = await computePosition(invoker, contentRef.current, {
placement,
strategy: 'fixed',
middleware: [floatingOffset(offset), flip(), shift({ padding: 8 })]
})
setResolvedPlacement(result.placement)
setPositionStyle({ position: 'fixed', left: result.x, top: result.y })
}
return autoUpdate(invoker, contentRef.current, updatePosition)
}, [
isOpen,
offset,
placement,
referenceVersion,
resolveInvoker,
syncInvokerAria,
syncNativePopover
])
useEffect(() => {
if (!isOpen) return
function handleOutsidePointer(event) {
const path = eventPath(event)
const reference = resolveInvoker()
if (
path.includes(contentRef.current) ||
(reference &&
(path.includes(reference) || reference.contains?.(event.target))) ||
invokers().some(
(invoker) => path.includes(invoker) || invoker.contains(event.target)
)
) {
return
}
requestOpen(false)
}
function handleEscape(event) {
if (event.key !== 'Escape') return
if (supportsNative) {
const openPopovers = [...document.querySelectorAll(':popover-open')]
if (openPopovers.at(-1) !== contentRef.current) return
}
event.preventDefault()
requestOpen(false, { restoreFocus: true })
}
document.addEventListener('keydown', handleEscape)
document.addEventListener('pointerdown', handleOutsidePointer, true)
return () => {
document.removeEventListener('pointerdown', handleOutsidePointer, true)
document.removeEventListener('keydown', handleEscape)
}
}, [isOpen, invokers, requestOpen, supportsNative])
function handleNativeToggle(event) {
const nativeEvent = event.nativeEvent
const nextOpen = nativeEvent.newState === 'open'
const shouldRestoreFocus =
!nextOpen &&
nativeEvent.source?.getAttribute?.('popovertargetaction') === 'hide'
if (nextOpen) resolveInvoker(nativeEvent.source)
if (nextOpen === latestOpen.current) return
if (!isControlled) setInternalOpen(nextOpen)
onOpenChange?.(nextOpen)
queueMicrotask(syncInvokerAria)
if (isControlled) queueMicrotask(syncNativePopover)
if (shouldRestoreFocus) {
queueMicrotask(() => {
const invoker = resolveInvoker()
if (invoker?.isConnected) invoker.focus({ preventScroll: true })
})
}
}
const close = ({ restoreFocus = latestOpen.current } = {}) =>
requestOpen(false, { restoreFocus })
return (
<div
{...contentProps}
ref={contentRef}
id={contentId}
popover="auto"
hidden={!supportsNative && !isOpen}
data-slot={dataSlot}
data-state={isOpen ? 'open' : 'closed'}
data-placement={resolvedPlacement}
className={twMerge(BASE_CLASSES, className)}
style={{ ...positionStyle, ...style }}
onToggle={handleNativeToggle}
>
{typeof children === 'function'
? children({ open: isOpen, close })
: children}
</div>
)
})
export default Popover
Svelte
<script>
import {
autoUpdate,
computePosition,
flip,
offset as floatingOffset,
shift,
} from "@floating-ui/dom";
import { onMount, untrack } from "svelte";
import { twMerge } from "tailwind-merge";
const BASE_CLASSES = [
"z-50 m-0 w-max max-w-[calc(100vw-1rem)] rounded-md border border-gray-200 bg-white p-4 text-gray-950 shadow-lg outline-none",
"dark:border-gray-700 dark:bg-gray-950 dark:text-white",
];
let {
id,
open = $bindable(),
defaultOpen = false,
onOpenChange,
placement = "bottom-start",
offset = 8,
class: className = "",
style,
children,
...contentProps
} = $props();
const componentId = $props.id();
const generatedId = `klean-popover-${componentId}`;
let internalOpen = $state(untrack(() => defaultOpen));
let contentElement = $state();
let activeInvoker = $state();
let supportsNative = $state(false);
let resolvedPlacement = $state(untrack(() => placement));
let positionStyle = $state({ position: "fixed", left: "0px", top: "0px" });
let isOpen = $derived(open ?? internalOpen);
let contentId = $derived(id ?? generatedId);
let mergedStyle = $derived(
[positionStyle, style]
.flatMap((value) => {
if (!value) return [];
if (typeof value === "string") return value;
return Object.entries(value)
.filter(([, propertyValue]) => propertyValue != null)
.map(([property, propertyValue]) => {
const cssProperty = property.replace(
/[A-Z]/g,
(letter) => `-${letter.toLowerCase()}`,
);
return `${cssProperty}:${propertyValue}`;
});
})
.join(";"),
);
function invokers() {
const root = contentElement?.getRootNode?.() ?? document;
return [...(root.querySelectorAll?.("[popovertarget]") ?? [])].filter(
(element) => element.getAttribute("popovertarget") === contentId,
);
}
function eventPath(event) {
return event.composedPath?.() ?? [event.target];
}
function resolveInvoker(candidate) {
if (candidate?.isConnected) {
activeInvoker = candidate;
}
if (!activeInvoker?.isConnected) activeInvoker = invokers()[0];
return activeInvoker;
}
function syncInvokerAria() {
for (const invoker of invokers()) {
invoker.setAttribute("aria-controls", contentId);
invoker.setAttribute("aria-expanded", String(isOpen));
}
}
function popoverIsShowing() {
if (!supportsNative || !contentElement) return false;
try {
return contentElement.matches(":popover-open");
} catch {
return false;
}
}
function syncNativePopover() {
if (!supportsNative || !contentElement) return;
const showing = popoverIsShowing();
try {
if (isOpen && !showing) {
contentElement.showPopover({ source: resolveInvoker() });
} else if (!isOpen && showing) {
contentElement.hidePopover();
}
} catch {
// A rapid native toggle can briefly make the requested state redundant.
}
}
function requestOpen(nextOpen, { restoreFocus = false } = {}) {
if (open === undefined) internalOpen = nextOpen;
else open = nextOpen;
onOpenChange?.(nextOpen);
queueMicrotask(() => {
syncInvokerAria();
syncNativePopover();
const invoker = resolveInvoker();
if (!nextOpen && restoreFocus && invoker?.isConnected) {
invoker.focus({ preventScroll: true });
}
});
}
export function close({ restoreFocus = isOpen } = {}) {
requestOpen(false, { restoreFocus });
}
export function show(source) {
resolveInvoker(source);
requestOpen(true);
}
export function getContent() {
return contentElement;
}
function handleNativeToggle(event) {
const nextOpen = event.newState === "open";
const shouldRestoreFocus =
!nextOpen &&
event.source?.getAttribute?.("popovertargetaction") === "hide";
if (nextOpen) resolveInvoker(event.source);
if (nextOpen === isOpen) return;
if (open === undefined) internalOpen = nextOpen;
else open = nextOpen;
onOpenChange?.(nextOpen);
queueMicrotask(syncInvokerAria);
if (shouldRestoreFocus) {
queueMicrotask(() => {
const invoker = resolveInvoker();
if (invoker?.isConnected) invoker.focus({ preventScroll: true });
});
}
}
onMount(() => {
supportsNative =
typeof contentElement?.showPopover === "function" &&
typeof contentElement?.hidePopover === "function";
resolveInvoker();
syncInvokerAria();
if (supportsNative) return;
function handleFallbackInvokerClick(event) {
const candidate = eventPath(event).find(
(element) => element?.getAttribute?.("popovertarget") === contentId,
);
if (candidate?.getAttribute("popovertarget") !== contentId) return;
resolveInvoker(candidate);
const action = candidate.getAttribute("popovertargetaction") ?? "toggle";
if (action === "show") requestOpen(true);
else if (action === "hide") {
requestOpen(false, { restoreFocus: true });
} else requestOpen(!isOpen);
}
document.addEventListener("click", handleFallbackInvokerClick);
return () =>
document.removeEventListener("click", handleFallbackInvokerClick);
});
$effect(() => {
syncInvokerAria();
syncNativePopover();
const invoker = resolveInvoker();
if (!isOpen || !invoker || !contentElement) return;
const updatePosition = async () => {
const result = await computePosition(invoker, contentElement, {
placement,
strategy: "fixed",
middleware: [floatingOffset(offset), flip(), shift({ padding: 8 })],
});
resolvedPlacement = result.placement;
positionStyle = {
position: "fixed",
left: `${result.x}px`,
top: `${result.y}px`,
};
};
return autoUpdate(invoker, contentElement, updatePosition);
});
$effect(() => {
if (!isOpen) return;
function handleOutsidePointer(event) {
const path = eventPath(event);
const reference = resolveInvoker();
if (
path.includes(contentElement) ||
(reference &&
(path.includes(reference) || reference.contains?.(event.target))) ||
invokers().some(
(invoker) => path.includes(invoker) || invoker.contains(event.target),
)
) {
return;
}
requestOpen(false);
}
function handleEscape(event) {
if (event.key !== "Escape") return;
if (supportsNative) {
const openPopovers = [...document.querySelectorAll(":popover-open")];
if (openPopovers.at(-1) !== contentElement) return;
}
event.preventDefault();
requestOpen(false, { restoreFocus: true });
}
document.addEventListener("keydown", handleEscape);
document.addEventListener("pointerdown", handleOutsidePointer, true);
return () => {
document.removeEventListener("pointerdown", handleOutsidePointer, true);
document.removeEventListener("keydown", handleEscape);
};
});
</script>
<div
{...contentProps}
bind:this={contentElement}
id={contentId}
popover="auto"
hidden={!supportsNative && !isOpen}
data-slot={contentProps["data-slot"] ?? "popover-content"}
data-state={isOpen ? "open" : "closed"}
data-placement={resolvedPlacement}
class={twMerge(BASE_CLASSES, className)}
style={mergedStyle}
ontoggle={handleNativeToggle}
>
{@render children?.({ open: isOpen, close })}
</div>
Closing the surface
Prefer the native close action when a button only dismisses the surface:
<Button popovertarget="filters" popovertargetaction="hide">Done</Button>Use the framework-native close value when an application action already runs code:
<Popover id="filters" v-slot="{ close }">
<Button @click="applyFilters(); close()">Apply</Button>
</Popover>Escape and explicit dismissal return focus to the connected invoker. Outside pointer dismissal leaves focus with the element the person selected instead of moving it unexpectedly.
Composed controls may pass their active element when opening programmatically. Vue and React expose open(source); Svelte exposes show(source). That element becomes the placement and focus-return anchor, so Date Picker and Date Range Picker remain attached to the field currently in use.
Observable, never persisted
Most Popovers need no application state. Observe visibility only when another part of the interface truly responds to it.
Popover is not every overlay
- Popover is a generic non-modal surface. It leaves content in ordinary document tab order and assigns no role.
- Menu composes this foundation with
menuandmenuitemsemantics, arrow keys, Home/End, and typeahead. - Dialog is modal. It uses native dialog behavior, labeling, focus containment, and an inert background.
- Tooltip describes a control on hover or focus and follows a different trigger and dismissal contract.
Do not add role="menu" merely because a Popover contains several links or buttons. A list, navigation region, form, heading, or group of ordinary buttons is usually more truthful.
Product recipes
Hagfish's share surface and Slipway's operational filters need the same interaction behavior but intentionally different visual language. Their Tailwind stays visible at the call site.
Accessibility and Durable UI contract
- The invoker is a real button with native keyboard activation.
aria-controlsandaria-expandedstay synchronized automatically.- Escape and light dismissal work without trapping focus or locking page scroll.
- Explicit and keyboard dismissal restore focus only when the invoker still exists.
- Global listeners and position observers exist only while the surface is open and are cleaned up on unmount.
- Popover assigns no Menu or Dialog role; the application's semantic content remains visible in its markup.
- Open state is ephemeral. Meaningful state inside the surface follows the Durable UI contract.
- Visual treatment follows the application-owned theming convention.
Related components
- Tooltip — short supplementary text for one semantic control, never interactive content.
- Menu — action and navigation semantics with roving focus.
- Select — a fixed-list value picker composed with Popover.
- Date Picker — a date-only
YYYY-MM-DDfield composed with Popover. - Date Range Picker — two ordered date-only
YYYY-MM-DDboundaries. - Dialog — a modal surface when background interaction must stop.