Liquid Distortion Effects

A slideshow with liquid distortion effects in WebGL powered by PixiJS and GSAP.

Today we’d like to share an interesting distortion effect with you. The main concept of this demo is to use a displacement map in order to distort an underlying image, giving it different types of effects. To demonstrate the liquid-like transitions between images, we’ve created a slideshow.

What a displacement map generally does, is using an image as a texture, that is later applied to an object, giving the illusion that the underlying object is wrapped around that texture. This is a technique commonly used in many different areas, but today we’ll explore how this can be applied to a simple image slideshow.

We’ll be using PixiJS as our renderer and filtering engine and GSAP for our animations.

Getting Started

In order to have a displacement effect, you need a displacement map texture. In this demo’s code we’ve provided with different types of textures you can use, but of course you can create one of your own, for example by using Photoshop’s render tool. Keep in mind that this image’s dimensions affect the end result, so playing around with differently sized textures, might give you different effect looks.

A general rule of thumb is that your texture image should be a power of 2 sized texture. What this means is that its width and height can be doubled-up or divided-down by 2. This ensures that your texture is optimized to run fast, without consuming too much memory. In other words the suggested dimensions for your texture image (width and/or height), would be: 8, 16, 32, 64, 128, 256, 512, 1024, 2048 etc.

For the demos, we’ve created a slideshow that, when navigating, shows the effect as a transition on the slides. We’ll also add some other options, but we’ll just go through the main idea of the distortion effect.


Our base markup for this demo is really minimal. We just need the navigation buttons for our slider and a wrapper for the slides. We use this wrapper to pass our slides to our component, therefore we hide it by default with CSS. This markup to JS approach may simplify the task of adding images to the slideshow, when working in a more dynamic environment. However, if it suits you better, you could just easily pass them as an array, upon initializing.

<div class="slide-wrapper">
	<div class="slide-item">
		<h3 class="slide-item__title">Slide 1</h3>
		<img src="..." class="slide-item__image">
	<div class="slide-item">
		<h3 class="slide-item__title">Slide 2</h3>
		<img src="..." class="slide-item__image">
	<div class="slide-item">
		<h3 class="slide-item__title">Slide 3</h3>
		<img src="..." class="slide-item__image">

<a href="#" class="scene-nav scene-nav--prev" data-nav="previous">PREV</a>
<a href="#" class="scene-nav scene-nav--next" data-nav="next">NEXT</a>


In our CSS we hide our wrapper and position the navigation buttons at the left and right edges of the viewport.

.slide-wrapper {
  display: none;

.scene-nav {
  position: fixed;
  top: 50%;
  transform: translateY(-50%);
  z-index: 10;
  display: inline-block;

.scene-nav--next {
  right: 2%;

.scene-nav--prev {
  left: 2%;

Setting up the stage

The idea is fairly simple: we add all of our slides into a container, apply the displacement filter and render. Then, when clicking the navigation buttons, we set the alpha property of the current image to 0, set the next one’s to 1 and tweak the displacement filter while navigating.

var renderer            = new PIXI.autoDetectRenderer();
var stage               = new PIXI.Container();
var slidesContainer     = new PIXI.Container();
var displacementSprite  = new PIXI.Sprite.fromImage( displacementImage );
var displacementFilter  = new PIXI.filters.DisplacementFilter( displacementSprite );

// Add canvas to the HTML
document.body.appendChild( renderer.view );

// Add child container to the stage
stage.addChild( slidesContainer );

// Set the filter to stage
stage.filters = [displacementFilter];        

// We load the sprites to the slides container and position them at the center of the stage
// The sprites array is passed to our component upon its initialization
// If our slide has text, we add it as a child to the image and center it
function loadPixiSprites( sprites ) {
  for ( var i = 0; i < sprites.length; i++ ) {
    var texture = new PIXI.Texture.fromImage( sprites[i] );
    var image   = new PIXI.Sprite( texture );

    if ( texts ) {

      // Base styles for our Text
      var textStyle = new PIXI.TextStyle({
        fill: '#ffffff', 
        wordWrap: true,
        wordWrapWidth: 400

      var text = new PIXI.Text( texts[i], textStyle);
      image.addChild( text );
      // Center each to text to the image
      text.x = image.width / 2;
      text.y = image.height / 2;      
    image.x = renderer.width / 2;
    image.y = renderer.height / 2;            

    slidesContainer.addChild( image );

That would be the most basic setup you’d need in order for the scene to be ready. Next thing we want to do is handle the clicks of the navigation buttons. Like we said, when the user clicks on the next or previous button, we change the alpha property of the according slide and tweak our Displacement Filter. We use a simple timeline for this, which you could of course customize accordingly.

// We listen at each navigation element click and call the move slider function 
// passing it the index we want to go to
var currentIndex = 0;
var slideImages = slidesContainer.children;
var isPlaying = false;  

for ( var i = 0; i < nav.length; i++ ) {
  var navItem = nav[i];

  navItem.onclick = function( event ) {

    // Make sure the previous transition has ended
    if ( isPlaying ) {
      return false;

    if ( this.getAttribute('data-nav') === 'next' ) {

      if ( that.currentIndex >= 0 && that.currentIndex < slideImages.length - 1 ) {
        moveSlider( currentIndex + 1 );
      } else {
        moveSlider( 0 );

    } else {

      if ( that.currentIndex > 0 && that.currentIndex < slideImages.length ) {
        moveSlider( currentIndex - 1 );
      } else {
        moveSlider( spriteImages.length - 1 );


    return false;


// Our transition between the slides
// On our timeline we set the alpha property of the relevant slide to 0 or 1 
// and scale out filter on the x & y axis accordingly
function moveSlider( newIndex ) {

	isPlaying = true;

	var baseTimeline = new TimelineMax( { onComplete: function () {
		that.currentIndex = newIndex;
		isPlaying = false;
		.to(displacementFilter.scale, 1, { x: 200, y: 200  })
		.to(slideImages[that.currentIndex], 0.5, { alpha: 0 })
		.to(slideImages[newIndex], 0.5, { alpha: 1 })          
		.to(displacementFilter.scale, 1, { x: 20, y: 20 } );


Finally, we have to render our scene and optionally add some default animations.

// Use Pixi's Ticker class to render our scene 
// similar to requestAnimationFrame
var ticker = new PIXI.ticker.Ticker();
ticker.add( function( delta ) {
	// Optionally have a default animation
	displacementSprite.x += 10 * delta;
	displacementSprite.y += 3 * delta;
	// Render our stage
	renderer.render( stage );


Working Demo

This should sum up the most basic parts of how the demo works and give you a good starting point if you want to edit it according to your needs. However, if you don’t want to mess with too much code and need a quick working demo to play on your own, there are several options you could use when you initialize the component. So just include the script on your page and add the following code wherever you want to show your slideshow. Play around with different values to get started and don’t forget to try out different displacement map textures for different effects.

// Select all your images
var spriteImages = document.querySelectorAll( '.slide-item__image' ); 
var spriteImagesSrc = [];
var texts = [];

for ( var i = 0; i < spriteImages.length; i++ ) {
  var img = spriteImages[i];
  // Set the texts you want to display to each slide 
  // in a sibling element of your image and edit accordingly
  if ( img.nextElementSibling ) {
  } else {
  spriteImagesSrc.push( img.getAttribute('src' ) );

// Initialise the Slideshow
var initCanvasSlideshow = new CanvasSlideshow({
  // pass the images you want as an array
  sprites: spriteImagesSrc, 
  // if you want your slides to have title texts, pass them as an array
  texts: texts, 																	
  // set your displacement texture
  displacementImage: '', 
  // optionally start with a default animation 
  autoPlay: true, 

  // [x, y] controls the speed for your default animation
  autoPlaySpeed: [10, 3], 
  // [x, y] controls the effect amount during transitions
  displaceScale: [200, 70], 

  // choose whether or not you slideshow will take up all the space of the viewport
  fullScreen: true,

  // If you choose to not have a fullscreen slideshow, set the stage's width & height accordingly
  stageWidth: 800,
  stageHeight: 600,

  // add you navigation element. Should have a 'data-nav' attribute with a value of next/previous
  navElement: document.querySelectorAll( '.scene-nav' ),

  // will fit the filter bounding box to the renderer
  displaceAutoFit: false



Last thing we want to do is optionally make our stage interactive. That is instead of auto playing, have our effect interact with our mouse. Just set the the interactive property to be true and play around with your mouse.

var initCanvasSlideshow = new CanvasSlideshow({
  interactive: true

In all mouse interactions we listen for the corresponding event, and based on the event data, we scale our displacement event respectively. It looks like this:

// Set our container to interactive mode
slidesContainer.interactive = true;
slidesContainer.buttonMode = true;       

// Our animation
var rafID, mouseX, mouseY;

function rotateSpite() {
  displacementSprite.rotation += 0.001;
  rafID = requestAnimationFrame( rotateSpite );

slidesContainer.pointerover = function( mouseData ){
  mouseX =;
  mouseY =; displacementFilter.scale, 1, { x: "+=" + Math.sin( mouseX ) * 100 + "", y: "+=" + Math.cos( mouseY ) * 100 + ""  });   
slidesContainer.pointerdown = function( mouseData ){
  mouseX =;
  mouseY =;   displacementFilter.scale, 1, { x: "+=" + Math.sin( mouseX ) * 1200 + "", y: "+=" + Math.cos( mouseY ) * 200 + ""  });   

slidesContainer.pointerout = function( mouseData ){ displacementFilter.scale, 1, { x: 0, y: 0 });
  cancelAnimationFrame( rafID );

slidesContainer.pointerup = function( mouseData ){ displacementFilter.scale, 1, { x: 0, y: 0 });                      
  cancelAnimationFrame( rafID );

Simple as that! Hope this demo gives you a good starting point to play around with different filters and make it easy for you to create your own.

Tagged with:

Yannis Yannakopoulos

Interactive Developer. Currently exploring Generative Art, WebGL, GLSL & Web Audio. Musicing at The Blimp!.

Stay in the loop: Get your dose of frontend twice a week

๐Ÿ‘พ Hey! Looking for the latest in frontend? Twice a week, we'll deliver the freshest frontend news, website inspo, cool code demos, videos and UI animations right to your inbox.

Zero fluff, all quality, to make your Mondays and Thursdays more creative!

Feedback 36

Comments are closed.
  1. It is bugged on latest Safari on High Sierra, once you click to move to the next slide, the left slide arrow moves to the centre of the image.

  2. Hi,
    is it possible to animate a fullwidth div (as a background) instead of an image?


    ’cause it seems to doesn’t work.

  3. Hey, realy nice work, many thanks for sharing!

    Is it posible autoplay between slides? That is passing from one slide item to next slide item without click/tap on nav?

    Becouse, in this case, autoPlay: true is the effect inself.

    thank you

    • Hey,

      What you can do for that is to simply add the following code just after the CLICK HANDLERS function in your main.js file around line 250

      that.moveSlider( that.currentIndex + 1 );
      }, 6000);

    • I meant this :

      if ( that.currentIndex >= 0 && that.currentIndex < slideImages.length – 1 ) {
      that.moveSlider( that.currentIndex + 1 );
      } else {
      that.moveSlider( 0 );
      }, 6000);

  4. The demo is awesome, I am doing the website for an artist and this effect will perfectly work over the header. I tried downloading the demo file but nothing is wkring. When I am opening index.html, browser is not loading any image. I haven’t change anything ( Just downloaded the file and open it on chrome)

  5. ๐Ÿ™ Not working? It doesn’t apply any effect when sliding, only fadeOut and fadeIn the next picture. Tested in newest Chrome and Edge, same thing.

  6. Love this! Have you discovered a way for the slides to transition into each other? Without the black in between? Or to change the black to another color? It’s a bit dark for my photos.

  7. Hi ๐Ÿ™‚

    Wonderfull effect ! i played with it, however i don’t understand this: ` displacementFilter.scale, โ€ฆ`

    displacementFilter.scale returns an object `Point{x: 20, y: 20}`. How Tweenmax can selects this ? i tried to replace GSAP TweenMax with AnimeJS using the same behavior but it doesnt work. Have you an idea ?

    • ok, i figured it out with :

      targets: displacementFilter.scale,
      x: ‘+=’ + Math.sin(500) * 1000 + ”,
      y: ‘+=’ + Math.cos(500) * 1000 + ”,
      duration: 2000

      AnimeJS is lighter than GSAP ๐Ÿ˜‰

  8. Is there a certain dimension that the image needs to be to fit the full width of the page using this effect? All of the images i have used have a margin on the left side and there is just a black space there. I have the images set right now to 2000×1333. The margin is not visible on smaller screen resolutions such as a 13″ macbook pro, but on the wider PC monitors i use at work the margins become very oobvious.

  9. I’m trying to display text on top of the images in demo5, however I am not able to get that at all. I added ‘texts: texts’, initializing parameter texts to the array defined previously in call to ‘var initCanvasSlideshow = new CanvasSlideshow({‘, however text still doesn’t render. Stepping through main.js I’ve checked the value of the rTexts variable which is added as a child of the image and it does contain the text I am trying to display. Please help me locate where I am going wrong. You can find my code at