📖 Developer Guide

How to Build a Popover in Vue 3 — and What Stays Hard Afterwards

A Vue popover looks like a v-if and a bit of positioning. Then the browser closes it behind your back and your ref starts lying, a card with overflow: hidden crops it, and two of them fight over who gets to be on top. This guide builds the version that survives all three — native shell, reconciled state, declarative positioning — and is honest about what is still hard when it works.

📅 Updated September 2026 ⏱ 12 min read ✍️ By Kompassify
The four layers of a Vue 3 popover — the native top-layer shell, a single reconciled source of truth, CSS anchor positioning and ARIA wiring — shown as a stack

A popover is the component people reach for when a tooltip is too small and a modal is too much. It holds a filter form, a user menu, a date picker, a "more actions" list. In Vue it usually starts as a v-if, an absolutely positioned div, and a @click handler on the document.

Then it gets clipped by a card with overflow: hidden. It opens underneath a sticky header. Two of them open at once. Closing one closes both. It never closes on Escape, and when it does close, focus is somewhere near the top of the page.

Most of that is now the browser's job rather than yours. This guide builds a Vue 3 popover on top of the native popover API, keeps Vue's state and the DOM's state from disagreeing, positions it without a measuring loop, and is honest about what stays hard once it works.

Key Takeaways

  • Do not build the shell. The native popover attribute gives you the top layer, light dismiss and Escape for free.
  • The DOM can close itself. Light dismiss fires without Vue's knowledge, so a naive ref desynchronises — reconcile on beforetoggle.
  • Declare position, do not measure it. CSS anchor positioning replaces the getBoundingClientRect watcher that never quite kept up with scroll.
  • A popover is not a dialog. It does not trap focus and it does not block the page — if it must, you wanted a modal.
  • Teleport is still useful, but for the cases the top layer does not cover, not as the default.
  • A UI popover is not an onboarding popover. Same rectangle, completely different lifecycle and owner.

Popover, Tooltip, Modal: Three Different Components

Teams merge these three and then fight the consequences for a year. They differ on one axis that decides everything else: who is in control while it is open.

Tooltip Popover Modal dialog
Opened by Hover or focus An explicit click or key press An explicit action
Contains A short string, nothing interactive Anything, including form controls A task, often multi-field
Rest of the page Fully usable Fully usable Inert, behind a backdrop
Focus Never moves May move in, never trapped Moves in and is trapped
Vue building block Custom, see the Vue tooltip guide popover="auto" <dialog> + showModal(), see the Vue modal guide

The tell: if you find yourself adding a focus trap and a backdrop to your popover, you have built a modal with the wrong element. Switch to <dialog> before the keyboard behaviour becomes someone's bug report.


1. Start From the Native Popover, Not From a Div

The popover API is the part of this component you no longer have to write. Adding the popover attribute to any element promotes it to the browser's top layer when open, which means it cannot be clipped by an ancestor's overflow and cannot be stacked under anything by z-index. It also brings light dismiss — a click outside or Escape closes it — and, when paired with popovertarget, a fully working control with no JavaScript at all.

<!-- The whole component, before Vue is involved --> <button popovertarget="filters">Filters</button> <div id="filters" popover> <!-- checkboxes, a date range, an Apply button --> </div>

Two values matter. popover="auto" (the default) light-dismisses and closes other open auto popovers — correct for menus, wrong for two independent filter panels. popover="manual" does neither: it only closes when you tell it to, which is what you want for a persistent side panel or a stack of popovers that should coexist.

Styling the top layer. A popover is display: none until it opens, so style the open state with the :popover-open pseudo-class and the backdrop — if you want one — with ::backdrop. Transitions need transition-behavior: allow-discrete plus a @starting-style rule, because you are animating a property that jumps from none.


2. Driving It From Vue Without Two Sources of Truth

Here is the bug that catches every first implementation. The user clicks outside; the browser closes the popover; Vue's isOpen ref is still true. The next click on the trigger sets it to true again — no change, no watcher, nothing opens. The button appears dead until you click it twice.

The DOM is now a second source of truth, and it can change without asking. The fix is to treat the element as the authority and let Vue follow it, reconciling on the beforetoggle event.

// usePopover.ts import { ref, watch, onBeforeUnmount, type Ref } from 'vue' export function usePopover(el: Ref<HTMLElement | null>) { const open = ref(false) // DOM -> Vue: light dismiss and Escape happen without us const sync = (e: Event) => { open.value = (e as ToggleEvent).newState === 'open' } watch(el, (node, _old, onCleanup) => { if (!node) return node.addEventListener('beforetoggle', sync) onCleanup(() => node.removeEventListener('beforetoggle', sync)) }, { immediate: true }) // Vue -> DOM: only ever call the imperative API watch(open, (v) => { const node = el.value if (!node) return const isOpen = node.matches(':popover-open') if (v && !isOpen) node.showPopover() if (!v && isOpen) node.hidePopover() }) return { open, toggle: () => { open.value = !open.value } } }

Three details in there are load-bearing. The guard on matches(':popover-open') stops the two watchers from ping-ponging. Reading newState from beforetoggle rather than assuming a close is what makes the composable work for programmatic opens too. And beforetoggle — not toggle — fires before the visual change, so a v-if inside the panel renders in the same frame instead of one late.

Two directions, one truth Vue const open = ref(false) watch(open, ...) renders the trigger + contents The element :popover-open top layer · light dismiss closes on its own initiative showPopover() / hidePopover() beforetoggle → newState Skip the return arrow and the trigger needs two clicks after every outside click.

The element is the authority; Vue's ref is a mirror that has to be corrected.


3. Positioning: Anchor It, Do Not Measure It

The classic Vue implementation measures the trigger in onMounted, writes top and left into a reactive style object, and re-runs on scroll and resize. It is a lot of code, it fires on every frame of a scroll, and it is always one tick behind.

CSS anchor positioning removes the loop. You name the trigger, tell the popover which edge to sit against, and give the browser a list of fallbacks to try when there is no room.

/* the trigger declares a name */ .filters-trigger { anchor-name: --filters; } /* the popover declares where it wants to be */ .filters-panel { position: absolute; position-anchor: --filters; position-area: block-end span-inline-end; /* below, aligned to the start edge */ margin-block-start: .5rem; /* what to do when that does not fit */ position-try-fallbacks: flip-block, flip-inline; position-try-order: most-height; }

Support is good in Chromium and shipping elsewhere, so keep a fallback. The pragmatic pattern is to feature-detect once and only pay for the JavaScript path where it is needed:

const hasAnchor = CSS.supports('anchor-name', '--x') // if (!hasAnchor) -> fall back to a positioning library's autoUpdate() // Do not hand-roll it: flip, shift, and scroll-following are where hand-rolled code dies.

Where does Teleport fit now? The top layer already solves clipping and stacking, so a native popover rarely needs teleporting. Keep <Teleport to="body"> for the cases it does not cover: a non-popover overlay you own, a fallback path on a browser without the API, or a panel that must escape a parent with a transform while not being in the top layer. Reaching for Teleport first is a habit worth retiring.


4. ARIA and Keyboard: Less Than You Expect, But Exactly That

A popover is not a dialog, so most of the heavy accessibility machinery does not apply. What it does need is small and non-negotiable.

The role depends on the contents, and guessing wrong is worse than leaving it off. A menu of actions wants role="menu" with menuitem children and arrow-key navigation — which is a real commitment, because the roles imply the keyboard behaviour. A panel of form controls wants no role at all: it is a group of ordinary inputs and Tab is the correct way through it.

<template> <button ref="trigger" :aria-expanded="open" aria-controls="filters-panel" @click="toggle" >Filters</button> <div ref="panel" id="filters-panel" popover="auto" class="filters-panel"> <slot /> </div> </template>

What Stays Hard After It Works

The component will pass review. These are the things that show up afterwards, in order of how often they do.

Nesting

An auto popover inside another auto popover closes its parent unless the browser can see the relationship — through DOM containment or popovertarget. Open a select from inside a filter panel and both vanish. Anchor the nested one to the parent, or go manual and own the dismissal yourself.

Scroll containers

The panel is in the top layer, so it no longer scrolls with the element it points at. Scroll a long table and the popover hovers over nothing. You want it to close on scroll of the nearest scrollable ancestor — which means finding that ancestor, not listening on window.

SSR and hydration

With Nuxt, showPopover() on an element that has not hydrated throws, and any state read from matches(':popover-open') during setup is meaningless on the server. Gate the imperative calls behind onMounted and keep the initial render closed.

Testing

jsdom does not implement the top layer or light dismiss. Component tests will happily assert on a popover that no real browser would have kept open. Dismissal behaviour needs a real browser runner; in jsdom, test the composable's reconciliation logic instead.


A UI Popover Is Not an Onboarding Popover

Once the component exists, someone asks for "a popover that shows new users where the export button is". It looks like the same rectangle. It is a different product.

An onboarding popover anchored to a control in a product, showing a step counter and a Next button rather than a menu

The same rectangle, doing a different job: note the “1/3” — this one knows where the user is in a sequence.

UI popover (what you just built) Onboarding popover
Trigger The user clicked something This user has not done something yet
Audience Everyone, identically A segment: plan, role, signup date, feature usage
State Lives for one interaction Seen / dismissed / completed, per user, forever
Sequencing None Step 2 of 5, resumable across sessions
Changes Rarely, through a release Weekly, by a non-engineer, without a release
Success is It positioned correctly Activation went up — which you have to measure

Building the second on top of the first is where teams lose a quarter. The positioning is the part you already solved; the parts you have not are per-user persistence, segment targeting, sequencing, analytics per step, and a copy-editing loop that does not go through your release train. That is a contextual help system, not a UI component — and the honest version of the build-versus-buy question is whether you want to own that roadmap.

Keep the component. Skip the content pipeline.

Kompassify lets product and onboarding teams add popovers, tooltips, hotspots, checklists and guided tours to a Vue app without shipping a release for every copy change — targeted by segment, with adoption data on each one. Free up to 100 monthly active users, plans from $129/month, GDPR-compliant and EU-hosted.

Start for free →

Build It, or Configure It?

Both, and the line is clean once you draw it by who changes it and how often.

Build the component

  • Menus, filter panels, pickers — anything that is part of the product's own interface.
  • Anything a designer specified and a developer maintains.
  • Anything that must work offline, inside your design system, with your tokens.

Configure the guidance

  • Anything aimed at a segment rather than at everyone.
  • Anything whose copy a PM or CS lead will want to change on a Tuesday.
  • Anything you need per-step completion data for.
A guidance tool showing segment targeting for an in-app message, alongside view and click analytics

The part a popover component does not give you: who sees it, and whether it worked.

A team that builds both ends up maintaining a small, badly-instrumented onboarding platform as a side effect of having written a popover.


A Pre-Ship Checklist

  1. Trigger is a real <button> with aria-expanded and aria-controls.
  2. Vue state reconciles with beforetoggle — verified by clicking outside, then clicking the trigger once.
  3. Escape closes it, and focus lands back on the trigger.
  4. auto versus manual chosen deliberately, not by default.
  5. Positioned by anchor positioning, with a tested fallback path where it is unsupported.
  6. Closes when its scroll container scrolls out from under it.
  7. Nested popovers verified: opening the child does not close the parent.
  8. Transitions respect prefers-reduced-motion.
  9. Renders closed on the server; no imperative calls before onMounted.
  10. Nothing essential exists only inside the popover.

The One-Sentence Version

Build a Vue 3 popover on the native popover attribute so the browser owns the top layer and light dismiss, reconcile your ref with the element on beforetoggle so the two never disagree, position it declaratively with anchor positioning, and recognise that the moment it needs to know which user is looking at it, you have left UI and entered onboarding.

If the next ticket is a sequence of these pointing at four different features for new users only, read the product tour guide before you start — and the hotspot UX guide if the trigger is meant to be a pulsing dot rather than a button.


Frequently Asked Questions

How do you create a popover in Vue 3?

Start from the native popover API rather than a positioned div. Put the popover attribute on the panel element, which promotes it to the browser's top layer when open and brings light dismiss and Escape for free, and give the trigger popovertarget so it works before any JavaScript runs. Then add a small composable that owns the open state: it calls showPopover() and hidePopover() when your ref changes, and listens for the element's beforetoggle event to write the element's real state back into that ref. Position it with CSS anchor positioning — anchor-name on the trigger, position-area and position-try-fallbacks on the panel — instead of measuring the trigger in a watcher. Finish with aria-expanded and aria-controls on the trigger, and return focus to it when the panel closes.

Why does my Vue popover need two clicks to reopen?

Because the DOM closed it without telling Vue. Light dismiss — a click outside, or the Escape key — is handled by the browser, so the popover disappears while your isOpen ref is still true. The next click on the trigger sets it to true again, which is not a change, so no watcher fires and nothing opens. The fix is to stop treating your ref as the source of truth: listen for the element's beforetoggle event, read event.newState, and write it back into the ref so Vue and the DOM agree. Guard the outgoing watcher with a matches(':popover-open') check so the two directions do not ping-pong.

Do I still need Teleport for a popover in Vue?

Usually not. Teleport exists to move an element out of an ancestor that clips or stacks it, and the native popover's top layer already solves both problems — it cannot be cropped by overflow: hidden and cannot be stacked under anything by z-index. Keep Teleport for what the top layer does not cover: a non-popover overlay you own, a fallback path on a browser without the popover API, or a panel that must escape a transformed parent without being promoted to the top layer. Reaching for Teleport as the default is a habit from before the API shipped.

What is the difference between popover="auto" and popover="manual"?

An auto popover light-dismisses — clicking outside or pressing Escape closes it — and it also closes any other open auto popover that is not its ancestor. That is exactly right for a menu or a single filter panel, and exactly wrong for two independent panels that should be able to sit open at once, or for a nested popover that keeps closing its parent. A manual popover does neither: it opens and closes only when your code calls showPopover() or hidePopover(), which means you own dismissal, including the outside-click handling. Choose deliberately rather than accepting the default.

How do you make a Vue popover accessible?

A popover is not a dialog, so it needs less than people assume, but the small list is non-negotiable. Use a real button as the trigger, not a div with a click handler. Bind aria-expanded to the open state and point aria-controls at the panel's id. Do not trap focus — the rest of the page must stay usable, and that is the difference from a modal. If you moved focus into the panel, return it to the trigger on close, including when the browser closed the panel by light dismiss. Only add role="menu" if you are also implementing arrow-key navigation and menuitem children; a panel of ordinary form controls should have no role at all.

What is the difference between a UI popover and an onboarding popover?

A UI popover is user-invoked and stateless: someone clicked a control, it opens, it shows the same thing to everyone, and it is gone after one interaction. An onboarding popover is proactive and stateful: it appears because a particular user has not done a particular thing yet, it is targeted by segment, it is usually step two of a sequence that has to resume across sessions, and it needs per-user seen and dismissed state plus completion analytics. The positioning work you did for the UI component carries over; the targeting, persistence, sequencing and weekly copy changes do not, and they are the parts that consume a roadmap. That is why product and onboarding teams usually configure in-app guidance in a tool instead of shipping it through the front-end release cycle.