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 a hideDelay (default 140ms) close timer, during which the pointer may move onto the popover (keepOpen() cancels the close).
  • Anchor capture: show() captures the anchor's getBoundingClientRect() 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 pointerdown inside 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 no click and 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 (an allowTouch long-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 + MovieHoverPreview is a complete reference implementation.

Parameters

useHoverPreview(anchorRef, options?)

NameTypeRequiredDefaultDescription
anchorRefRef<HTMLElement | null>✓—Anchor element (usually the cover container)
options.showDelaynumber300Delay from enter to open, ms (quick-swipe window)
options.hideDelaynumber140Delay from leave to close, ms (tolerates moving onto the popover)
options.allowTouchbooleanfalseAlso respond to touch / pen. Defaults to mouse only; enable it only when you are deliberately building a touch long-press preview
options.closeOnScrollbooleantrueClose 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

NameTypeDescription
visibleRef<boolean>Whether the popover is visible
rectRef<DOMRect | null>Anchor rectangle (captured on show())
show(e?: Event) => voidBind 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() => voidBind to the card's / popover's @mouseleave
keepOpen() => voidBind 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)

NameTypeRequiredDefaultDescription
rect{ left, top, width, height }✓—Anchor rectangle (the returned rect)
viewport{ w, h }✓—Viewport size (window.innerWidth/Height)
options.scalenumber1.25Zoom factor relative to the cover
options.boundsPreviewBoundssee abovePer-orientation width range (orientation inferred from rect aspect)
options.edgenumber8Minimum 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()
}

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 with SwVideoPreview. 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 a hideDelay (140ms default) close timer; the overlay itself must call keepOpen() on @mouseenter to cancel it and hide() 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 pass closeOnScroll: false: otherwise pausing on a card mid-scroll, lazy-image layout shifts, or Playwright's hover() 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), so mouseenter does fire. Worse, lifting the finger produces no mouseleave (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. Pass allowTouch: true to respond to touch as well.
  • Bind @pointerenter, not @mouseenter — and don't roll your own matchMedia check — 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:
    DeviceMedia queryFinger tapMouse hover
    iPad + Magic Keyboard / trackpadtrueallowed ⇒ gate defeatedfine
    Touch-first 2-in-1 with a mouse attachedfalsecorrectly blockedblocked ⇒ mouse hover killed

    PointerEvent.pointerType is 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.