This comment is from @shion , posted three days ago.
<script setup lang="ts">
import { Avatar, HoverCard, Inline, Link, Stack, Text } from '@hina-ui/vue'
</script>
<template>
<Text>
This comment is from
<HoverCard>
<Link href="#">@shion</Link>
<template #content>
<Stack gap="sm" class="w-64">
<Inline gap="sm" align="center">
<Avatar src="/avatars/selfie.webp" name="Hoshimi Shion" size="md" />
<Stack gap="none">
<Text weight="medium">Hoshimi Shion</Text>
<Text tone="muted" size="sm">@shion</Text>
</Stack>
</Inline>
<Text size="sm">
Reads, writes, draws now and then. Catching up on this season's anime.
</Text>
<Inline gap="md">
<Text size="sm">
<Text as="span" weight="medium">128</Text>
following
</Text>
<Text size="sm">
<Text as="span" weight="medium">2,048</Text>
followers
</Text>
</Inline>
</Stack>
</template>
</HoverCard>
, posted three days ago.
</Text>
</template>
Usage
import { HoverCard } from '@hina-ui/vue'
The hover card gives a link a preview: after the pointer rests on the trigger for a moment the card floats out, and it pulls back once the pointer leaves; focusing the trigger with the keyboard also uses openDelay. The default slot is the trigger, usually a link, and the content slot is the card. It is something to glance at, so it neither stops the page from scrolling nor takes focus.
<script setup lang="ts">
import { HoverCard, Link, Stack, Text } from '@hina-ui/vue'
</script>
<template>
<HoverCard>
<Link href="#">Reka UI</Link>
<template #content>
<Stack gap="xs" class="w-64">
<Text weight="medium">Reka UI</Text>
<Text tone="muted" size="sm">
An unstyled, accessible set of Vue primitives that most interactive components in this
library are built on.
</Text>
</Stack>
</template>
</HoverCard>
</template>
Examples
Placement
side sets the direction the card floats out on and align how it lines up with the trigger, as in Popover; the default is right below.
<script setup lang="ts">
import { HoverCard, Inline, Link, Text } from '@hina-ui/vue'
const sides = ['top', 'right', 'bottom', 'left'] as const
const names = { top: 'Top', right: 'Right', bottom: 'Bottom', left: 'Left' }
</script>
<template>
<Inline gap="lg">
<HoverCard v-for="side in sides" :key="side" :side="side">
<Link href="#">{{ names[side] }}</Link>
<template #content>
<Text size="sm">A card floating out on the {{ names[side].toLowerCase() }}.</Text>
</template>
</HoverCard>
</Inline>
</template>
Delays
openDelay is how long the pointer rests before the card opens and closeDelay how long after leaving it closes, both in milliseconds. Moving from the trigger into the card does not close it.
<script setup lang="ts">
import { HoverCard, Inline, Link, Text } from '@hina-ui/vue'
</script>
<template>
<Inline gap="lg">
<HoverCard :open-delay="0" :close-delay="0">
<Link href="#">At once</Link>
<template #content>
<Text size="sm">No delay; it appears as soon as the pointer arrives.</Text>
</template>
</HoverCard>
<HoverCard :open-delay="800" :close-delay="400">
<Link href="#">Rest a little longer</Link>
<template #content>
<Text size="sm">
Appears after 800 milliseconds and closes 400 milliseconds after leaving.
</Text>
</template>
</HoverCard>
</Inline>
</template>
Controlled
open supports two-way binding. The card still closes when the pointer leaves, on a click outside or on Esc, writing false back; an outside control only opens it.
Closed
<script setup lang="ts">
import { ref } from 'vue'
import { Button, HoverCard, Inline, Link, Text } from '@hina-ui/vue'
const open = ref(false)
</script>
<template>
<Inline gap="lg" align="center">
<Button variant="outline" tone="neutral" :disabled="open" @click="open = true">
Open the card
</Button>
<HoverCard v-model:open="open">
<Link href="#">Preview</Link>
<template #content>
<Text size="sm">
Opened from outside; moving the pointer away, clicking outside or pressing Esc closes it.
</Text>
</template>
</HoverCard>
<Text tone="muted" size="sm">{{ open ? 'Open' : 'Closed' }}</Text>
</Inline>
</template>
External anchor
Leave the default slot empty and pass an external element through anchor. Use v-model:open to open the card. Updating the anchor and content lets multiple triggers share one card. When the default slot is provided, its trigger is used instead.
An external anchor does not open the card automatically. Setting open = true opens immediately, without openDelay. Leaving still uses closeDelay; moving into the card keeps it open, and moving keyboard focus away from the anchor closes it. Clearing or removing the anchor, or scrolling its ancestors, closes the card.
This example uses Button to bind pointer and keyboard focus events.
During exit, the card continues to follow a connected anchor. If the anchor is cleared or removed, it finishes exiting at the last recorded position.
<script setup lang="ts">
import { ref, shallowRef } from 'vue'
import { Button, HoverCard, Inline, Stack, Text } from '@hina-ui/vue'
const open = ref(false)
const anchor = shallowRef<HTMLElement | null>(null)
const current = ref(1)
function show(event: PointerEvent | FocusEvent, index: number) {
const target = event.currentTarget as HTMLElement
if ('pointerType' in event ? event.pointerType === 'touch' : !target.matches(':focus-visible'))
return
anchor.value = target
current.value = index
open.value = true
}
</script>
<template>
<Inline>
<Button
v-for="index in 3"
:key="index"
variant="outline"
tone="neutral"
@pointerenter="show($event, index)"
@focus="show($event, index)"
>
Anchor {{ index }}
</Button>
<HoverCard v-model:open="open" :anchor="anchor">
<template #content>
<Stack gap="xs" class="w-56">
<Text weight="medium">Anchor {{ current }}</Text>
<Text tone="muted" size="sm">
One card updates its position and content with the active anchor.
</Text>
</Stack>
</template>
</HoverCard>
</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.
Virtual anchors have no hover area. Control visibility with v-model:open; pointer departure and ancestor scrolling do not close the card, while outside clicks and Escape still do. Element anchors retain their hover, close-delay, and scroll-dismissal behavior.
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,
HoverCard,
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>
<HoverCard v-model:open="open" :anchor="anchor" update-position-strategy="always" align="start">
<template #content>
<Text size="sm">Moving coordinates</Text>
</template>
</HoverCard>
</Stack>
</template>
Positioner styling
positionerClass adds classes to the outer positioning element, while class still applies to the inner card. Use utilities or global CSS classes to define movement transitions separately from the card’s enter and exit animations.
This example enables hn-transition-base when switching anchors while the card is open. It removes the movement transition on initial opening and closing, and disables it when reduced motion is preferred. The triggers use Button.
<script setup lang="ts">
import { ref, shallowRef } from 'vue'
import { Button, HoverCard, Inline, Stack, Text } from '@hina-ui/vue'
const open = ref(false)
const anchor = shallowRef<HTMLElement | null>(null)
const current = ref(1)
const moving = ref(false)
function show(event: PointerEvent | FocusEvent, index: number) {
const target = event.currentTarget as HTMLElement
if ('pointerType' in event ? event.pointerType === 'touch' : !target.matches(':focus-visible'))
return
if (!open.value) moving.value = false
else if (anchor.value !== target) moving.value = true
anchor.value = target
current.value = index
open.value = true
}
</script>
<template>
<Inline>
<Button
v-for="index in 3"
:key="index"
variant="outline"
tone="neutral"
@pointerenter="show($event, index)"
@focus="show($event, index)"
>
Anchor {{ index }}
</Button>
<HoverCard
v-model:open="open"
:anchor="anchor"
:positioner-class="
open && moving ? 'hn-transition-base motion-reduce:transition-none' : undefined
"
>
<template #content>
<Stack gap="xs" class="w-56">
<Text weight="medium">Anchor {{ current }}</Text>
<Text tone="muted" size="sm">One card moves smoothly to the active anchor.</Text>
</Stack>
</template>
</HoverCard>
</Inline>
</template>
Behavior
- Opens after the pointer rests for
openDelayand closescloseDelayafter it leaves; moving into the card keeps it open. - Focusing the slotted trigger opens after
openDelay; moving focus away closes aftercloseDelay. - Returning during the close delay cancels closing. Once the exit animation starts, the content is no longer interactive and hovering its previous area does not reopen it.
- A click outside the card or Esc closes it as well.
- The card does not stop the page from scrolling, and the rest of the page stays interactive.
- Touch does not open it.
Accessibility
- The card is supplementary; nothing in it should be the only way to reach something.
- The trigger should be focusable so keyboard users can see the card.
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
anchor | OverlayAnchor | null | — | Positioning element or virtual anchor when the default slot is empty |
updatePositionStrategy | 'optimized' | 'always' | 'optimized' | Position update strategy |
side | 'top' | 'right' | 'bottom' | 'left' | 'bottom' | Side the card floats out on |
align | 'start' | 'center' | 'end' | 'center' | Alignment against the trigger |
sideOffset | number | 8 | Distance from the trigger in pixels |
openDelay | number | 300 | Rest time before opening, in milliseconds |
closeDelay | number | 150 | Delay after leaving before closing, in ms |
padded | boolean | true | Whether the card has padding |
open | boolean | — | Whether it is open, supports two-way binding |
class | string | — | Classes appended to the card |
positionerClass | string | — | Classes added to the outer positioning element |
Slots
| Slot | Description |
|---|---|
| default | Optional trigger; omit to use anchor |
content | Card content |