< drag to rotate >

loading
Go to angle

ease360° is a physics-based 360° image sequencer built for the web. Drag, release, coast — the spin feels earned, not programmed. Built on HTML5 canvas with zero dependencies, it gives you precise control over every frame, every breakpoint, and every pixel. Serve your assets from any CDN via URL parameters, or drop in the ease360° Cloudinary helper to generate responsive frame sets on the fly — no static image folders, no manual resizing, no overhead. Trusted in production at Hyundai USA.

Designed and built by Derek Dintzner. Open source under the MIT license — contributions welcome on GitHub.

Features

  • Physics-based spin — damping engine with configurable friction
  • Zero dependencies — pure vanilla JS, no jQuery, no build tools
  • Responsive breakpoints — per-breakpoint frame sets, dimensions, and crop control
  • CDN-native — serve frames from Cloudinary, Imgix, or any image CDN via URL transforms
  • ease360.cdn.js — built-in Cloudinary helper generates responsive frame sets from a single master upload
  • Smart preloading — loads 50% of frames first, remainder on first interaction
  • Transparent PNG and WebP sequence support
  • UMD — works with AMD, CommonJS, ESM, or a plain script tag

Download

Get it on GitHub

Settings

Option Type Required Default Description
frames array null Ordered array of image paths.
sourceWidth int null Pixel width of the delivered image. Optional — when omitted, ease360° probes frames[0] automatically. Provide it to skip the probe: synchronous init, no CORS dependency. Must match the delivered size, not the master. See Ex 1 — sourceWidth & sourceHeight.
sourceHeight int null Pixel height of the delivered image. Optional — same probe behaviour as sourceWidth. Both must be provided together to skip the probe; providing only one still triggers it.
frameDirection int 1 Set -1 to reverse sequence direction.
startAngle int 0 Initial angle on load (0–359).
backgroundSize enum 'stretch' 'stretch' sizes images to fill the element. 'cover' fills while preserving aspect ratio. 'cover-center' and 'cover-top' control vertical crop alignment.
backgroundOffsetX float 0 X offset in px when backgroundSize is a cover mode.
backgroundOffsetY float 0 Y offset in px when backgroundSize is a cover mode.
preloadSmart boolean false Loads every other frame first (50%), remaining frames load on first interaction. Requires an even-length frames array.
flex object { w: false } Set { w: true } for percentage-width containers.
dragDirection enum 'left-right' Which drag direction drives the spin. 'left-right' = horizontal. 'up-down' = vertical. 'all' = both. Previously dragAxis, touchdirection.
damping float 0.95 Physics friction. Range 0.85 (firm) → 0.98 (fluid). A value of 1.0 creates a continuous spin.
transparencySupport boolean true Clears the canvas before each frame draw. Set false for opaque/legacy behavior.
responsive array Array of breakpoint objects — see table below. When used, sourceWidth and sourceHeight are not required at the top level.

✅ * Required unless using responsive.

Breakpoint object

Each entry in the responsive array defines a complete frame set for a viewport width range. ease360° activates the first entry whose breakpoint value is ≥ the current viewport width. All per-breakpoint options override their top-level equivalents for that range only.

Properties

Key Type Required Description
breakpoint int Max viewport width in px at which this set activates.
frames array Ordered array of image paths for this breakpoint.
sourceWidth int Pixel width of the delivered image at this breakpoint. Optional — omit to let ease360° probe. Providing it skips the probe for this slot: faster init, no CORS dependency. Previously width (v0.x).
sourceHeight int Pixel height of the delivered image at this breakpoint. Optional — same probe behaviour as sourceWidth.
flex object { w: true } to fill a percentage-width container. Height recalculates to maintain ratio.
backgroundOffsetY float Vertical crop offset in px. Fine-tune the subject position within the canvas independently per breakpoint.
backgroundOffsetX float Horizontal crop offset in px.
backgroundSize enum Override backgroundSize for this breakpoint only.
preloadSmart boolean Override smart preloading for this breakpoint only.
startAngle int Override starting angle for this breakpoint only — useful when a different hero angle looks better on mobile.
Property Type Description
.progress float 0–1 loading progress of the current frame set. getter

Methods

Method Parameters Description
angle() Returns the current angle (0–359). getter
angle(value) int Sets the angle instantly. setter
angleTo(angle, duration?) int, float Animates to the specified angle. Duration in seconds, defaults to 1.0.
angleStep(angle) int Jumps angle by an offset relative to the current position — positive or negative.
spinOver(speed?) float Continuous spin. Speed can be positive or negative, defaults to 1.0. Intended for hover/rollover effects.
spinOut() Cancels spinOver().
changeFrames(frames, hdpiFrames?) array, array Swaps the current frame set. If initialized with framesHighDPI (deprecated), a matching HiDPI array is required. Waits for engine stop before swapping.
changeFramesResponsive(setsArray) array of arrays Swaps frame sets across all responsive breakpoints. Order must match the responsive array passed on init.
destroy() Removes the canvas, unbinds all events, and cancels any active animation.

Callbacks

Callback Argument Description
progressUpdate float (0–1) Fired on every frame load tick.
angleUpdate int (0–359) Fired on every angle change during render.
responsiveUpdate breakpoint object Fired when the active responsive breakpoint changes.
stateUpdate string Fired on engine status change. Values: 'init', 'start', 'active', 'stop'.

Example

Below is the initialization code used for the Genesis G90 demo above. ease360° works with any image CDN that supports on-the-fly URL transforms — resize, format, and quality parameters passed directly in the URL. No static image folders, no manual resizing. Cloudinary is used here as the example, but the same pattern works with Imgix, Bunny, Fastly, and others.

// Cloudinary config — one master set, resized per breakpoint via URL
// w_{n}   = resize to width n px, height scales proportionally
// q_auto  = Cloudinary selects optimal compression automatically
// f_webp  = force WebP delivery (~25–35% smaller than JPEG at same quality)
//           use f_auto instead for JPEG fallback on older browsers
const cdn = {
    cloud:   'YOURCLOUDID',
    version: 'YOURVERSION',
    prefix:  'G90-LondonGray-lg'
};

// Build a frame array for a given width.
// Frames are named sequentially with 3-digit zero-padding: 001 → 036
const makeFrames = (width) =>
    Array.from({ length: 36 }, (_, i) => {
        const frame = String(i + 1).padStart(3, '0');
        return `https://res.cloudinary.com/${cdn.cloud}/image/upload/w_${width},q_auto,f_webp/${cdn.version}/${cdn.prefix}-${frame}.jpg`;
    });

const myEase360 = ease360('#myEase360', {
    frameDirection: -1,       // reversed spin direction
    backgroundSize: 'cover',  // scale frames to fill canvas, preserving aspect ratio
    preloadSmart:   true,     // load 50% of frames first, rest on first interaction
    damping:        0.94,     // physics friction — 0.85 (firm) → 0.98 (fluid)

    // ── Responsive breakpoints ────────────────────────────────────────────────
    // Each entry defines a frame set for a max-viewport-width range.
    // ease360° monitors the viewport and automatically swaps to the correct
    // set on resize, triggering responsiveUpdate() during the transition.
    //
    // Note: width/height inside responsive breakpoints are canvas render
    // dimensions — how the viewer draws on screen. These are different from
    // the top-level sourceWidth/sourceHeight (source image pixel dimensions).
    // In a responsive setup, sourceWidth/sourceHeight are not needed at the
    // top level — the breakpoint width/height serve the same canvas math role.
    //
    // Per-breakpoint options worth tuning:
    //   backgroundOffsetY — vertical crop adjustment in px (cover mode only)
    //   backgroundOffsetX — horizontal crop adjustment in px (cover mode only)
    //   backgroundSize    — override crop mode per breakpoint
    //   preloadSmart      — toggle smart preload per breakpoint
    //   startAngle        — set a different starting angle per breakpoint
    responsive: [

        // Wide desktop (≤ 1920px) — max resolution
        { breakpoint: 1920, frames: makeFrames(1920), sourceWidth: 1920, sourceHeight: 1050, flex: { w: true }, backgroundOffsetY: 150 },

        // Desktop (≤ 1440px)
        { breakpoint: 1440, frames: makeFrames(1440), sourceWidth: 1440, sourceHeight: 810,  flex: { w: true }, backgroundOffsetY: 80  },

        // Laptop (≤ 1024px)
        { breakpoint: 1024, frames: makeFrames(1024), sourceWidth: 1024, sourceHeight: 576,  flex: { w: true } },

        // Tablet (≤ 768px)
        { breakpoint: 768,  frames: makeFrames(768),  sourceWidth: 768,  sourceHeight: 480,  flex: { w: true } },

        // Mobile (≤ 640px) — full bleed, height tuned to keep subject proportional
        { breakpoint: 640,  frames: makeFrames(640),  sourceWidth: 640,  sourceHeight: 400,  flex: { w: true }, backgroundOffsetY: 30  },

        // Mobile small (≤ 375px) — lightest set, optimized for bandwidth
        { breakpoint: 375,  frames: makeFrames(375),  sourceWidth: 375,  sourceHeight: 160,  flex: { w: true } }

    ],

    angleUpdate:      () => myAngleUpdate(),     // fires on every rendered frame
    progressUpdate:   () => myProgress(),        // fires on every image load tick (0 → 1)
    responsiveUpdate: () => myResponsiveUpdate() // fires when breakpoint changes on resize
});

Callbacks & Controls

The three callbacks below power everything you see in the G90 demo above — the loading percentage, the canvas fade-in, the "drag to rotate" prompt, and the seamless breakpoint swap on resize. The goto() function shows how the angle buttons are wired to angleTo(), the same method you can attach to any hotspot, color swatch, or UI trigger in your own build.

// ── angleUpdate ───────────────────────────────────────────────────────────────
// Fired on every rendered frame while the viewer is moving.
// Fade out the "drag to rotate" instruction on first interaction.
const instructions = document.querySelector('.instructions');

const myAngleUpdate = () => instructions.classList.remove('opacity1');

// ── progressUpdate ────────────────────────────────────────────────────────────
// ease360.progress returns a 0–1 float.
// When preloadSmart is true, only 50% of frames load first — multiply by 2
// to display 0–100% correctly during the smart load phase.
const myProgress = () => {
    const inSmartPhase = myEase360.preloadSmart &&
                         myEase360.totalLoaded <= myEase360.frames.length / 2;
    const multiplier   = inSmartPhase ? 2 : 1;
    const percent    = Math.floor(myEase360.progress * 100) * multiplier;
    const loading    = document.querySelector('.loading');

    document.querySelector('.loading h4').textContent = `${percent}%`;

    if (percent !== 100) return;

    document.querySelector('#myEase360 canvas').classList.add('opacity1');
    loading.classList.add('opacity0');

    setTimeout(() => {
        loading.style.zIndex        = '0';
        loading.querySelector('h4').textContent = 'loading'; // reset for next swap
        document.querySelector('#ease360Layout h3.instructions').classList.add('opacity1');
    }, 2000);
};

// ── responsiveUpdate ──────────────────────────────────────────────────────────
// Fired when the viewport crosses a breakpoint and ease360° swaps frame sets.
// Re-show the loader while the new set downloads.
const myResponsiveUpdate = () => {
    const loading = document.querySelector('.loading');
    loading.classList.remove('opacity0');
    loading.style.zIndex = '1000';
    document.querySelector('#myEase360 canvas').classList.remove('opacity1');
    loading.querySelector('h4').textContent = 'loading';
};

// ── angleTo — button controls ─────────────────────────────────────────────────
// Wire angleTo() to any button, hotspot, or UI event.
// Animates to the target angle over 0.75s with a cubic ease-out curve.
//
// HTML: <button onclick="goto(90)">90°</button>
const goto = (angle) => myEase360.angleTo(angle, 0.75);