< drag to rotate >
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
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.
| 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);