<script setup lang="ts">
import { Settings2 } from '@lucide/vue'
import { Button, Heading, Input, Popover, Stack, Text } from '@hina-ui/vue'
</script>
<template>
<Popover align="start">
<Button variant="outline" tone="neutral">
<template #icon>
<Settings2 />
</template>
Reading settings
</Button>
<template #content>
<Stack gap="sm" class="w-60">
<Stack gap="xs">
<Heading :level="4" size="sm">Reading settings</Heading>
<Text tone="muted" size="sm">Adjust the width and line height of the text.</Text>
</Stack>
<Stack gap="xs">
<Text size="sm">Body width</Text>
<Input model-value="720" size="sm" />
</Stack>
<Stack gap="xs">
<Text size="sm">Line height</Text>
<Input model-value="1.8" size="sm" />
</Stack>
<Button size="sm">Save</Button>
</Stack>
</template>
</Popover>
</template>
Usage
import { Popover } from '@hina-ui/vue'
The default slot is the trigger and the content slot is what floats out. Clicking the trigger opens the panel; clicking it again, or clicking outside the panel, closes it. The content is laid out freely and can take focus.
<script setup lang="ts">
import { Button, Popover, Text } from '@hina-ui/vue'
</script>
<template>
<Popover>
<Button variant="outline" tone="neutral">About this book</Button>
<template #content>
<Text size="sm">
ATRI, published by ANIPLEX.EXE, follows a girl who restarts an observation programme at an
abandoned observatory.
</Text>
</template>
</Popover>
</template>
Examples
Placement
side decides which way the panel floats out and align how it lines up with the trigger. The default is centred below. When there is not enough room, the panel flips to the opposite side.
<script setup lang="ts">
import { Button, Inline, Popover, Text } from '@hina-ui/vue'
</script>
<template>
<Inline align="center" class="gap-6">
<Popover align="start">
<Button variant="outline" tone="neutral">start</Button>
<template #content>
<Text size="sm">Lined up with the start edge of the trigger.</Text>
</template>
</Popover>
<Popover align="center">
<Button variant="outline" tone="neutral">center</Button>
<template #content>
<Text size="sm">Centred on the trigger.</Text>
</template>
</Popover>
<Popover side="right" align="start">
<Button variant="outline" tone="neutral">right</Button>
<template #content>
<Text size="sm">Opens to the right of the trigger.</Text>
</template>
</Popover>
<Popover side="top">
<Button variant="outline" tone="neutral">top</Button>
<template #content>
<Text size="sm">Opens above the trigger.</Text>
</template>
</Popover>
</Inline>
</template>
Offset
sideOffset is the distance in pixels between the panel and its trigger.
<script setup lang="ts">
import { Button, Inline, Popover, Text } from '@hina-ui/vue'
</script>
<template>
<Inline align="center" class="gap-6">
<Popover :side-offset="0">
<Button variant="outline" tone="neutral">Flush</Button>
<template #content>
<Text size="sm">sideOffset is 0.</Text>
</template>
</Popover>
<Popover :side-offset="16">
<Button variant="outline" tone="neutral">Further away</Button>
<template #content>
<Text size="sm">sideOffset is 16.</Text>
</template>
</Popover>
</Inline>
</template>
Controlled
open supports two-way binding, so the panel can be opened or closed from outside, and a button inside the panel can close it as well.
Currently closed
<script setup lang="ts">
import { ref } from 'vue'
import { Button, Inline, Popover, Stack, Text } from '@hina-ui/vue'
const open = ref(false)
</script>
<template>
<Inline align="center">
<Popover v-model:open="open">
<Button variant="outline" tone="neutral">Details</Button>
<template #content>
<Stack gap="sm" class="w-56">
<Text size="sm">This panel can be opened and closed from outside.</Text>
<Button size="sm" variant="soft" tone="neutral" @click="open = false">Got it</Button>
</Stack>
</template>
</Popover>
<Button size="sm" variant="soft" tone="neutral" @click="open = !open">
{{ open ? 'Close' : 'Open' }} from outside
</Button>
<Text tone="muted" size="sm">Currently {{ open ? 'open' : 'closed' }}</Text>
</Inline>
</template>
External anchor
anchor accepts OverlayAnchor | null. Set it to omit the default slot and control visibility with v-model:open. The panel waits until the anchor is available. Changing the anchor while open updates the position; clearing it on close preserves the exit position.
When both the default slot and anchor are provided, the slot controls triggering and anchor controls positioning. The caller manages click and keyboard behavior, aria-haspopup, and aria-expanded on external elements.
<script setup lang="ts">
import { ref, shallowRef } from 'vue'
import { Button, Inline, Popover, Stack, Text } from '@hina-ui/vue'
const open = ref(false)
const current = ref(0)
const anchor = shallowRef<HTMLElement | null>(null)
function toggle(event: MouseEvent, index: number) {
const target = event.currentTarget as HTMLElement
open.value = anchor.value === target ? !open.value : true
anchor.value = target
current.value = index
}
</script>
<template>
<Inline>
<Button
v-for="index in 2"
:key="index"
variant="outline"
tone="neutral"
aria-haspopup="dialog"
:aria-expanded="open && current === index"
@click="toggle($event, index)"
>
Anchor {{ index }}
</Button>
<Popover v-model:open="open" :anchor="anchor" :modal="false" aria-label="External anchor">
<template #content>
<Stack gap="sm" class="w-56">
<Text size="sm">The default slot is empty. The anchor sets the position.</Text>
<Button size="sm" variant="soft" tone="neutral" @click="open = false">Close</Button>
</Stack>
</template>
</Popover>
</Inline>
</template>
Virtual anchors and continuous tracking
anchor also accepts an object with getBoundingClientRect() returning a rectangle in viewport coordinates. Import OverlayAnchor from the package root. The callback must return the latest coordinates; the object itself does not need to be replaced.
The optional contextElement identifies the element associated with those coordinates, allowing scroll ancestors and clipping boundaries to be detected. It does not become a trigger or extend the overlay’s interaction area.
updatePositionStrategy defaults to 'optimized', updating on scrolling, resizing, and layout changes. Set it to 'always' to check the rectangle every frame while mounted, including coordinate changes without DOM events. The strategy can change while open. Tracking continues throughout exit; clearing the anchor or removing its context element preserves the last position. Measurement stops after unmount.
To preserve external focus, set :modal="false" and use @open-auto-focus.prevent.
The example opens with Button and uses ScrollArea as its scroll container. The panel follows changing coordinates and container scrolling.
Scroll down to observe the position update.
<script setup lang="ts">
import { ref, shallowRef, watch } from 'vue'
import { useRafFn } from '@vueuse/core'
import {
Button,
ScrollArea,
Inline,
Stack,
Text,
Popover,
type OverlayAnchor,
} from '@hina-ui/vue'
const open = ref(false)
const surface = ref<InstanceType<typeof Stack>>()
const anchor = shallowRef<OverlayAnchor | null>(null)
const x = ref(80)
const { pause, resume } = useRafFn(
({ delta }) => {
x.value = 80 + ((x.value - 80 + delta / 35) % 120)
},
{ immediate: false },
)
watch(open, value => (value ? resume() : pause()))
function show() {
const element = surface.value?.$el as HTMLElement | undefined
if (!element) return
anchor.value = {
contextElement: element,
getBoundingClientRect: () => {
const rect = element.getBoundingClientRect()
return new DOMRect(rect.left + x.value, rect.top + 100, 2, 20)
},
}
open.value = true
}
</script>
<template>
<Stack class="w-full">
<Inline>
<Button variant="outline" tone="neutral" @click="show">Open</Button>
<Text size="sm" tone="muted">Scroll down to observe the position update.</Text>
</Inline>
<ScrollArea class="h-64 rounded-lg border border-line bg-inset">
<Stack ref="surface" class="relative h-128 shrink-0">
<Text
as="span"
aria-hidden="true"
class="absolute top-25 h-5 w-0.5 bg-accent"
:style="{ left: `${x}px` }"
/>
</Stack>
</ScrollArea>
<Popover
v-model:open="open"
:anchor="anchor"
update-position-strategy="always"
align="start"
:modal="false"
@open-auto-focus.prevent
>
<template #content>
<Text size="sm">Moving coordinates</Text>
</template>
</Popover>
</Stack>
</template>
Modality
modal defaults to true, locking page scrolling and restricting outside interaction while open. With :modal="false", the page remains scrollable and interactive. Clicking outside still closes the panel.
Focus and dismissal
Call event.preventDefault() in openAutoFocus or closeAutoFocus to cancel default focus behavior. Without a default trigger, closing restores the element focused before opening; the anchor is only used for positioning. If the caller has already moved focus outside, the previous element is not focused again.
Use interactOutside to prevent dismissal from outside pointer or focus interactions, and escapeKeyDown to prevent Escape dismissal. aria-label, aria-describedby, and data-* attributes are forwarded to the panel.
Custom padding
The panel is padded by default. Set padded="false" when the content should reach the edges and arrange its own spacing.
<script setup lang="ts">
import { Button, Divider, Popover, Stack, Text } from '@hina-ui/vue'
</script>
<template>
<Popover :padded="false" align="start">
<Button variant="outline" tone="neutral">Latest update</Button>
<template #content>
<Stack gap="none" class="w-64">
<Stack gap="none" class="bg-inset px-4 py-3">
<Text size="sm" class="font-medium">Chapter 42</Text>
</Stack>
<Divider />
<Stack gap="xs" class="px-4 py-3">
<Text tone="muted" size="sm">Written by Isuna Hasekura</Text>
<Text tone="muted" size="sm">Updated three hours ago</Text>
</Stack>
</Stack>
</template>
</Popover>
</template>
Triggers
The trigger is not limited to a button; any element that can take focus will do. When an icon button is the trigger, turn its tooltip off so two overlays do not stack up.
<script setup lang="ts">
import { Info } from '@lucide/vue'
import { IconButton, Inline, Link, Popover, Text } from '@hina-ui/vue'
</script>
<template>
<Inline align="center" class="gap-6">
<Popover>
<IconButton variant="ghost" tone="neutral" label="Show notes" :tooltip="false">
<Info />
</IconButton>
<template #content>
<Text size="sm">The trigger here is an icon button.</Text>
</template>
</Popover>
<Popover align="start">
<Link href="#">Shion Hoshimi</Link>
<template #content>
<Text size="sm">The trigger here is a piece of inline link text.</Text>
</template>
</Popover>
</Inline>
</template>
Behaviour
- In the default modal mode, the page is locked from scrolling while the panel is open.
- The trigger keeps its pressed ink for as long as the panel is open.
- Focus moves into the panel once it opens, and Escape closes it and returns focus to the trigger.
- In the default modal mode, clicking outside closes the panel without passing the click to the element underneath.
Accessibility
- Without a default trigger, provide an
aria-labelfor the panel. Closing restores the element focused before opening by default. - The trigger carries
aria-haspopup="dialog"andaria-expanded, and the panel is arole="dialog". - Headings, descriptions and form controls inside the panel are treated as ordinary page content and announced one by one.
- With a default trigger, focus returns to it once the panel closes unless focus restoration is cancelled.
API
Popover
| Prop | Type | Default | Description |
|---|---|---|---|
open | boolean | — | Whether it is open; supports v-model |
anchor | OverlayAnchor | null | — | Positioning element or virtual anchor |
updatePositionStrategy | 'optimized' | 'always' | 'optimized' | Position update strategy |
modal | boolean | true | Restrict outside interaction and lock scrolling |
side | 'top' | 'right' | 'bottom' | 'left' | 'bottom' | Which way it floats out |
align | 'start' | 'center' | 'end' | 'center' | How it lines up with the trigger |
sideOffset | number | 8 | Distance from the trigger |
padded | boolean | true | Whether the panel is padded |
class | string | — | Classes appended to the panel |
| Slot | Description |
|---|---|
default | Optional trigger |
content | The panel content |
| Event | Argument | Description |
|---|---|---|
openAutoFocus | Event | Before focusing on open; can be prevented |
closeAutoFocus | Event | Before restoring focus on close; can be prevented |
escapeKeyDown | KeyboardEvent | Escape key; dismissal can be prevented |
pointerDownOutside | PointerDownOutsideEvent | Outside pointer down; dismissal can be prevented |
focusOutside | FocusOutsideEvent | Focus moves outside; dismissal can be prevented |
interactOutside | PointerDownOutsideEvent | FocusOutsideEvent | Outside pointer or focus interaction; dismissal can be prevented |