# vue-hover-effect

> WebGL-powered image hover transition effects for Vue 3, built on top of [Three.js](https://threejs.org/) and [GSAP](https://gsap.com/). Inspired by [Robin Delaporte's hover-effect](https://github.com/robin-dela/hover-effect).

## Features

- GPU-accelerated displacement map transitions between images
- Two ready-to-use components: single two-image transition and multi-image carousel
- Manual control via `next` / `previous` slot methods
- Video texture support
- Fully typed props — TypeScript autocompletion out of the box
- SSR / Nuxt compatible — Three.js and GSAP are lazy-loaded client-side only

---

## Installation

```bash
# npm
npm install vue-hover-effect

# pnpm
pnpm add vue-hover-effect

# yarn
yarn add vue-hover-effect
```

### Peer dependencies

Three.js and GSAP are peer dependencies. Install them alongside the package:

```bash
npm install three gsap
```

---

## Usage

### SingleHoverEffect

Transitions between two images on hover (or manually via `next` / `previous`).

```vue
<script setup lang="ts">
import { SingleHoverEffect } from 'vue-hover-effect'
</script>

<template>
  <SingleHoverEffect
    image1="/images/photo-a.webp"
    image2="/images/photo-b.webp"
    displacement-image="/displacements/displacement.webp"
  />
</template>
```

#### With custom size

Use the `distortion-class` prop to apply your own CSS class to the canvas container:

```vue
<template>
  <SingleHoverEffect
    image1="/images/photo-a.webp"
    image2="/images/photo-b.webp"
    displacement-image="/displacements/displacement.webp"
    distortion-class="my-effect"
  />
</template>

<style>
.my-effect {
  width: 600px;
  height: 400px;
}
</style>
```

#### With manual controls

Use the `controllers` slot to wire up your own buttons. The slot exposes `funcs.goNext()` and `funcs.goPrev()`:

```vue
<template>
  <SingleHoverEffect
    image1="/images/photo-a.webp"
    image2="/images/photo-b.webp"
    displacement-image="/displacements/displacement.webp"
    :hover="false"
    distortion-class="my-effect"
  >
    <template #controllers="{ funcs }">
      <button @click="funcs.goPrev()">Previous</button>
      <button @click="funcs.goNext()">Next</button>
    </template>
  </SingleHoverEffect>
</template>
```

#### With all options

```vue
<template>
  <SingleHoverEffect
    image1="/images/photo-a.webp"
    image2="/images/photo-b.webp"
    displacement-image="/displacements/displacement.webp"
    :images-ratio="1.5"
    :intensity="0.8"
    :speed-in="1.2"
    :speed-out="0.9"
    easing="power2.out"
    :hover="true"
  />
</template>
```

#### Props

| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
| `image1` | `string` | Yes | — | URL of the first image |
| `image2` | `string` | Yes | — | URL of the second image |
| `displacementImage` | `string` | No | CDN map #1 | URL of the displacement map texture |
| `imagesRatio` | `number` | No | `1.0` | Aspect ratio of the images (width / height) |
| `intensity` | `number` | No | `1` | Distortion intensity fallback for both images |
| `intensity1` | `number` | No | `1` | Distortion intensity for the first image |
| `intensity2` | `number` | No | `1` | Distortion intensity for the second image |
| `angle` | `number` | No | `Math.PI / 4` | Displacement rotation angle fallback |
| `angle1` | `number` | No | — | Rotation angle for the first image |
| `angle2` | `number` | No | — | Rotation angle for the second image |
| `speed` | `number` | No | — | Transition speed fallback for both directions |
| `speedIn` | `number` | No | `1.6` | Duration (seconds) of the enter transition |
| `speedOut` | `number` | No | `1.2` | Duration (seconds) of the exit transition |
| `hover` | `boolean` | No | `true` | Trigger transitions automatically on hover |
| `easing` | `string` | No | `'expo.out'` | GSAP easing string |
| `video` | `boolean` | No | `false` | Treat `image1` / `image2` as `<video>` sources |
| `distortionClass` | `string` | No | `''` | CSS class applied to the canvas container element |

---

### MultipleHoverEffect

Cycles through an array of images with displacement transitions. Requires at least two images.

```vue
<script setup lang="ts">
import { MultipleHoverEffect } from 'vue-hover-effect'
</script>

<template>
  <MultipleHoverEffect
    :images="[
      '/images/photo-a.webp',
      '/images/photo-b.webp',
      '/images/photo-c.webp',
    ]"
    displacement-image="/displacements/displacement.webp"
  />
</template>
```

#### With manual controls

```vue
<template>
  <MultipleHoverEffect
    :images="[
      '/images/photo-a.webp',
      '/images/photo-b.webp',
      '/images/photo-c.webp',
    ]"
    displacement-image="/displacements/displacement.webp"
    :hover="false"
    distortion-class="my-gallery"
  >
    <template #controllers="{ funcs }">
      <button @click="funcs.goPrev()">Previous</button>
      <button @click="funcs.goNext()">Next</button>
    </template>
  </MultipleHoverEffect>
</template>
```

#### Props

| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
| `images` | `string[]` | Yes | — | Array of image URLs (minimum 2) |
| `displacementImage` | `string` | No | CDN map #1 | URL of the displacement map texture |
| `imagesRatio` | `number` | No | `1.0` | Aspect ratio of the images (width / height) |
| `intensity1` | `number` | No | `1` | Distortion intensity for the current image |
| `intensity2` | `number` | No | `1` | Distortion intensity for the next image |
| `angle1` | `number` | No | `Math.PI / 4` | Rotation angle for the current image |
| `angle2` | `number` | No | — | Rotation angle for the next image |
| `speedIn` | `number` | No | `1.2` | Duration (seconds) of the forward transition |
| `speedOut` | `number` | No | `1.0` | Duration (seconds) of the backward transition |
| `hover` | `boolean` | No | `false` | Advance to next image on hover |
| `easing` | `string` | No | `'expo.out'` | GSAP easing string |
| `distortionClass` | `string` | No | `''` | CSS class applied to the canvas container element |

---

## Displacement maps

The `displacementImage` prop is optional. When omitted, the component uses a built-in displacement map served from jsDelivr CDN — no setup required.

The package ships 16 displacement textures. You can reference any of them via the exported `DISPLACEMENT_URLS` array:

```ts
import { DISPLACEMENT_URLS } from 'vue-hover-effect'
// DISPLACEMENT_URLS[0]  →  map #1  (default)
// DISPLACEMENT_URLS[5]  →  map #6
// ...up to DISPLACEMENT_URLS[15]
```

```vue
<template>
  <SingleHoverEffect
    image1="/images/photo-a.webp"
    image2="/images/photo-b.webp"
    :displacement-image="DISPLACEMENT_URLS[4]"
  />
</template>
```

### Host displacement maps yourself

If you prefer to serve the files locally (offline support, no CDN dependency), copy them from the package to your project's `public` folder:

```bash
cp -r node_modules/vue-hover-effect/dist/lib-images/displacements public/displacements
```

Then pass the local path:

```vue
<SingleHoverEffect
  image1="/images/photo-a.webp"
  image2="/images/photo-b.webp"
  displacement-image="/displacements/3.webp"
/>
```

---

## TypeScript

All prop types are exported and can be imported for use in your own components or composables:

```ts
import type {
  SingleHoverEffectProps,
  MultiImageEffectProps,
  SingleHoverEffectOptions,
  MultiImageEffectOptions,
  SingleHoverEffectController,
  MultiImageEffectController,
} from 'vue-hover-effect'
```

`SingleHoverEffectProps` and `MultiImageEffectProps` are the component-facing types (without `parent`, which is handled internally). `SingleHoverEffectOptions` / `MultiImageEffectOptions` are the full option types accepted by the underlying factory functions.

---

## Nuxt

The components are SSR-safe out of the box. Three.js and GSAP are dynamically imported inside `onMounted`, so they are never included in the server bundle and no WebGL code runs during server-side rendering.

No extra configuration is needed. Just import and use:

```vue
<!-- pages/index.vue -->
<script setup lang="ts">
import { SingleHoverEffect } from 'vue-hover-effect'
</script>

<template>
  <SingleHoverEffect
    image1="/images/photo-a.webp"
    image2="/images/photo-b.webp"
    displacement-image="/displacements/displacement.webp"
  />
</template>
```

If you prefer an explicit client-only guarantee, Nuxt's built-in `<ClientOnly>` wrapper also works:

```vue
<template>
  <ClientOnly>
    <SingleHoverEffect
      image1="/images/photo-a.webp"
      image2="/images/photo-b.webp"
      displacement-image="/displacements/displacement.webp"
      :hover="true"
    />
  </ClientOnly>
</template>
```

---

## Global registration

You can register both components globally in your Vue app:

```ts
// main.ts
import { createApp } from 'vue'
import { SingleHoverEffect, MultipleHoverEffect } from 'vue-hover-effect'
import App from './App.vue'

const app = createApp(App)
app.component('SingleHoverEffect', SingleHoverEffect)
app.component('MultipleHoverEffect', MultipleHoverEffect)
app.mount('#app')
```

For Nuxt, register them in a plugin:

```ts
// plugins/vue-hover-effect.client.ts
import { SingleHoverEffect, MultipleHoverEffect } from 'vue-hover-effect'

export default defineNuxtPlugin((nuxtApp) => {
  nuxtApp.vueApp.component('SingleHoverEffect', SingleHoverEffect)
  nuxtApp.vueApp.component('MultipleHoverEffect', MultipleHoverEffect)
})
```

---

## Using the factory functions directly

The underlying factory functions are also exported for headless usage without the Vue components:

```ts
import { createSingleHoverEffect, createMultiImageEffect } from 'vue-hover-effect'

const controller = createSingleHoverEffect({
  parent: document.querySelector('#my-container') as HTMLElement,
  image1: '/images/photo-a.webp',
  image2: '/images/photo-b.webp',
  displacementImage: '/displacements/displacement.webp',
  speedIn: 1.2,
  speedOut: 0.9,
  hover: true,
})

// Manual control
controller?.next()     // transition to image2
controller?.previous() // transition back to image1
controller?.resize()   // recalculate on container resize
```

---

## License

MIT — [Sultonkhon](https://sulton.dev/)
