useHoverPreview()
useHoverPreview is the mechanism layer of the "hover a card, pop up a zoomed preview" pattern (Netflix-style cover preview):
- Hover intent: the popover opens only after
showDelay(default 300ms) — quick swipes never trigger it; leaving starts ahideDelay(default 140ms) close timer, during which the pointer may move onto the popover (keepOpen()cancels the close). - Anchor capture:
show()captures the anchor'sgetBoundingClientRect()so the popover can be centered on the cover. - Instant close on scroll: a window scroll listener (capture phase — also catches scrolls inside SwRail-like horizontal containers) closes immediately, because the fixed popover would drift away from the card. In-place previews opt out with
closeOnScroll: false. - Pressing cancels a pending open: a
pointerdowninside the anchor cancels a preview that has not opened yet — the user has decided to click through. Otherwise the popover could open between press and release and cover the pointer; with mousedown on the card and mouseup on the popover, the browser dispatches noclickand the click is lost. An already-visible preview is unaffected (buttons inside an in-place preview, such as a mute toggle, still work), and a touch press does not cancel (anallowTouchlong-press preview relies on the timer running while the finger is held). - Touch devices are ignored by default: built-in gating (opt out with
allowTouch). ⚠️ Note that "touch devices have no mouseenter" is wrong — a tap does fire compatibility mouse events; see the notes below.
It owns behavior only, not looks — the popover component (dimming, scanline, badges, etc.) is drawn by your app; play video inside it with SwVideoPreview. Position it with the co-exported pure function computePreviewBox.
Consolidated from the yc001 / starter twin implementations (framework#253); starter's
MovieCard+MovieHoverPreviewis a complete reference implementation.
Parameters
useHoverPreview(anchorRef, options?)
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
anchorRef | Ref<HTMLElement | null> | ✓ | — | Anchor element (usually the cover container) |
options.showDelay | number | 300 | Delay from enter to open, ms (quick-swipe window) | |
options.hideDelay | number | 140 | Delay from leave to close, ms (tolerates moving onto the popover) | |
options.allowTouch | boolean | false | Also respond to touch / pen. Defaults to mouse only; enable it only when you are deliberately building a touch long-press preview | |
options.closeOnScroll | boolean | true | Close immediately on any window scroll (fixed-popover semantics). Pass false for in-place previews (video mounted inside the anchor, scrolling with the card); otherwise pausing on a card mid-scroll, lazy-image layout shifts, or Playwright's hover() auto scrollIntoView will kill the preview |
Return Value
| Name | Type | Description |
|---|---|---|
visible | Ref<boolean> | Whether the popover is visible |
rect | Ref<DOMRect | null> | Anchor rectangle (captured on show()) |
show | (e?: Event) => void | Bind to the card's @pointerenter (passing the event lets the gate judge precisely by pointerType; @mouseenter also works, falling back to a device-capability check) |
hide | () => void | Bind to the card's / popover's @mouseleave |
keepOpen | () => void | Bind to the popover's @mouseenter (cancels the close) |
computePreviewBox()
Pure positioning function: cover width × scale (default 1.25) → clamp into the per-orientation bounds (default landscape [272, 600] / portrait [198, 400] px) → keep the cover's real aspect ratio → center on the cover's geometric center → keep ≥ edge (default 8px) from the viewport edges.
computePreviewBox(rect, viewport, options?) => { left, top, width, height } (px, for position: fixed)
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
rect | { left, top, width, height } | ✓ | — | Anchor rectangle (the returned rect) |
viewport | { w, h } | ✓ | — | Viewport size (window.innerWidth/Height) |
options.scale | number | 1.25 | Zoom factor relative to the cover | |
options.bounds | PreviewBounds | see above | Per-orientation width range (orientation inferred from rect aspect) | |
options.edge | number | 8 | Minimum distance from viewport edges, px |
Examples
<!-- Example 1: wire up a card (cover anchor + popover open/close) -->
<script setup lang="ts">
const coverRef = ref<HTMLElement | null>(null)
const { visible, rect, show, hide, keepOpen } = useHoverPreview(coverRef)
</script>
<template>
<a class="sw-my-card" @pointerenter="show" @pointerleave="hide">
<div ref="coverRef" class="cover"><SwImage :src="item.img" :alt="item.name" ratio="16/9" /></div>
<p class="title">{{ item.name }}</p>
</a>
<!-- popover visuals are yours: take rect/visible, @enter="keepOpen" @leave="hide" -->
<MyHoverPreview :item="item" :rect="rect" :visible="visible" @enter="keepOpen" @leave="hide" />
</template>
<!-- Example 2: position inside the popover + SwVideoPreview (business-drawn visuals)
The SSR guard is not optional: the popover component does get server-rendered, and window does not exist there -->
<script setup lang="ts">
const props = defineProps<{ item: MyItem; rect: DOMRect | null; visible: boolean }>()
const box = computed(() =>
props.rect && typeof window !== 'undefined'
? computePreviewBox(props.rect, { w: window.innerWidth, h: window.innerHeight })
: null)
</script>
<template>
<ClientOnly>
<Teleport to="body">
<a
v-if="visible && box"
class="sw-my-hover-preview"
:style="{ position: 'fixed', left: `${box.left}px`, top: `${box.top}px`, width: `${box.width}px`, height: `${box.height}px` }"
@mouseenter="$emit('enter')"
@mouseleave="$emit('leave')"
>
<SwVideoPreview :id="item.id" />
</a>
</Teleport>
</ClientOnly>
</template>
// Example 3: tune the open/close rhythm / zoom parameters
useHoverPreview(coverRef, { showDelay: 450, hideDelay: 200 })
computePreviewBox(rect, vp, { scale: 1.4, edge: 16 })
// Example 4: business warm-up hooks (e.g. fixed sample videos) wrap your own show — not built in
function onEnter() {
warmPreviewVideos()
show()
}
Related
- SwVideoPreview muted looping preview player — the go-to component for video inside the popover
- SwRail horizontal rail — cover rail scenario (align arrows to the cover via
--sw-rail-arrow-top) - starter
MovieCard/MovieHoverPreview— complete reference implementation (dim / scanline / fade-in visuals)
Caveats
- Behavior only — the overlay's visuals are yours to draw — it returns state such as
{ visible, rect }; dimming, scanlines, badges and so on are written by the applier, and video inside the overlay pairs withSwVideoPreview. Don't hand-roll hover delay / positioning logic — consolidating exactly those two implementations (yc001 and starter) is why this composable exists. - Moving the pointer into the overlay must call
keepOpen()— leaving the anchor starts ahideDelay(140ms default) close timer; the overlay itself must callkeepOpen()on@mouseenterto cancel it andhide()on@mouseleave. Otherwise it closes the moment the pointer arrives. - Closing on scroll is intentional, but only for popovers — the overlay is
fixed-positioned, so any scroll (including inside a horizontal rail) would desync it from the card. A capture-phase window scroll listener closes it outright rather than tracking. When you use it for an in-place preview (video mounted inside the cover, scrolling with the card) you must passcloseOnScroll: false: otherwise pausing on a card mid-scroll, lazy-image layout shifts, or Playwright'shover()auto scrollIntoView on off-screen elements will cancel the pending show right after pointerenter and the preview silently never starts (shows up as e2e turning red depending on where the card lands that day). - Touch devices are ignored by default (built-in gating — don't re-implement it) — "touch devices have no mouseenter" is a widespread misconception: to keep mouse-only legacy sites working, browsers fire a burst of compatibility mouse events after a tap (
mouseover/mouseenter/mousemove/click), somouseenterdoes fire. Worse, lifting the finger produces nomouseleave(it only arrives when the user next taps somewhere else) ⇒ without gating, a single tap on mobile pops the overlay open and leaves it hanging there — not "a brief flash before navigation wipes it".
This composable gates that internally, so consumers do not need their own device check. PassallowTouch: trueto respond to touch as well. - Bind
@pointerenter, not@mouseenter— and don't roll your ownmatchMediacheck —matchMedia('(hover: hover) and (pointer: fine)')reports the device's primary input capability, whereas what matters is which input started this particular interaction. On 2-in-1 devices those differ, and it is wrong in both directions:Device Media query Finger tap Mouse hover iPad + Magic Keyboard / trackpad trueallowed ⇒ gate defeated fine Touch-first 2-in-1 with a mouse attached falsecorrectly blocked blocked ⇒ mouse hover killed PointerEvent.pointerTypeis evaluated per interaction ('touch'/'pen'/'mouse'), so it has no such problem. Binding@pointerenter="show"passes the event through and is the most accurate;@mouseenter="show"still works (it falls back to the media query) but misjudges 2-in-1 devices.