Components Motion Primitives LinkImageHover

LinkImageHover

Links with floating image preview on hover.

Live demo

01

Single link

Cities β€” database-backed grid

Nature β€” alternate category

Custom image sizes

h-32 w-32
h-44 w-44
h-64 w-64

Implementation

02
LinkImageHover.svelte
<script>
  import LinkImageHover from '$lib/components/LinkImageHover.svelte';
</script>
​
<LinkImageHover
  href="https://en.wikipedia.org/wiki/Mumbai"
  text="Mumbai"
  imageSrc="https://example.com/mumbai.jpg"
  imageAlt="Mumbai skyline"
  imageWidth="h-44 w-44"
/>

LinkImageHover keeps the link as a real anchor β€” accessible by default β€” and conditionally renders a floating preview while the cursor is over it. Svelte transitions handle the blur fade-in, so there is no animation library to import. Touch users get the link without the preview because the hover state never activates.

Logic explainer

03

What Does It Do? (Plain English)

LinkImageHover is an inline link that reveals a floating image preview when the user hovers it on a desktop or taps it once on a phone. The link itself stays a plain anchor so it inherits everything anchors get for free β€” keyboard navigation, screen-reader semantics, right-click "open in new tab", and the user's preferred external-link behaviour. The preview is a small thumbnail that drops in from above with a blur transition; on touch devices it stays open until the user taps it again to navigate or taps elsewhere to dismiss.

It is the kind of decoration that earns its keep on documentation pages, reference lists, and anywhere a link would benefit from a visual aside without forcing the reader off-page first.

How It Works (Pseudo-Code)

state:
  isHover            = false   // desktop hover state
  showPreviewMobile  = false   // touch tap-to-preview state
  isTouchDevice      = false   // capability flag
  containerRef                 // bound DOM node for click-outside

on mount:
  isTouchDevice = matchMedia('(pointer: coarse)').matches

effect (re-runs when state changes):
  if isTouchDevice and showPreviewMobile:
    after 10ms (avoid same-tap closure):
      addEventListener(document, 'click', handleClickOutside)
    on cleanup:
      removeEventListener

on link mouseenter (desktop only via use:linkEffect):
  isHover = true

on link mouseleave (desktop only):
  isHover = false

on link click:
  if isTouchDevice and not showPreviewMobile:
    event.preventDefault()           // don't navigate yet
    showPreviewMobile = true
  // else (desktop, or mobile second-tap) β†’ allow native navigation

on preview button click (mobile only):
  event.stopPropagation()            // don't bubble to document
  showPreviewMobile = false
  window.open(href, target)          // navigate

render:
  if (isHover and not isTouchDevice) or (showPreviewMobile and isTouchDevice):
    if isTouchDevice:
      <button onclick={handlePreviewClick}> <img in:blur /> </button>
    else:
      <img in:blur />                // not interactive on desktop
  <a href target onclick={handleLinkClick}>{ text }</a>

The desktop and touch paths are deliberately split because the underlying gestures differ. Hover is continuous and reversible; tap is discrete and stateful. Trying to share one path produces a worst-of-both-worlds component that double-fires on hybrid devices.

The Core Concept: Capability-Switched Behaviour

Two media queries decide which path runs:

  • (pointer: coarse) β€” true for primarily touch-driven devices (phones, most tablets). Stays false for laptops with touchscreens whose primary input is the trackpad.
  • (implicit) hover semantics β€” only fire on devices that actually emit hover events.

Detection happens once on mount via matchMedia, and the result drives a hard fork in render and event-handling logic.

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”      β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  (pointer: fine)     β”‚      β”‚  (pointer: coarse)   β”‚
β”‚  desktop / laptop    β”‚      β”‚  phone / tablet      β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜      β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
           β”‚                             β”‚
           β–Ό                             β–Ό
   hover handlers fire             tap handlers fire
   isHover toggles                 showPreviewMobile toggles
   preview = <img>                 preview = <button><img></button>
   click β†’ navigate immediately    1st tap β†’ preview only
                                   2nd tap β†’ navigate
                                   tap elsewhere β†’ dismiss

The linkEffect Svelte action is how desktop hover is wired without polluting the markup. Actions are functions called once per element with (node) => { destroy() } semantics β€” perfect for adding/removing listeners deterministically.

The first-tap-prevent-default path is the trick that makes the touch UX work without wrapper buttons everywhere: the link is still a real <a>, but on touch devices the first tap calls event.preventDefault() to suppress navigation, sets state to "preview shown", and the second tap (which by that point hits a wrapping <button> over the image) calls window.open(href, target) explicitly.

CSS Animation Strategy

The reveal uses Svelte's built-in blur transition rather than a custom animation. The in:blur={{ duration: 300 }} directive on the image runs once per mount: a fade-in plus a CSS filter blur that resolves to clarity over 300 ms. There is no out: transition β€” the image is removed instantly when state flips back, which keeps the dismissal snappy on mobile and avoids visible "stuck preview" feel.

<img in:blur={{ duration: 300 }} … />

The image is absolutely positioned bottom: 40px (Tailwind utilities or inline style) so it sits 40 px above the link text, regardless of viewport size. Tailwind's h-44 w-44 (the default imageWidth) sizes it consistently β€” the imageWidth prop is a Tailwind utility class string so consumers can adjust without writing CSS.

rounded-lg shadow-lg on the image and z-50 on both the link and preview ensure the preview floats over neighbouring inline content without being clipped by overflow boundaries.

State Flow Diagram

                       β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                       β”‚       IDLE           β”‚
                       β”‚  isHover = false     β”‚
                       β”‚  showPreview = false β”‚
                       β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                                  β”‚
              β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
              β”‚ desktop hover                          β”‚ touch first tap
              β–Ό                                        β–Ό
        β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”                       β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
        β”‚  HOVER OPEN  β”‚                       β”‚  TAP OPEN       β”‚
        β”‚  preview img β”‚                       β”‚  preview button β”‚
        β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜                       β””β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜
               β”‚                                     β”‚     β”‚
   pointer leaves                            tap preview   tap outside
               β”‚                                     β”‚     β”‚
               β–Ό                                     β–Ό     β–Ό
        β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”                       β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
        β”‚   IDLE       β”‚                       β”‚  navigate href  β”‚
        β”‚   img gone   β”‚                       β”‚  & set IDLE     β”‚
        β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜                       β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Props Reference

Prop Type Default Description
href string 'https://example.com' URL the link points to. Standard anchor semantics.
text string 'Link Text' Visible link text. Inherits the host typography.
imageSrc string a Pinterest example URL Source URL of the preview image.
imageAlt string 'Preview Image' Alt text β€” read by screen readers. Required for accessibility even though the preview is decorative.
imageWidth string 'h-44 w-44' Tailwind utility class string sizing the preview. Pass anything Tailwind understands (e.g. 'h-32 w-48').
target string '_blank' Standard anchor target. When _blank, rel="noopener noreferrer" is added automatically.

Edge Cases

Situation Behaviour
User has pointer: coarse but also a mouse The first detected primary pointer wins on mount. We honour the coarse path; users get tap-to-preview. Acceptable β€” hybrid devices are rare and the mobile UX is no worse than tap-to-navigate.
target='_self' The component sets rel only when target is _blank. Same-window navigation works as a normal anchor would.
Image fails to load Standard <img> failure β€” the alt text is shown. The preview button (mobile) remains tappable; consumers can wire onerror themselves if they need a fallback.
Multiple instances side by side Each tracks its own state. Hovering one does not affect the others. Click-outside handlers are scoped per-instance via containerRef, so opening one preview on mobile dismisses any other.
User taps the preview rapidly stopPropagation stops the document click-outside fire-back, so the navigation runs cleanly. The 10 ms setTimeout before adding the click-outside listener prevents the opening tap from immediately closing the preview.
Preview overflows the viewport edge The preview is position: absolute with bottom: 40px from the link β€” it always floats up. Near the top of the viewport it can extend beyond the page edge; consumers can override positioning via CSS or wrap with their own constraints.
prefers-reduced-motion: reduce Not specifically handled β€” Svelte's blur transition still runs at the configured 300 ms. Consumers wanting strict suppression should wrap with their own @media rule and override the duration to 0.
Component unmounts with mobile preview open The $effect cleanup removes the document click listener; no leaked handler.

Dependencies

  • Svelte 5 β€” $state, $props, $effect, onMount, action syntax (use:linkEffect).
  • svelte/transition β€” built-in blur transition for the preview reveal. No third-party animation library.
  • $lib/types β€” LinkImageHoverProps interface for type safety.
  • TailwindCSS β€” sizing utilities (h-44 w-44, rounded-lg, shadow-lg, z-50) drive the preview presentation. Removing Tailwind would mean porting these to scoped CSS.

File Structure

src/lib/components/LinkImageHover.svelte      # implementation
src/lib/components/LinkImageHover.md          # this explainer
src/routes/linkimagehover/+page.svelte        # demo page
src/routes/linkimagehover/+page.server.ts     # SSR data load (curated link sets)
src/lib/types.ts                              # LinkImageHoverProps interface

API

04
PropTypeDefaultDescription
hrefstringβ€”Destination URL for the link.
textstring'Link Text'Visible link label.
imageSrcstringβ€”URL for the preview image.
imageAltstring'Preview Image'Alt text for the preview image.
imageWidthstring'h-44 w-44'Tailwind size classes applied to the preview.
targetstring'_blank'Standard anchor target attribute.