Table of Contents

Interactive 3D Boxart

Below are the examples, some notes, and the code to some interactive boxarts that I made for Iron Fist Cube 2 in 2009 and 2015.

Examples

Below are some examples using cover art for Iron Fist Cube 2.

Iron Fist 2015

Iron Fist 2009

About

Implemented Changes

  • Added support to pinch to zoom in-page to view model closer and allow panning (See "Known Bugs" below).

Possible Upgrades

Possible upgrades could include switching to three.js's TrackballControls as shown in this example.


Known Issues

General Issues

  • On touch input devices, switching between native window-based pinch-to-zoom and trackball rotation capture requires a separate successive swipe or pinch.
  • On iOS touch devices, the touch area for the box is limited to a plane, and when orthogonal to the screen can be too thin to register a hit.
  • On Chrome in Windows 10 and iOS Safari, -webkit-box-reflect reflections only render whats on the screen, and disapear while scrolling off the page. Disable them if its a problem.
  • On Firefox and Edge in Windows 10, -webkit-box-reflect reflections are not supported. This is the reason for the empty padding at the bottom. Currently no checks are in place to check for this feature support to selectively enable this padding.

Machine-Specific Issues

A list of computer configurations and exibited issues is shown below.

ConfigurationIssues
Acer Nitro 5 w/ R5 2500U (using integrated Vega 8 and amd gpu driver)
OpenSUSE Tumbleweed (GNU + Linux)
Firefox Version 85.0.2 (64-bit)
Boxart has weird clipping that limits view to top left quarter, and flicker while rotating.

Code

HTML

Note that in the above examples I do not make use the #stage-target, as I like having the end user interact with the boxart directly in a physical-like manner as if they would in real life. If you choose to do so as well you will need to remove or comment-out the <div id="stage-target" class="stage-target"></div> and will need to carry through this exlusion in the in-page JavaScript below.

<div class="boxart-wrapper">
  <h3 style="text-align:center;color:#fff;">Boxart Title</h3>
  <div id="stage" class="boxart-stage">
    <div class="boxart">
      <div class="boxart__side boxart__side--front">
        <img src="/image/path/to/image.front.png"/>
      </div>
      <div class="boxart__side boxart__side--top">
        <img src="/image/path/to/image.top.png"/>
      </div>
      <div class="boxart__side boxart__side--bottom">
        <img src="/image/path/to/image.bottom.png"/>
      </div>
      <div class="boxart__side boxart__side--back">
        <img src="/image/path/to/image.back.png"/>
      </div>
      <div class="boxart__side boxart__side--left">
        <img src="/image/path/to/image.left.png"/>
      </div>
      <div class="boxart__side boxart__side--right">
        <img src="/image/path/to/image.right.png"/>
      </div>
    </div>
    <div id="stage-target" class="stage-target"></div>
  </div>
</div>

CSS

.boxart-stage {
  -webkit-box-reflect: below -120px -webkit-gradient(linear, left top, left bottom, from(transparent), color-stop(0.5, transparent), to(#ffffff));
  display: block;
  left: 50%;
  margin: 20px 0 0 -300px;
  -webkit-perspective: 700px;
     -moz-perspective: 700px;
      -ms-perspective: 700px;
       -o-perspective: 700px;
          perspective: 700px;
  -webkit-perspective-origin: 50% 50%;
     -moz-perspective-origin: 50% 50%;
       -o-perspective-origin: 50% 50%;
      -ms-perspective-origin: 50% 50%;
          perspective-origin: 50% 50%;
  position: relative;
  width:  600px;
  height: 600px;
}

.boxart-stage__target {
  position: absolute;
  top: 0;
  bottom: 0;
  left: 0;
  right: 0;
}

.boxart-wrapper {
  border-radius: 8px;
  padding: 16px;
  padding-bottom: 250px;
}
.boxart-wrapper--l {
  background: #001;
  background: #0018;
}
.boxart-wrapper--r {
  background: #110;
  background: #1108;
}

@media (min-width: 768px) { /* between Tablet & Desktop */
  .boxart-wrapper--l, .boxart-wrapper--r {
    // width: 50%;
    width: 49%;
  }
  .boxart-wrapper--l {
    float: left;
    margin-right: 1%;
  }
  .boxart-wrapper--r {
    float: right;
    margin-left: 1%;
  }
}

.boxart {
  cursor: move;
  margin-top:  -177px;
  margin-left: -125px;
  position: absolute;
  top:  50%;
  left: 50%;
  -webkit-transform: rotate3d(1,0,0,-40deg);
     -moz-transform: rotate3d(1,0,0,-40deg);
      -ms-transform: rotate3d(1,0,0,-40deg);
       -o-transform: rotate3d(1,0,0,-40deg);
          transform: rotate3d(1,0,0,-40deg);
  -webkit-transform-style: preserve-3d;
     -moz-transform-style: preserve-3d;
      -ms-transform-style: preserve-3d;
       -o-transform-style: preserve-3d;
          transform-style: preserve-3d;
  width:  250px;
  height: 355px;
}

.boxart__side {
  -webkit-backface-visibility: hidden;
     -moz-backface-visibility: hidden;
      -ms-backface-visibility: hidden;
       -o-backface-visibility: hidden;
          backface-visibility: hidden;
  -webkit-box-shadow: inset 0 0 15px rgba(0, 0, 0, 0.7);
     -moz-box-shadow: inset 0 0 15px rgba(0, 0, 0, 0.7);
       -o-box-shadow: inset 0 0 15px rgba(0, 0, 0, 0.7);
      -ms-box-shadow: inset 0 0 15px rgba(0, 0, 0, 0.7);
          box-shadow: inset 0 0 15px rgba(0, 0, 0, 0.7);
  display:  block;
  margin-top:  -177px;
  margin-left: -117px;
  overflow: hidden;
  position: absolute;
  top:  50%;
  left: 50%;
  -webkit-transform-style: flat;
     -moz-transform-style: flat;
       -o-transform-style: flat;
          transform-style: flat;
}

.boxart__side img {
  border-radius: 0px;
  width:  100%;
  height: 100%;
  z-index: 10;
}

.boxart__side--front, .boxart__side--back {
  width:  250px;
  height: 355px;
}
.boxart-wrapper--r .boxart__side--front, .boxart-wrapper--r .boxart__side--back {
  width:  272px;
}
.boxart__side--front {
  -webkit-transform: translate3d(0px, 0px, 25px);
     -moz-transform: translate3d(0px, 0px, 25px);
       -o-transform: translate3d(0px, 0px, 25px);
          transform: translate3d(0px, 0px, 25px);
}
.boxart__side--back {
  -webkit-transform: rotateY(180deg) translate3d(0px, 0px, 25px);
     -moz-transform: rotateY(180deg) translate3d(0px, 0px, 25px);
       -o-transform: rotateY(180deg) translate3d(0px, 0px, 25px);
         -transform: rotateY(180deg) translate3d(0px, 0px, 25px);
}

.boxart__side--top, .boxart__side--bottom {
  width:  250px;
  height:  50px;
}
.boxart-wrapper--r .boxart__side--top, .boxart-wrapper--r .boxart__side--bottom {
  width:  272px;
}
.boxart__side--top {
  -webkit-transform: rotateX(90deg) translate3d(0px, 0px, 25px);
     -moz-transform: rotateX(90deg) translate3d(0px, 0px, 25px);
       -o-transform: rotateX(90deg) translate3d(0px, 0px, 25px);
         -transform: rotateX(90deg) translate3d(0px, 0px, 25px);
}
.boxart__side--bottom {
  -webkit-transform: rotateX(-90deg) translate3d(0px, 0px, 330px);
     -moz-transform: rotateX(-90deg) translate3d(0px, 0px, 330px);
       -o-transform: rotateX(-90deg) translate3d(0px, 0px, 330px);
         -transform: rotateX(-90deg) translate3d(0px, 0px, 330px);
}

.boxart__side--left, .boxart__side--right {
  width:   50px;
  height: 355px;
}
.boxart__side--left {
  -webkit-transform: rotateY(-90deg) translate3d(0px, 0px, 25px);
     -moz-transform: rotateY(-90deg) translate3d(0px, 0px, 25px);
       -o-transform: rotateY(-90deg) translate3d(0px, 0px, 25px);
         -transform: rotateY(-90deg) translate3d(0px, 0px, 25px);
}
.boxart__side--right {
  -webkit-transform: rotateY(90deg) translate3d(0px, 0px, 225px);
     -moz-transform: rotateY(90deg) translate3d(0px, 0px, 225px);
       -o-transform: rotateY(90deg) translate3d(0px, 0px, 225px);
         -transform: rotateY(90deg) translate3d(0px, 0px, 225px);
}
.boxart-wrapper--r .boxart__side--right {
  -webkit-transform: rotateY(90deg) translate3d(0px, 0px, 247px);
     -moz-transform: rotateY(90deg) translate3d(0px, 0px, 247px);
       -o-transform: rotateY(90deg) translate3d(0px, 0px, 247px);
         -transform: rotateY(90deg) translate3d(0px, 0px, 247px);
}

JavaScript

Make sure to load the following 2 JavaScript files and the in-page script in the order they are shown from top to bottom.

traqball.js

/*
 *  traqball 2.2
 *  written by Dirk Weber
 *  http://www.eleqtriq.com/
 *  See demo at: http://www.eleqtriq.com/wp-content/static/demos/2011/traqball2011

 *  Copyright (c) 2011 Dirk Weber (http://www.eleqtriq.com)
 *  Licensed under the MIT (http://www.eleqtriq.com/wp-content/uploads/2010/11/mit-license.txt)
 */

(function() {
  var userAgent = navigator.userAgent.toLowerCase(),
    startEvent = document.createEvent('HTMLEvents'),
    rotateEvent = document.createEvent('HTMLEvents'),
    releaseEvent = document.createEvent('HTMLEvents'),
    fullStopEvent = document.createEvent('HTMLEvents'),
    canTouch = "ontouchstart" in window,
    prefix = cssPref = "",
    requestAnimFrame, cancelAnimFrame;

  if (/webkit/gi.test(userAgent)) {
    prefix = "-webkit-";
    cssPref = "Webkit";
  } else if (/msie | trident/gi.test(userAgent)) {
    prefix = "-ms-";
    cssPref = "ms";
  } else if (/mozilla/gi.test(userAgent)) {
    prefix = "-moz-";
    cssPref = "Moz";
  } else if (/opera/gi.test(userAgent)) {
    prefix = "-o-";
    cssPref = "O";
  } else {
    prefix = "";
  }

  startEvent.initEvent("traqballStartRotate", true, true);
  rotateEvent.initEvent("traqballRotate", true, true);
  releaseEvent.initEvent("traqballRelease", true, true);
  fullStopEvent.initEvent("traqballFullStop", true, true);

  function bindEvent(target, type, callback, remove) {
    //translate events
    var evType = type || "touchend",
      mouseEvs = ["mousedown", "mouseup", "mousemove"],
      touchEvs = ["touchstart", "touchend", "touchmove"],
      remove = remove || "add";

    evType = canTouch ? evType : mouseEvs[touchEvs.indexOf(type)];

    target[remove + "EventListener"](evType, callback, false);
  }

  function getCoords(eventObj) {
    var xTouch,
      yTouch;

    if (eventObj.type.indexOf("mouse") > -1) {
      xTouch = eventObj.pageX;
      yTouch = eventObj.pageY;
    } else if (eventObj.type.indexOf("touch") > -1) {
      //only do stuff if 1 single finger is used:
      if (eventObj.touches.length === 1) {
        var touch = eventObj.touches[0];
        xTouch = touch.pageX;
        yTouch = touch.pageY;
      }
    }

    return [xTouch, yTouch];
  }

  function getStyle(target, prop) {
    var style = document.defaultView.getComputedStyle(target, "");
    return style.getPropertyValue(prop);
  }

  requestAnimFrame = (function() {
    return window[cssPref + "RequestAnimationFrame"] ||
      function(callback) {
        window.setTimeout(callback, 17);
      };
  })();

  cancelAnimFrame = (function() {
    return window[cssPref + "CancelRequestAnimationFrame"] ||
      clearTimeout;
  })();

  var Traqball = function(confObj) {
    this.config = {
      stage: document.body
    };
    this.box = null;

    this.setup(confObj);
  };

  Traqball.prototype.disable = function() {
    if (this.box !== null) {
      bindEvent(this.config.activationArea, 'touchstart', this.evHandlers[0], "remove");
      bindEvent(document, 'touchmove', this.evHandlers[1], "remove");
      bindEvent(document, 'touchend', this.evHandlers[2], "remove");
    }
  }

  Traqball.prototype.activate = function() {
    if (this.box !== null) {
      bindEvent(this.config.activationArea, 'touchstart', this.evHandlers[0]);
      bindEvent(document, 'touchmove', this.evHandlers[1], "remove");
      bindEvent(document, 'touchend', this.evHandlers[2], "remove");
    }
  }

  Traqball.prototype.setup = function(conf) {
    var THIS = this,
      radius, // prepare a variable for storing the radius of our virtual trackball
      stage, // the DOM-container of our "rotatable" element
      axis = [], // The rotation-axis
      mouseDownVect = [], // Vector on mousedown
      mouseMoveVect = [], // Vector during mousemove
      startMatrix = [], // Transformation-matrix at the moment of *starting* dragging
      delta = 0,
      impulse, pos, w, h, decr, angle, oldAngle, oldTime, curTime;

    (function init() {
      THIS.disable();

      for (var prop in conf) {
        THIS.config[prop] = conf[prop];
      }

      if (typeof THIS.config.stage === 'string') {
        THIS.config.stage = document.getElementById(THIS.config.stage);
      }
      stage = THIS.config.stage;
      pos = findPos(stage);
      angle = THIS.config.angle || 0;
      impulse = THIS.config.impulse === false ? false : true;

      // Let's calculate some basic values from "stage" that are necessary for our virtual trackball
      // 1st: determine the radius of our virtual trackball:
      h = stage.offsetHeight / 2,
        w = stage.offsetWidth / 2;

      //take the shortest of both values as radius
      radius = h < w ? h : w;

      //We parse viewport. The first block-element we find will be our "victim" and made rotatable
      for (var i = 0, l = stage.childNodes.length; i < l; i++) {
        var child = stage.childNodes[i];

        if (child.nodeType === 1) {
          THIS.box = child;
          break;
        }
      }
      if (typeof THIS.config.activationArea === 'undefined') {
        THIS.config.activationArea = THIS.box;
      }

      var perspective = getStyle(stage, prefix + "perspective"),
        pOrigin = getStyle(stage, prefix + "perspective-origin"),
        bTransform = getStyle(THIS.box, prefix + "transform");

      //Let's define the start values. If "conf" contains angle or perspective or vector, use them.
      //If not, look for css3d transforms within the CSS.
      //If this fails, let's use some default values.

      if (THIS.config.axis || THIS.config.angle) {
        // Normalize the initAxis (initAxis = axis of rotation) because "box" will look distorted if normal is too long
        axis = normalize(THIS.config.axis) || [1, 0, 0];
        angle = THIS.config.angle || 0;
        // Last but not least we calculate a matrix from the axis and the angle.
        // This matrix will store the initial orientation in 3d-space
        startMatrix = calcMatrix(axis, angle);

        // if (isNaN(startMatrix[0]) || isNaN(startMatrix[1]) || isNaN(startMatrix[2]) || .....) {
        // startMatrix = calcMatrix(axis, angle);
        // }
      } else if (bTransform !== "none") {
        //already css3d transforms on element?
        startMatrix = bTransform.split(",");

        //Under certain circumstances some browsers report 2d Transforms.
        //Translate them to 3d:
        if (/matrix3d/gi.test(startMatrix[0])) {
          startMatrix[0] = startMatrix[0].replace(/(matrix3d\()/g, "");
          startMatrix[15] = startMatrix[15].replace(/\)/g, "");
        } else {
          startMatrix[0] = startMatrix[0].replace(/(matrix\()/g, "");
          startMatrix[5] = startMatrix[5].replace(/\)/g, "");
          startMatrix.splice(2, 0, 0, 0);
          startMatrix.splice(6, 0, 0, 0);
          startMatrix.splice(8, 0, 0, 0, 1, 0);
          startMatrix.splice(14, 0, 0, 1);
        }
        for (var i = 0, l = startMatrix.length; i < l; i++) {
          startMatrix[i] = parseFloat(startMatrix[i]);
        }
      } else {
        axis = [0, 1, 0];
        angle = 0;
        startMatrix = calcMatrix(axis, angle);
      }

      if (THIS.config.perspective) {
        stage.style[cssPref + "Perspective"] = THIS.config.perspective;
      } else if (perspective === "none") {
        stage.style[cssPref + "Perspective"] = "700px";
      }

      if (THIS.config.perspectiveOrigin) {
        stage.style[cssPref + "PerspectiveOrigin"] = THIS.config.perspectiveOrigin;
      }

      // stage.classList.add("user-interacted");

      THIS.box.style[cssPref + "Transform"] = "matrix3d(" + startMatrix + ")";
      bindEvent(THIS.config.activationArea, 'touchstart', startrotation);

      THIS.evHandlers = [startrotation, rotate, finishrotation];
    })();

    var isPinching = false;
    var pinchTimer = null;

    function pinching(e) {
      // console.log(e);
      // console.log(e.changedTouches.TouchList.length);
      // console.log(e.targetTouches.TouchList.length);
      // console.log(e.touches.TouchList.length);
      // console.log(e.changedTouches.TouchList);
      // console.log(e.targetTouches.TouchList);
      // console.log(e.touches.TouchList);
      // console.log(e.TouchEvent);
      // console.log(e.changedTouches);
      // console.log(e.changedTouches.length);
      // console.log(e.targetTouches.length);
      // console.log(e.touches.length);
      // if ( e.changedTouches.length > 1 || e.targetTouches.length > 1 || e.touches.length > 1 ) alert("pinch");


              // FIXME: THIS WAS THE LAST DEBUGGING BLOCK USED:

              if ( ( e.changedTouches && e.changedTouches.length > 1 ) || ( e.targetTouches && e.targetTouches.length > 1 ) || ( e.touches && e.touches.length > 1 ) ) {
                // document.body.style = "background:green;";
                isPinching = true;
                // THIS.disable();
                clearTimeout(pinchTimer);
                pinchTimer = setTimeout(function() {
                  // document.body.style = "";
                  isPinching = false;
                  // THIS.activate();
                }, 1000);
                return true;
              }


      // if ( e.changedTouches.length > 1 || e.targetTouches.length > 1 || e.touches.length > 1 ) return;

      // if (e.targetTouches.TouchList.length == 2) alert("help");
      // if (e.touches.length == 2) alert("help");
      // if (e.changedTouches.length == 2) alert("help");
      // THIS.activate();
      return false;
    }

    function startrotation(e) {
      if (delta !== 0) {
        stopSlide();
      };

      // if (! pinching(e)) {
        // e.preventDefault();
        // if ( ! isPinching ) e.preventDefault();

        // if ( ! pinching(e) ) e.preventDefault();
        // else {
        //   bindEvent(document, 'touchmove', rotate, "remove");
        //   bindEvent(document, 'touchend', finishrotation, "remove");
        //   bindEvent(THIS.config.activationArea, 'touchstart', startrotation);
        //
        //   stopSlide();
        // }
        // if ( pinching(e) ) finishrotation(e);

        mouseDownVect = calcZvector(getCoords(e));
        oldTime = curTime = new Date().getTime();
        oldAngle = angle;

        THIS.box.dispatchEvent(startEvent);

        // THIS.box.classList.add("user-interacted");
        // THIS.config.activationArea.classList.add("user-interacted");
        // stage.classList.add("user-interacted");
        if ( pinching(e) || isPinching ) {
          // THIS.box.style[cssPref + "touch-action"] = "none";
          THIS.box.style["touch-action"] = "";
          // stage.style["touch-action"] = "";
          finishrotation(e);
          return true;
        }
        else {
          e.preventDefault();
          THIS.box.style["touch-action"] = "none";
          // stage.style["touch-action"] = "none";
        }

        bindEvent(THIS.config.activationArea, 'touchstart', startrotation, "remove");
        bindEvent(document, 'touchmove', rotate);
        bindEvent(document, 'touchend', finishrotation);
      // }
      // } else e
    }

    function finishrotation(e) {
      var stopMatrix;

      bindEvent(document, 'touchmove', rotate, "remove");
      bindEvent(document, 'touchend', finishrotation, "remove");
      bindEvent(THIS.config.activationArea, 'touchstart', startrotation);

      calcSpeed();

      THIS.box.dispatchEvent(releaseEvent);

      if (impulse && delta > 0) {
        requestAnimFrame(slide);
      } else if (!(isNaN(axis[0]) || isNaN(axis[1]) || isNaN(axis[2]))) {
        stopSlide();
      }
    }

    function cleanupMatrix() {
      // Clean up when finishing rotation. Only thing to do: create a new "initial" matrix for the next rotation.
      // If we don't, the object will flip back to the position at launch every time the user starts dragging.
      // Therefore we must:
      // 1. calculate a matrix from axis and the current angle
      // 2. Create a new startmatrix by combining current startmatrix and stopmatrix to a new matrix.
      // Matrices can be combined by multiplication, so what are we waiting for?
      stopMatrix = calcMatrix(axis, angle);
      startMatrix = multiplyMatrix(startMatrix, stopMatrix);
    }

    // The rotation:
    function rotate(e) {
      // if (!pinching(e)) {

        // e.preventDefault();
        // if ( pinching(e) ) e.preventDefault();

        // if ( ! pinching(e) ) e.preventDefault();
        // if ( pinching(e) ) finishrotation(e);
        // startrotation(e)

        // if ( ! pinching(e) ) e.preventDefault();
        if ( pinching(e) ) {
        // else {
          // // bindEvent(document, 'touchmove', rotate, "remove");
          // bindEvent(document, 'touchend', finishrotation, "remove");
          // bindEvent(THIS.config.activationArea, 'touchstart', startrotation);
          //
          // stopSlide();
          finishrotation(e);
          // THIS.box.classList.remove("user-interacted");
          // THIS.config.activationArea.classList.remove("user-interacted");

          // stage.classList.remove("user-interacted");

          THIS.box.style["touch-action"] = "";
          // stage.style["touch-action"] = "";
          return true;
        }
        else {
          // e.stopPropagation();
          // THIS.box.classList.add("user-interacted");
          // THIS.config.activationArea.classList.add("user-interacted");

          // stage.classList.add("user-interacted");

            // e.preventDefault();
            THIS.box.style["touch-action"] = "none";
            // stage.style["touch-action"] = "none";
        }

        oldTime = curTime;
        oldAngle = angle;

        var eCoords = getCoords(e);

        // Calculate the currrent z-component of the 3d-vector on the virtual trackball
        mouseMoveVect = calcZvector(eCoords);

        // We already calculated the z-vector-component on mousedown and the z-vector-component during mouse-movement.
        // We will use them to retrieve the current rotation-axis
        // (the normal-vector perpendiular to mouseDownVect and mouseMoveVect).
        axis[0] = mouseDownVect[1] * mouseMoveVect[2] - mouseDownVect[2] * mouseMoveVect[1];
        axis[1] = mouseDownVect[2] * mouseMoveVect[0] - mouseDownVect[0] * mouseMoveVect[2];
        axis[2] = mouseDownVect[0] * mouseMoveVect[1] - mouseDownVect[1] * mouseMoveVect[0];
        axis = normalize(axis);

        // Now that we have the normal, we need the angle of the rotation.
        // Easy to find by calculating the angle between mouseDownVect and mouseMoveVect:
        angle = calcAngle(mouseDownVect, mouseMoveVect);

        // if (isNaN(startMatrix[0]) || isNaN(startMatrix[1]) || isNaN(startMatrix[2])) {
        //   startMatrix = calcMatrix(axis, angle);
        // }

        //Only one thing left to do: Update the position of the box by applying a new transform:
        // 2 transforms will be applied: the current rotation 3d and the start-matrix
        THIS.box.style[cssPref + "Transform"] = "rotate3d(" + axis + "," + angle + "rad) matrix3d(" + startMatrix + ")";

        rotateEvent.data = {
          axis: axis,
          angle: angle
        };
        THIS.box.dispatchEvent(rotateEvent);

        curTime = new Date().getTime();
      // }
      // } else e
    }

    function calcSpeed() {
      var dw = angle - oldAngle;
      dt = curTime - oldTime;

      delta = Math.abs(dw * 17 / dt);

      if (isNaN(delta)) {
        delta = 0;
      } else if (delta > 0.2) {
        delta = 0.2;
      }
    }

    function slide() {
      angle += delta;
      decr = 0.01 * Math.sqrt(delta);
      delta = delta > 0 ? delta - decr : 0;

      THIS.box.style[cssPref + "Transform"] = "rotate3d(" + axis + "," + angle + "rad) matrix3d(" + startMatrix + ")";

      rotateEvent.data = {
        axis: axis,
        angle: angle
      };
      THIS.box.dispatchEvent(rotateEvent);

      if (delta === 0) {
        stopSlide();
      } else {
        requestAnimFrame(slide);
      }
    }

    function stopSlide() {
      cancelAnimFrame(slide);
      cleanupMatrix();

      oldAngle = angle = 0;
      delta = 0;

      fullStopEvent.data = {
        matrix: startMatrix
      };
      THIS.box.dispatchEvent(fullStopEvent);
    }

    //Some stupid matrix-multiplication.
    function multiplyMatrix(m1, m2) {
      var matrix = [];

      matrix[0] = m1[0] * m2[0] + m1[1] * m2[4] + m1[2] * m2[8] + m1[3] * m2[12];
      matrix[1] = m1[0] * m2[1] + m1[1] * m2[5] + m1[2] * m2[9] + m1[3] * m2[13];
      matrix[2] = m1[0] * m2[2] + m1[1] * m2[6] + m1[2] * m2[10] + m1[3] * m2[14];
      matrix[3] = m1[0] * m2[3] + m1[1] * m2[7] + m1[2] * m2[11] + m1[3] * m2[15];
      matrix[4] = m1[4] * m2[0] + m1[5] * m2[4] + m1[6] * m2[8] + m1[7] * m2[12];
      matrix[5] = m1[4] * m2[1] + m1[5] * m2[5] + m1[6] * m2[9] + m1[7] * m2[13];
      matrix[6] = m1[4] * m2[2] + m1[5] * m2[6] + m1[6] * m2[10] + m1[7] * m2[14];
      matrix[7] = m1[4] * m2[3] + m1[5] * m2[7] + m1[6] * m2[11] + m1[7] * m2[15];
      matrix[8] = m1[8] * m2[0] + m1[9] * m2[4] + m1[10] * m2[8] + m1[11] * m2[12];
      matrix[9] = m1[8] * m2[1] + m1[9] * m2[5] + m1[10] * m2[9] + m1[11] * m2[13];
      matrix[10] = m1[8] * m2[2] + m1[9] * m2[6] + m1[10] * m2[10] + m1[11] * m2[14];
      matrix[11] = m1[8] * m2[3] + m1[9] * m2[7] + m1[10] * m2[11] + m1[11] * m2[15];
      matrix[12] = m1[12] * m2[0] + m1[13] * m2[4] + m1[14] * m2[8] + m1[15] * m2[12];
      matrix[13] = m1[12] * m2[1] + m1[13] * m2[5] + m1[14] * m2[9] + m1[15] * m2[13];
      matrix[14] = m1[12] * m2[2] + m1[13] * m2[6] + m1[14] * m2[10] + m1[15] * m2[14];
      matrix[15] = m1[12] * m2[3] + m1[13] * m2[7] + m1[14] * m2[11] + m1[15] * m2[15];

      return matrix;
    }

    // This function will calculate a z-component for our 3D-vector from the mouse x and y-coordinates
    // (the corresponding point on our virtual trackball):
    function calcZvector(coords) {
      var x = THIS.config.limitAxxis === "x" ? radius : coords[0] - pos[0],
        y = THIS.config.limitAxxis === "y" ? radius : coords[1] - pos[1],
        vector = [(x / radius - 1), (y / radius - 1)],
        z = 1 - vector[0] * vector[0] - vector[1] * vector[1];

      // Make sure that dragging stops when z gets a negative value:
      vector[2] = z > 0 ? Math.sqrt(z) : 0;

      return vector;
    }

    // Normalization recalculates all coordinates in a way that the resulting vector has a length of "1".
    // We achieve this by dividing the x, y and z-coordinates by the vector's length
    function normalize(vect) {
      var length = Math.sqrt(vect[0] * vect[0] + vect[1] * vect[1] + vect[2] * vect[2]);

      vect[0] /= length;
      vect[1] /= length;
      vect[2] /= length;

      return vect;
    }

    // Calculate the angle between 2 vectors.
    function calcAngle(vect_1, vect_2) {
      var numerator = vect_1[0] * vect_2[0] + vect_1[1] * vect_2[1] + vect_1[2] * vect_2[2],
        denominator = Math.sqrt(vect_1[0] * vect_1[0] + vect_1[1] * vect_1[1] + vect_1[2] * vect_1[2]) *
        Math.sqrt(vect_2[0] * vect_2[0] + vect_2[1] * vect_2[1] + vect_2[2] * vect_2[2]),
        angle = Math.acos(numerator / denominator);

      return angle;
    }

    function calcMatrix(vector, angle) {
      // calculate transformation-matrix from a vector[x,y,z] and an angle
      var x = vector[0],
        y = vector[1],
        z = vector[2],
        sin = Math.sin(angle),
        cos = Math.cos(angle),
        cosmin = 1 - cos,
        matrix = [(cos + x * x * cosmin), (y * x * cosmin + z * sin), (z * x * cosmin - y * sin), 0,
          (x * y * cosmin - z * sin), (cos + y * y * cosmin), (z * y * cosmin + x * sin), 0,
          (x * z * cosmin + y * sin), (y * z * cosmin - x * sin), (cos + z * z * cosmin), 0,
          0, 0, 0, 1
        ];

      return matrix;
    }

    //findPos-script by www.quirksmode.org
    function findPos(obj) {
      var curleft = 0,
        curtop = 0;

      if (obj.offsetParent) {
        do {
          curleft += obj.offsetLeft;
          curtop += obj.offsetTop;
        } while (obj = obj.offsetParent);

        return [curleft, curtop];
      }
    }
  }

  window.Traqball = Traqball;
})();

modernizr.custom.css3d.js

/* Modernizr 2.0.4 (Custom Build) | MIT & BSD
 * Contains: csstransforms3d | iepp | teststyles | testprop | prefixes | load
 */
;
window.Modernizr = function ( a, b, c )
{
  function z( a, b )
  {
    for ( var d in a )
      if ( j[ a[ d ] ] !== c ) return b == "pfx" ? a[ d ] : !0;
    return !1
  }

  function y( a, b )
  {
    return !!~( "" + a ).indexOf( b )
  }

  function x( a, b )
  {
    return typeof a === b
  }

  function w( a, b )
  {
    return v( m.join( a + ";" ) + ( b || "" ) )
  }

  function v( a )
  {
    j.cssText = a
  }
  var d = "2.0.4",
    e = {},
    f = b.documentElement,
    g = b.head || b.getElementsByTagName( "head" )[ 0 ],
    h = "modernizr",
    i = b.createElement( h ),
    j = i.style,
    k, l = Object.prototype.toString,
    m = " -webkit- -moz- -o- -ms- -khtml- ".split( " " ),
    n = {},
    o = {},
    p = {},
    q = [],
    r = function ( a, c, d, e )
    {
      var g, i, j, k = b.createElement( "div" );
      if ( parseInt( d, 10 ) )
        while ( d-- ) j = b.createElement( "div" ), j.id = e ? e[ d ] : h + ( d + 1 ), k.appendChild( j );
      g = [ "&shy;", "<style>", a, "</style>" ].join( "" ), k.id = h, k.innerHTML += g, f.appendChild( k ), i = c( k, a ), k.parentNode.removeChild( k );
      return !!i
    },
    s, t = {}.hasOwnProperty,
    u;
  !x( t, c ) && !x( t.call, c ) ? u = function ( a, b )
  {
    return t.call( a, b )
  } : u = function ( a, b )
  {
    return b in a && x( a.constructor.prototype[ b ], c )
  };
  var A = function ( a, c )
  {
    var d = a.join( "" ),
      f = c.length;
    r( d, function ( a, c )
    {
      var d = b.styleSheets[ b.styleSheets.length - 1 ],
        g = d.cssRules && d.cssRules[ 0 ] ? d.cssRules[ 0 ].cssText : d.cssText || "",
        h = a.childNodes,
        i = {};
      while ( f-- ) i[ h[ f ].id ] = h[ f ];
      e.csstransforms3d = i.csstransforms3d.offsetLeft === 9
    }, f, c )
  }( [ , [ "@media (", m.join( "transform-3d),(" ), h, ")", "{#csstransforms3d{left:9px;position:absolute}}" ].join( "" ) ], [ , "csstransforms3d" ] );
  n.csstransforms3d = function ()
  {
    var a = !!z( [ "perspectiveProperty", "WebkitPerspective", "MozPerspective", "OPerspective", "msPerspective" ] );
    a && "webkitPerspective" in f.style && ( a = e.csstransforms3d );
    return a
  };
  for ( var B in n ) u( n, B ) && ( s = B.toLowerCase(), e[ s ] = n[ B ](), q.push( ( e[ s ] ? "" : "no-" ) + s ) );
  v( "" ), i = k = null, a.attachEvent && function ()
  {
    var a = b.createElement( "div" );
    a.innerHTML = "<elem></elem>";
    return a.childNodes.length !== 1
  }() && function ( a, b )
  {
    function s( a )
    {
      var b = -1;
      while ( ++b < g ) a.createElement( f[ b ] )
    }
    a.iepp = a.iepp ||
    {};
    var d = a.iepp,
      e = d.html5elements || "abbr|article|aside|audio|canvas|datalist|details|figcaption|figure|footer|header|hgroup|mark|meter|nav|output|progress|section|summary|time|video",
      f = e.split( "|" ),
      g = f.length,
      h = new RegExp( "(^|\\s)(" + e + ")", "gi" ),
      i = new RegExp( "<(/*)(" + e + ")", "gi" ),
      j = /^\s*[\{\}]\s*$/,
      k = new RegExp( "(^|[^\\n]*?\\s)(" + e + ")([^\\n]*)({[\\n\\w\\W]*?})", "gi" ),
      l = b.createDocumentFragment(),
      m = b.documentElement,
      n = m.firstChild,
      o = b.createElement( "body" ),
      p = b.createElement( "style" ),
      q = /print|all/,
      r;
    d.getCSS = function ( a, b )
    {
      if ( a + "" === c ) return "";
      var e = -1,
        f = a.length,
        g, h = [];
      while ( ++e < f )
      {
        g = a[ e ];
        if ( g.disabled ) continue;
        b = g.media || b, q.test( b ) && h.push( d.getCSS( g.imports, b ), g.cssText ), b = "all"
      }
      return h.join( "" )
    }, d.parseCSS = function ( a )
    {
      var b = [],
        c;
      while ( ( c = k.exec( a ) ) != null ) b.push( ( ( j.exec( c[ 1 ] ) ? "\n" : c[ 1 ] ) + c[ 2 ] + c[ 3 ] ).replace( h, "$1.iepp_$2" ) + c[ 4 ] );
      return b.join( "\n" )
    }, d.writeHTML = function ()
    {
      var a = -1;
      r = r || b.body;
      while ( ++a < g )
      {
        var c = b.getElementsByTagName( f[ a ] ),
          d = c.length,
          e = -1;
        while ( ++e < d ) c[ e ].className.indexOf( "iepp_" ) < 0 && ( c[ e ].className += " iepp_" + f[ a ] )
      }
      l.appendChild( r ), m.appendChild( o ), o.className = r.className, o.id = r.id, o.innerHTML = r.innerHTML.replace( i, "<$1font" )
    }, d._beforePrint = function ()
    {
      p.styleSheet.cssText = d.parseCSS( d.getCSS( b.styleSheets, "all" ) ), d.writeHTML()
    }, d.restoreHTML = function ()
    {
      o.innerHTML = "", m.removeChild( o ), m.appendChild( r )
    }, d._afterPrint = function ()
    {
      d.restoreHTML(), p.styleSheet.cssText = ""
    }, s( b ), s( l );
    d.disablePP || ( n.insertBefore( p, n.firstChild ), p.media = "print", p.className = "iepp-printshim", a.attachEvent( "onbeforeprint", d._beforePrint ), a.attachEvent( "onafterprint", d._afterPrint ) )
  }( a, b ), e._version = d, e._prefixes = m, e.testProp = function ( a )
  {
    return z( [ a ] )
  }, e.testStyles = r;
  return e
}( this, this.document ),
function ( a, b, c )
{
  function k( a )
  {
    return !a || a == "loaded" || a == "complete"
  }

  function j()
  {
    var a = 1,
      b = -1;
    while ( p.length - ++b )
      if ( p[ b ].s && !( a = p[ b ].r ) ) break;
    a && g()
  }

  function i( a )
  {
    var c = b.createElement( "script" ),
      d;
    c.src = a.s, c.onreadystatechange = c.onload = function ()
    {
      !d && k( c.readyState ) && ( d = 1, j(), c.onload = c.onreadystatechange = null )
    }, m( function ()
    {
      d || ( d = 1, j() )
    }, H.errorTimeout ), a.e ? c.onload() : n.parentNode.insertBefore( c, n )
  }

  function h( a )
  {
    var c = b.createElement( "link" ),
      d;
    c.href = a.s, c.rel = "stylesheet", c.type = "text/css", !a.e && ( w || r ) ? function a( b )
    {
      m( function ()
      {
        if ( !d ) try
        {
          b.sheet.cssRules.length ? ( d = 1, j() ) : a( b )
        }
        catch ( c )
        {
          c.code == 1e3 || c.message == "security" || c.message == "denied" ? ( d = 1, m( function ()
          {
            j()
          }, 0 ) ) : a( b )
        }
      }, 0 )
    }( c ) : ( c.onload = function ()
    {
      d || ( d = 1, m( function ()
      {
        j()
      }, 0 ) )
    }, a.e && c.onload() ), m( function ()
    {
      d || ( d = 1, j() )
    }, H.errorTimeout ), !a.e && n.parentNode.insertBefore( c, n )
  }

  function g()
  {
    var a = p.shift();
    q = 1, a ? a.t ? m( function ()
    {
      a.t == "c" ? h( a ) : i( a )
    }, 0 ) : ( a(), j() ) : q = 0
  }

  function f( a, c, d, e, f, h )
  {
    function i()
    {
      !o && k( l.readyState ) && ( r.r = o = 1, !q && j(), l.onload = l.onreadystatechange = null, m( function ()
      {
        u.removeChild( l )
      }, 0 ) )
    }
    var l = b.createElement( a ),
      o = 0,
      r = {
        t: d,
        s: c,
        e: h
      };
    l.src = l.data = c, !s && ( l.style.display = "none" ), l.width = l.height = "0", a != "object" && ( l.type = d ), l.onload = l.onreadystatechange = i, a == "img" ? l.onerror = i : a == "script" && ( l.onerror = function ()
    {
      r.e = r.r = 1, g()
    } ), p.splice( e, 0, r ), u.insertBefore( l, s ? null : n ), m( function ()
    {
      o || ( u.removeChild( l ), r.r = r.e = o = 1, j() )
    }, H.errorTimeout )
  }

  function e( a, b, c )
  {
    var d = b == "c" ? z : y;
    q = 0, b = b || "j", C( a ) ? f( d, a, b, this.i++, l, c ) : ( p.splice( this.i++, 0, a ), p.length == 1 && g() );
    return this
  }

  function d()
  {
    var a = H;
    a.loader = {
      load: e,
      i: 0
    };
    return a
  }
  var l = b.documentElement,
    m = a.setTimeout,
    n = b.getElementsByTagName( "script" )[ 0 ],
    o = {}.toString,
    p = [],
    q = 0,
    r = "MozAppearance" in l.style,
    s = r && !!b.createRange().compareNode,
    t = r && !s,
    u = s ? l : n.parentNode,
    v = a.opera && o.call( a.opera ) == "[object Opera]",
    w = "webkitAppearance" in l.style,
    x = w && "async" in b.createElement( "script" ),
    y = r ? "object" : v || x ? "img" : "script",
    z = w ? "img" : y,
    A = Array.isArray || function ( a )
    {
      return o.call( a ) == "[object Array]"
    },
    B = function ( a )
    {
      return typeof a == "object"
    },
    C = function ( a )
    {
      return typeof a == "string"
    },
    D = function ( a )
    {
      return o.call( a ) == "[object Function]"
    },
    E = [],
    F = {},
    G, H;
  H = function ( a )
  {
    function f( a )
    {
      var b = a.split( "!" ),
        c = E.length,
        d = b.pop(),
        e = b.length,
        f = {
          url: d,
          origUrl: d,
          prefixes: b
        },
        g, h;
      for ( h = 0; h < e; h++ ) g = F[ b[ h ] ], g && ( f = g( f ) );
      for ( h = 0; h < c; h++ ) f = E[ h ]( f );
      return f
    }

    function e( a, b, e, g, h )
    {
      var i = f( a ),
        j = i.autoCallback;
      if ( !i.bypass )
      {
        b && ( b = D( b ) ? b : b[ a ] || b[ g ] || b[ a.split( "/" ).pop().split( "?" )[ 0 ] ] );
        if ( i.instead ) return i.instead( a, b, e, g, h );
        e.load( i.url, i.forceCSS || !i.forceJS && /css$/.test( i.url ) ? "c" : c, i.noexec ), ( D( b ) || D( j ) ) && e.load( function ()
        {
          d(), b && b( i.origUrl, h, g ), j && j( i.origUrl, h, g )
        } )
      }
    }

    function b( a, b )
    {
      function c( a )
      {
        if ( C( a ) ) e( a, h, b, 0, d );
        else if ( B( a ) )
          for ( i in a ) a.hasOwnProperty( i ) && e( a[ i ], h, b, i, d )
      }
      var d = !!a.test,
        f = d ? a.yep : a.nope,
        g = a.load || a.both,
        h = a.callback,
        i;
      c( f ), c( g ), a.complete && b.load( a.complete )
    }
    var g, h, i = this.yepnope.loader;
    if ( C( a ) ) e( a, 0, i, 0 );
    else if ( A( a ) )
      for ( g = 0; g < a.length; g++ ) h = a[ g ], C( h ) ? e( h, 0, i, 0 ) : A( h ) ? H( h ) : B( h ) && b( h, i );
    else B( a ) && b( a, i )
  }, H.addPrefix = function ( a, b )
  {
    F[ a ] = b
  }, H.addFilter = function ( a )
  {
    E.push( a )
  }, H.errorTimeout = 1e4, b.readyState == null && b.addEventListener && ( b.readyState = "loading", b.addEventListener( "DOMContentLoaded", G = function ()
  {
    b.removeEventListener( "DOMContentLoaded", G, 0 ), b.readyState = "complete"
  }, 0 ) ), a.yepnope = d()
}( this, this.document ), Modernizr.load = function ()
{
  yepnope.apply( window, [].slice.call( arguments, 0 ) )
};

In-Page JavaScript

Do not include or comment-out the activationElement: "stage-target" option if you would rather end users have to interact with the boxart itself rather than the entire surrounding area, similar to my implementation in the above examples.

Modernizr.load({
  test: Modernizr.csstransforms3d,
  yep: {
    'traqball': '/js/module/traqball.js'
  },
  nope: '/js/module/traqball_alternative.js',
  callback: {
    'traqball': function (url, result, key) {
      var traqball_1 = new Traqball( {
        // stage:                 "stage",           // id of block element. String, default value: 
        // activationElement:     "stage",           // DOM-Element that catches the events. Default value: first child of stage (the rotating element)
        // axis:                  [0.5,1,0.25],      // X,Y,Z values of initial rotation vector. Array, default value: [1,0,0]
        // angle:                 0.12,              // Initial rotation angle in radian. Float, default value: 0.
        // perspective:           perspectiveValue,  // Perspective. Integer, default value 700.
        // perspectiveOrigin:     "xVal yVal",       // Perspective Origin. String, default value "50% 50%".
        // impulse:               boolean,           // Defines if object receives an impulse after relesing mouse/touchend. Default value: true.
        // limitAxxis:            "x" | "y"          // limits the rotation to only one axxis.
        stage:                  "boxart-stage",
        activationElement:      "stage-target",
        axis:                   [0, 1, 0],
        angle:                  0.785398
      } );
      // var traqball_2 = new Traqball( { // follow this example to run 2 or more boxarts on one page at a time
      //   stage:               "boxart-stage-2",
      //   activationElement:   "stage-target-2",
      //   axis:                [0, 1, 0],
      //   angle:               0.785398
      // } );
    }
  }
});

Special Thanks

This page is comprised of my own additions and either partially or heavily modified elements from the following source(s):