Prism Effect Slider with Canvas

A tutorial on how to create a slider with a "prism" effect using HTML5 canvas globalCompositeOperation and a layering technique.

PrismEffectSlider

View demo Download source

Today we’d like to show you how to build a simple slider with an interesting “prism” effect. The idea is to place a shape in front of the slider and “reflect” the images of each slide, in order to create the illusion of a prism. We’ll be using the HTML5 canvas element and plain JavaScript.

The demo is supported in all major browsers, including Internet Explorer 9.

The Technique

The technique used to create the effect is actually pretty simple: first we’ll load and render the mask, which could be either an SVG or a PNG image (the important thing is that it has to have transparency), then we will render the slide’s image and apply the globalCompositeOperation.

The ‘globalCompositeOperation’ canvas property lets you define how an image should be drawn over another image.
By default, when we draw an image over existing pixels, the new image just replaces those pixels.

By using globalCompositeOperation we will define how the image (source) is drawn onto the mask (destination). There are twelve composite operations and the one that suits our case is source-atop which will display the image on top of the mask and will not show anything outside the area defined by the mask.

The key to the effect is to draw the mask before the image, otherwise, since all pixels are empty initially, the ‘source-atop’ operation will not take any effect.

In order to create the full effect we’ll need a layer for each part of our prism that we want to reflect the image in different ways. To do this we’ll use a layering technique, which means that we’ll have a separate canvas element for each layer. Then we’ll simply position them absolutely and place the canvases on top of each other.

The reason why we would need to layer multiple canvases is because by W3C definition there is only one CanvasRenderingContext2D object per canvas, so in order to apply different effects on distinct parts (masks) of the prism at the same time we’ll need multiple contexts.

We’ll have a look at this in more detail later. Let’s start with the HTML structure and some styles.

PrismEffectSlider01

The HTML and CSS

The only bits of HTML that we need for the slideshow initially, are a division where we append the canvases and an unordered list for the navigation bullets:

  
  <div class="container">
    <ul class="navigation"></ul>
  </div>
  

And here it is the required CSS:


.prism-slider {
  width: 1200px;
  max-width: 100%;
  height: 0;
  padding-bottom: 48%;
  position: relative;
}

.prism-slider canvas {
  width: 100%;
  position: absolute;
  top: 0;
  left: 0;
}

.navigation {
  width: 100%;
  position: absolute;
  bottom: 5%;
  text-align: center;
  list-style: none;
  z-index: 1;
}

.navigation li {
  border: 3px solid #eceff1;
  width: 18px;
  height: 18px;
  margin: 0 5px;
  background: #52525a;
  border-radius: 50%;
  display: inline-block;
  cursor: pointer;
}

.navigation .active {
  background: #eceff1;
}

We also need to pre-load the external resources for images and masks before the initialization of the slider, otherwise we’ll get an empty slider until the images are loaded. To do this we’ll use a division with the class “cache” which will have have an img element for each image/mask:

  
  <div class="cache">
    <!-- masks -->
    <img src="img/masks/cube-a.svg">
    <img src="img/masks/cube-b.svg">
    <img src="img/masks/cube-c.svg">
    <!-- photos -->
    <img src="img/shoreditch-a.jpg">
    <img src="img/shoreditch-b.jpg">
    <img src="img/shoreditch-c.jpg">
  </div>
  

Then we simply hide it with display: none and initialize the plugin on window.onload.

The JavaScript

The JavaScript is split in two modules: slideshow.js which acts as a controller, and PrismSlider.js which is the class responsible for the creation and rendering of each canvas layer.

Let’s first have a look at the main JavaScript of the Prism Slider and its first method that we call:


/**
 * Create canvas element, get context, set sizes
 * and append to main container.
 */
PrismSlider.prototype.addCanvas_ = function() {

  this.canvas = document.createElement('canvas');

  this.context = this.canvas.getContext('2d');

  this.canvas.width = this.settings.container.sizes.w;
  this.canvas.height = this.settings.container.sizes.h;

  this.container.appendChild(this.canvas);
};

Now that we have a canvas element as mentioned earlier we need to add and draw the mask:


/**
 * Add Mask.
 * Call loadImage method with path and callback,
 * once the loading will be completed we'll replace
 * the string path (this.mask.source) reference with
 * the actual <img> object.
 */
PrismSlider.prototype.addMask_ = function() {

  var path = this.mask.source;
  var callback = this.renderMask_.bind(this);
  // Replace image path with <img> object.
  this.mask.source = this.loadImage_(path, callback);
};

/**
 * Draw mask.
 * Calculate center position and draw mask, width and height at 100% of the container sizes.
 */
PrismSlider.prototype.renderMask_ = function() {
  var centerX = this.canvas.width / 2 - this.settings.container.sizes.w / 2;
  var centerY = this.canvas.height / 2 - this.settings.container.sizes.h / 2;

  var w = this.settings.container.sizes.w;
  var h = this.settings.container.sizes.h;

  this.context.drawImage(this.mask.source, centerX, centerY, w, h);
};

In the snippets above we used the loadImage method, since the browser has already cached all images at this point (because the script started after the document loaded) we can get the SVG mask and proceed without delaying the execution.


/**
 * Load image source from path and fire given callback,
 * return loaded <img> object.
 * @param  {String}   path     The path of the file.
 * @param  {Function} callback The callback to be executed when loading completed.
 * @return {Object}            The JavaScript <img> object.
 */
PrismSlider.prototype.loadImage_ = function(path, callback) {

  var image = new Image();

  image.onload = callback;

  // Path always after callback.
  image.src = path;

  return image;
};

Now that we added and drew the mask let’s add some slides in a similar way:


/**
 * Add Slides.
 * Call loadImage method for each image path in the slides array,
 * only when it's the first slide pass render callback,
 * when loading completed replace image path with  object.
 */
PrismSlider.prototype.addSlides_ = function() {

  this.slides.forEach(function(path, i) {
    // Render only first slide.
    var callback = (i === 0) ? this.renderSlide_.bind(this, i) : null;
    // Replace image path with  object.
    this.slides[i] = this.loadImage_(path, callback);

  }, this);
};

The rendering callback is a little bit more complex:

  • we get a couple of arguments, the index from the loop addSlides_ and a progress value that we don’t need for now but we make sure that it’s not going to be anything different from a number (like an event derived from image.onload).
  • Notice how we calculate the X position and remember that i is a number between 0 and the length of the slides that we will use.
  • We also apply the composite operation only if we have a mask to render.
  • Finally, we apply some effects right before the drawing.

The code:


/**
 * Draw Slide.
 * Calculate frame position, apply composite operation
 * and effects on the image when there is a mask.
 * @param  {Number} i        The index used to get the img to render.
 * @param  {Number} progress The progress value.
 */
PrismSlider.prototype.renderSlide_ = function(i, progress) {

  // Set progress to 0 if Not a Number or undefined.
  progress = (isNaN(progress) || progress === undefined) ? 0 : progress;

  // Get img object from array.
  var slide = this.slides[i];

  // Calculate X position.
  var x = this.canvas.width * (i - progress);
  var y = 0;

  var w = this.canvas.width;
  var h = this.canvas.height;

  // Apply composite operation.
  if (this.mask) this.context.globalCompositeOperation = 'source-atop';

  this.context.save();

  if (this.mask) this.applyEffects_();

  // Draw slide.
  this.context.drawImage(slide, x, y, w, h);

  this.context.restore();
};

The applyEffects method will just select one or both effects which will change the context before the drawing and will distinguish the image inside the mask from the main image on the slider.


/**
 * Apply effects.
 * Check mask object parameters and select effect.
 */
PrismSlider.prototype.applyEffects_ = function() {
  if (this.mask.effects.flip) this.flip_();
  if (this.mask.effects.rotate > 0) this.rotate_();
};


/**
 * Flip Effect.
 */
PrismSlider.prototype.flip_ = function() {
  // Get axes.
  var axes = this.mask.effects.flip;

  if (axes === 'X') {
    // Invert x position.
    this.context.translate(this.canvas.width, 0);
    // Flip context horizontally.
    this.context.scale(-1, 1);
  }

  if (axes === 'Y') {
    // Invert y position.
    this.context.translate(0, this.canvas.height);
    // Flip context vertically.
    this.context.scale(1, -1);
  }
};


/**
 * Rotate Effect.
 */
PrismSlider.prototype.rotate_ = function() {
  // Convert degrees to radians.
  var radians = this.mask.effects.rotate * (Math.PI / 180);
  // Move registration point to the center of the canvas.
  this.context.translate(this.canvas.width / 2, this.canvas.height / 2);
  // Apply rotation.
  this.context.rotate(radians);
  // Move registration point back to the top left corner of canvas.
  this.context.translate(-this.canvas.width / 2, -this.canvas.height / 2);
};

Let’s have a look at the slideshow controller.

At this point we have PrismSlider.js which can be instantiated and it will generate a canvas element, load the images and render both, mask and slide.

To keep things clean we’ll add another script which we’ll use as main controller to give instructions to the PrismSlider.

The code necessary for this is in slideshow.js. Let’s take a look at the configuration variables:


/**
 * Enum navigation classes, attributes and
 * provide navigation DOM element container.
 */
var navigation = {
  selector: '.navigation',
  element: null,
  bullet: 'li',
  attrs: {
    active: 'active',
    index: 'data-index'
  }
};

/**
 * Enum main element, sizes and provide
 * main DOM element container.
 * @type {Object}
 */
var container = {
  selector: '.container',
  element: null,
  sizes: {
    w: 1200,
    h: 780
  }
};

/**
 * Set of images to be used.
 * @type {Array}
 */
var slides = [
  'img/shoreditch-a.jpg',
  'img/shoreditch-b.jpg',
  'img/shoreditch-c.jpg',
  'img/graffiti-a.jpg',
  'img/graffiti-b.jpg',
  'img/graffiti-c.jpg'
];

/**
 * Set of masks with related effects.
 * @type {Array}
 */
var masks = [
  {
    source: 'img/masks/cube-a.svg',
    effects: {
      flip: 'Y',
      rotate: 167 // degrees
    }
  },
  {
    source: 'img/masks/cube-b.svg',
    effects: {
      flip: 'X',
      rotate: 90 // degrees
    }
  },
  {
    source: 'img/masks/cube-c.svg',
    effects: {
      flip: false,
      rotate: 13 // degrees
    }
  }
];

/**
 * Set global easing.
 * @type {Function(currentTime)}
 */
var easing = Easing.easeInOutQuint;

/**
 * Set global duration.
 * @type {Number}
 */
var duration = 2000;

/**
 * Container for PrismSlider instances.
 * @type {Object}
 */
var instances = {};

Notice the the last “instances” variable: it’s an empty object that we’ll use as a ‘container’ in order to keep a reference to each canvas, or better, to the PrismSlider instance and its methods.

In the init function steps are the following:


/**
 * Init.
 */
function init() {

  getContainer_();

  initSlider_();

  initPrism_();

  addNavigation_();

  addEvents_();
}

/**
 * Get main container element, and store in container element.
 */
function getContainer_() {
  container.element = document.querySelector(container.selector);
}

/**
 * Init Slides.
 * Create and initialise main background slider (first layer).
 * Since we'll use this as main slider no mask is given.
 */
function initSlider_() {

  instances.slider = new PrismSlider({
    container: container,
    slides: slides,
    mask: false,
    duration: duration,
    easing: easing
  });

  // Initialise instance.
  instances.slider.init();
}

/**
 * Init Masks.
 * Loop masks variable and create a new layer for each mask object.
 */
function initPrism_() {

  masks.forEach(function(mask, i) {
    // Generate reference name.
    var name = 'mask_' + i;

    instances[name] = new PrismSlider({
      container: container,
      slides: slides,
      mask: mask, // Here is the mask object.
      duration: duration,
      easing: easing
    });

    // Initialise instance.
    instances[name].init();
  });
}

/**
 * Add Navigation.
 * Create a new bullet for each slide and add it to navigation (ul)
 * with data-index reference.
 */
function addNavigation_() {
  // Store navigation element.
  navigation.element = document.querySelector(navigation.selector);

  slides.forEach(function(slide, i) {

    var bullet = document.createElement(navigation.bullet);

    bullet.setAttribute(navigation.attrs.index, i);

    // When it's first bullet set class as active.
    if (i === 0) bullet.className = navigation.attrs.active;

    navigation.element.appendChild(bullet);
  });
}

/**
 * Add Events.
 * Bind click on bullets.
 */
function addEvents_() {
  ...
}

In initSlider we create a new PrismSlider instance with mask set to false in order to have a full background layer.

In initPrism we loop through the masks array defined above and for each mask we create a new instance and pass the mask parameters.

Now the only thing left to do is the animation. When a click on a navigation bullet is captured, the function slideAllTo is called:


/**
 * Add Events.
 * Bind click on bullets.
 */
function addEvents_() {
  // Detect click on navigation elment (ul).
  navigation.element.addEventListener('click', function(e) {

    // Get clicked element.
    var bullet = e.target;

    // Detect if the clicked element is actually a bullet (li).
    var isBullet = bullet.nodeName === navigation.bullet.toUpperCase();

    // Check bullet and prevent action if animation is in progress.
    if (isBullet && !instances.slider.isAnimated) {
      // Remove active class from all bullets.
      for (var i = 0; i < navigation.element.childNodes.length; i++) {
        navigation.element.childNodes[i].className = '';
      }
      // Add active class to clicked bullet.
      bullet.className = navigation.attrs.active;

      // Get index from data attribute and convert string to number.
      var index = Number(bullet.getAttribute(navigation.attrs.index));

      // Call slideAllTo method with index.
      slideAllTo_(index);
    }

  });
}

/**
 * Call slideTo method of each instance.
 * In order to sync sliding of all layers we'll loop through the
 * instances object and call the slideTo method for each instance.
 * @param {Number} index The index of the destination slide.
 */
function slideAllTo_(index) {
  // Loop PrismSlider instances.
  for (var key in instances) {
    if (instances.hasOwnProperty(key)) {
      // Call slideTo for current instance.
      instances[key].slideTo(index);
    }
  }
}

As described in the comment, slideAllTo will loop through the instances and call the PrismSlider.prototype.slideTo method.

So let's add this method to PrismSlider.js together with animate_ and ticker_ which are used to run the sliding:


/**
 * Slide To.
 * @param {Number} index The destination slide index.
 */
PrismSlider.prototype.slideTo = function(index) {
  // Prevent when animation is in progress or if same bullet is clicked.
  if (this.isAnimated || index === this.slidesIndex) return;

  // Store current (start) index.
  this.prevSlidesIndex = this.slidesIndex;
  // Set destination (end) index.
  this.slidesIndex = index;

  // Calculate how many slides between current (start) and destination (end).
  var indexOffset = (this.prevSlidesIndex - this.slidesIndex) * -1;
  // Store offset always converted to positive number.
  this.indexOffset = (indexOffset > 0) ? indexOffset : indexOffset * -1;

  // Kickstart animation.
  this.animate_();
};

In the above method the key steps are when we update the indexes and when we calculate how many slides we need to animate with indexOffset.

The lasts two methods are animate which simply calculate the end time by adding the duration to Date.now(), and ticker, which is basically the method that we call with requestAnimationFrame.


/**
 * Animate.
 */
PrismSlider.prototype.animate_ = function() {

  // Calculate end time.
  var end = Date.now() + this.duration;

  // Mark animation as in progress.
  this.isAnimated = true;
  // Kickstart frames ticker.
  this.ticker_(end);
};

/**
 * Ticker called for each frame of the animation.
 * @param {Number} end The end time of the animation.
 */
PrismSlider.prototype.ticker_ = function(end) {

  // Start time.
  var now = Date.now();
  // Update time left in the animation.
  var remaining = end - now;

  // Retrieve easing and multiply for number of slides between stars
  // and end, in order to jump through N slides in one ease.
  var easing = this.easing(remaining / this.duration) * this.indexOffset;

  var i, progress, slide;

  // Select sliding direction.
  if (this.slidesIndex > this.prevSlidesIndex) {

    // Sliding forward.
    progress = this.slidesIndex - easing;

    // Loop offset and render slides from start to end.
    for (i = 0; i <= this.indexOffset; i++) {
      slide = this.slidesIndex - i;
      this.renderSlide_(slide, progress);
    }

  } else {

    // Sliding backward.
    progress = this.slidesIndex + easing;

    // Loop offset and render slides from start to end.
    for (i = 0; i <= this.indexOffset; i++) {
      slide = this.slidesIndex + i;
      this.renderSlide_(slide, progress);
    }
  }

  // Under 50 milliseconds reset and stop.
  if (remaining < 50) {
    // Set default value.
    this.indexOffset = 1;
    // Make sure slide is perfectly aligned.
    this.renderSlide_(this.slidesIndex);
    // Mark animation as finished.
    this.isAnimated = false;
    // Stop.
    return;
  }

  // Kickstart rAF with updated end.
  window.requestAnimationFrame(this.ticker_.bind(this, end));
};

Conclusion

I hope you enjoyed this tutorial and find it useful!

Please note that in a real life case it would be better to preload the images directly with JavaScript by extending the code presented above in order to accomplish a more dynamic optimization.

Download the full source and have a look at the files; everything is well documented and, I hope, pretty straightforward to understand and adapt to your needs.

View demo Download source

Previous:
Next:

Tagged with:

Claudio is a freelance creative developer based in London. He develops modern, highly interactive websites with cutting edge technologies. In his spare time he has fun with web experiments and enjoys travelling.

View all contributions by

Website: http://claudiocalautti.cc/

Related Articles

Feedback 27

  1. 1

    I will be glad to subscribe to your newsletter if you promise more articles of the same caliber :)

Follow this discussion

Leave a Comment

You may use these HTML tags and attributes: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <s> <strike> <strong>