This example builds a production-grade color picker on top of ease360° — handling the loader, crossfade, and responsive breakpoints as a complete system.
In this example you will learn the following:
changeFrames() swaps a frame set at runtime without reinitializing the viewerchangeFramesResponsive() keeps all breakpoints in sync when a color changes
Each color variant lives in its own Cloudinary folder. A single
makeFrames() helper builds the URL array for any color
at any width — swap the color name, get a different variant.
Important: when you provide sourceWidth and
sourceHeight, they must match the delivered image
dimensions — what Cloudinary actually sends to the browser — not the master.
If you request w_540 in the URL, set sourceWidth: 540.
If you omit both, ease360° probes the first frame automatically and reads
the correct dimensions — but see
Ex 1 — sourceWidth & sourceHeight for the speed and
CORS trade-offs before deciding.
const cdn = {
cloud: 'YOURCLOUDID'
// No version string — omitting it tells Cloudinary to always serve the
// latest uploaded asset. Re-upload any frame and it's live immediately.
};
// Frames named teapot_{color}_1.jpg → teapot_{color}_36.jpg — one-based, no padding.
const makeFrames = (color, width) =>
Array.from({ length: 36 }, (_, i) =>
`https://res.cloudinary.com/${cdn.cloud}/image/upload/w_${width},q_auto,f_webp/teapot_${color}_${i + 1}.jpg`
);
const variantFrames = {
green: makeFrames('green', 540),
blue: makeFrames('blue', 540),
orange: makeFrames('orange', 540)
};
const myEase360 = ease360('#myEase360', {
frames: variantFrames.green,
sourceWidth: 540, // optional — pixel width Cloudinary delivers at w_540 (not the master)
sourceHeight: 540, // optional — omit to auto-detect; see Ex 1 — sourceWidth & sourceHeight for speed/CORS trade-offs
backgroundSize: 'cover-center',
preloadSmart: true,
progressUpdate: () => onProgress()
});
Keep a map of which colors have been fully loaded at least once.
The first visit to a color shows the loader — but returning to a
color that's already been loaded skips the loader entirely.
changeFrames() will use the browser's cached images
and the swap is effectively instant.
// false = never loaded, true = loaded at least once
const colorLoaded = { green: false, blue: false, orange: false };
Fast connections can load frames in under 200ms — faster than the human
eye can register a loader at all. A brief flash of the loader and then
immediate hide looks broken, not fast. The solution is a minimum display
time: the loader stays up for at least MIN_MS milliseconds
regardless of how quickly the frames arrive.
This requires coordinating two independent async events — the image load completing and the timer expiring. The loader only hides when both are done. Whichever finishes last triggers the hide.
const MIN_MS = 800; // loader shows for at least 800ms
let timerDone = true;
let loadDone = false;
// Hides the loader only when both images and timer are complete
const hideLoaderWhenReady = () => {
if (!loadDone || !timerDone) return;
const loading = document.querySelector('.loading');
loading.classList.add('opacity0');
document.querySelector('#myEase360 canvas').classList.add('opacity1');
setTimeout(() => {
loading.style.zIndex = '0';
loading.querySelector('h4').textContent = 'loading'; // reset for next swap
loadDone = false;
}, 600); // matches CSS fade duration
};
// progressUpdate sets loadDone and tries to hide
const onProgress = () => {
const inSmartPhase = myEase360.preloadSmart &&
myEase360.totalLoaded <= myEase360.frames.length / 2;
const multiplier = inSmartPhase ? 2 : 1;
const pct = Math.floor(myEase360.progress * 100) * multiplier;
document.querySelector('.loading h4').textContent = `${pct}%`;
if (pct !== 100) return;
loadDone = true;
hideLoaderWhenReady();
};
changeFrames() waits for the physics engine to stop before
loading the new set — it won't cut off a spin in progress. The swatch
handler decides whether to show the loader based on colorLoaded,
starts the minimum timer, and then calls changeFrames().
const changeVariant = (key) => {
const frames = variantFrames[key];
if (!frames) return;
// Update active swatch
document.querySelectorAll('.swatch').forEach(s => s.classList.remove('active'));
document.querySelector(`.swatch-${key}`)?.classList.add('active');
if (!colorLoaded[key]) {
// First visit — show the loader
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';
// Start both the image load and the minimum display timer.
// hideLoaderWhenReady() won't fire until both are true.
loadDone = false;
timerDone = false;
setTimeout(() => {
timerDone = true;
hideLoaderWhenReady(); // timer finished — check if load is also done
}, MIN_MS);
colorLoaded[key] = true;
}
// Swap — engine waits for physics stop before loading
myEase360.changeFrames(frames);
};
// Wire swatches — mousedown prevents focus bleed onto the swatch
document.querySelectorAll('.swatch').forEach(swatch => {
swatch.addEventListener('mousedown', (e) => {
e.preventDefault();
changeVariant(swatch.dataset.color);
});
});
Without a crossfade, switching colors produces either a black flash (canvas goes
transparent while new frames load) or a hard cut (instant swap with no transition).
The solution is a snapshot canvas — a second
<canvas> element that captures the current frame before the
swap begins, sits above the live canvas while new frames load, then fades out
once the new color is ready.
The ease360° canvas starts at opacity: 0 and transitions to
opacity: 1 when the opacity1 class is added. This is
defined in docs.css and applies to all examples — it's what
allows the smooth fade-in on first load and on every color swap.
#myEase360 canvas {
position: absolute;
top: 0;
opacity: 0;
transition: opacity 2s ease;
}
#myEase360 canvas.opacity1 {
opacity: 1;
}
Before calling changeFrames(), copy the current canvas pixel data
into a snapshot canvas. The snapshot is positioned absolutely inside
#myEase360 at z-index: 2 — above the live canvas,
below the spinner (z-index: 1000). The outgoing color stays
visible throughout the load.
Important: do not use querySelector('#myEase360 canvas')
after the snapshot is inserted — it returns the first canvas found, which will be
the snapshot, not the ease360 canvas. Use myEase360.canvas.c directly
for a reliable reference to the live canvas.
let snapshotCanvas = null;
const captureSnapshot = () => {
const liveCanvas = myEase360.canvas.c; // direct reference — not querySelector
if (!snapshotCanvas) {
snapshotCanvas = document.createElement('canvas');
snapshotCanvas.style.cssText = [
'position:absolute', 'top:0', 'left:0',
'width:100%', 'height:100%',
'pointer-events:none',
'z-index:2' // above live canvas, below spinner at z-index:1000
].join(';');
document.querySelector('#myEase360').appendChild(snapshotCanvas);
}
// Copy current frame into snapshot
snapshotCanvas.width = liveCanvas.width;
snapshotCanvas.height = liveCanvas.height;
snapshotCanvas.getContext('2d').drawImage(liveCanvas, 0, 0);
snapshotCanvas.style.transition = 'none';
snapshotCanvas.style.opacity = '1';
};
On a first visit, show the spinner but do not remove opacity1
from the live canvas. The snapshot at z-index: 2 already covers it —
removing opacity would cause a dip between the snapshot appearing and the live
canvas going transparent. The spinner sits on top of the snapshot at
z-index: 1000.
const showLoader = () => {
captureSnapshot(); // freeze the current frame first
const loading = document.querySelector('.loading');
loading.classList.remove('opacity0');
loading.style.zIndex = '1000';
// Do NOT remove opacity1 from the live canvas here —
// the snapshot covers it, avoiding any opacity dip
};
When both the load and the minimum timer are complete, add opacity1
to the live canvas and fade the spinner and snapshot out at the same time.
The new color fades in over the frozen snapshot — a clean crossfade with no
black frame at any point.
const fadeOutSnapshot = () => {
if (!snapshotCanvas) return;
snapshotCanvas.style.transition = 'opacity 0.5s ease';
snapshotCanvas.style.opacity = '0';
};
const hideLoader = () => {
myEase360.canvas.c.classList.add('opacity1'); // new color fades in
document.querySelector('.loading').classList.add('opacity0'); // spinner fades out
fadeOutSnapshot(); // snapshot dissolves simultaneously
};
This example uses three breakpoints — desktop, tablet, and mobile — each
with its own frame size. changeFramesResponsive() updates all
three slots simultaneously when a swatch is clicked. Pass an array of frame
arrays in the exact same order as the responsive init array.
// Each color has frames at three sizes matching the responsive array order
const framesGreen = {
1920: makeFrames('green', 540), // desktop > 1024px — 540px frames
1024: makeFrames('green', 400), // tablet 641–1024px — 400px frames
640: makeFrames('green', 320) // mobile ≤ 640px — 320px frames
};
// Same structure for blue and orange...
let currentFrames = framesGreen;
const myEase360 = ease360('#myEase360', {
responsive: [
{ breakpoint: 1920, frames: currentFrames[1920], sourceWidth: 540, sourceHeight: 540, flex: { w: true } },
{ breakpoint: 1024, frames: currentFrames[1024], sourceWidth: 400, sourceHeight: 400, flex: { w: true } },
{ breakpoint: 640, frames: currentFrames[640], sourceWidth: 320, sourceHeight: 320, flex: { w: true } }
]
});
// On swatch click — swap all three breakpoints simultaneously
currentFrames = framesBlue;
myEase360.changeFramesResponsive([
currentFrames[1920],
currentFrames[1024],
currentFrames[640]
]);