ease360° has two communication channels. This example wires up both — a live readout driven by callbacks, and goto buttons, keyboard nav, and hover controllers driven by methods. Try the controls in the viewer above.
In this example you will learn the following:
preloadSmart loads half the frames first and why the progress multiplier must be applied correctlyangleTo() without focus bleedspinOver() and spinOut()All four callbacks are registered in the init options object. They fire on the viewer's internal render loop — no polling, no intervals. ease360° calls them precisely when something changes.
const myEase360 = ease360('#myEase360', {
frames: greenTeapot,
sourceWidth: 540, // optional — pixel width of the source image (local teapot is 540×540)
sourceHeight: 540, // optional — omit to auto-detect; see Ex 1 — sourceWidth & sourceHeight for trade-offs
backgroundSize: 'cover',
preloadSmart: true, // load 50% first, remainder on first interaction
progressUpdate: () => onProgress(), // every frame load tick
angleUpdate: () => onAngle(), // every rendered frame while moving
stateUpdate: (s) => onState(s), // engine state changes
responsiveUpdate: () => onResponsive() // breakpoint swap on resize
});
preloadSmart: true loads only every other frame first —
frames 0, 2, 4, 6... — giving you a complete 360° spin at half the
frame rate with half the payload. The viewer becomes interactive at 50% load.
When the user first drags, the remaining frames fill in seamlessly in the background.
If they never interact, the second half never downloads.
Because myEase360.progress reports against the full frame count,
it reaches 0.5 when the smart load completes — not 1.0.
The progressUpdate callback needs to detect this phase and
treat 0.5 as "done" rather than waiting for 1.0 that never comes.
Rather than showing a percentage counter, the production pattern is a spinner gif with a minimum display time. Fast connections can load frames in under 200ms — a loader that flashes on and immediately off looks broken, not fast. Enforcing a minimum time means the spinner always feels intentional.
This requires coordinating two independent async events — the images
finishing and a timer expiring. A timerStatus flag and a
recheckTimer() loop ensure the spinner only hides when
both are done, whichever finishes last.
const MIN_MS = 1000; // spinner stays up for at least this long
let timerStatus = 'timerComplete';
let timer;
// Start the minimum display timer on page load
timerStatus = 'timerWorking';
timer = setTimeout(() => { timerStatus = 'timerComplete'; }, MIN_MS);
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;
if (pct !== 100) return; // not done yet
// Images loaded — now check if the minimum timer has also elapsed
if (timerStatus !== 'timerComplete') {
const recheckTimer = () => {
if (timerStatus !== 'timerComplete') { setTimeout(recheckTimer, 50); return; }
hideLoader();
};
recheckTimer();
} else {
hideLoader();
}
};
const hideLoader = () => {
myEase360.canvas.c.classList.add('opacity1');
const loading = document.querySelector('.loading');
loading.classList.add('opacity0');
setTimeout(() => {
loading.style.zIndex = '0';
document.querySelector('#ease360Layout h3.instructions').classList.add('opacity1');
}, 600);
};
// Also update the live readout from the info bar
const onProgressInfo = () => {
const inSmartPhase = myEase360.preloadSmart &&
myEase360.totalLoaded <= myEase360.frames.length / 2;
const pct = Math.floor(myEase360.progress * 100) * (inSmartPhase ? 2 : 1);
document.getElementById('info-progress').textContent = `loaded: ${pct}%`;
};
Fires on every frame the physics engine renders while the viewer is in motion. Use it to drive a live angle display, sync a hotspot overlay, or trigger a UI change at a specific angle.
const onAngle = () => {
document.querySelector('.instructions').classList.remove('opacity1');
document.getElementById('info-angle').textContent = `angle: ${myEase360.angle()}°`;
};
The physics engine moves through four states during a typical interaction.
'init' fires once on load. 'start' fires on first touch
or mousedown. 'active' fires while dragging or coasting.
'stop' fires when velocity reaches zero — useful for triggering
a changeFrames() swap or an analytics event.
const onState = (s) => {
// s: 'init' | 'start' | 'active' | 'stop'
document.getElementById('info-state').textContent = `state: ${s}`;
};
Methods are called directly on the instance returned by ease360().
They work at any point after initialization — wire them to any
button, hotspot, color swatch, keyboard event, or scroll trigger in your UI.
angleTo(angle, duration) animates to the target angle over
duration seconds using a cubic ease-out curve.
The shortest arc is always taken — spinning from 350° to 10° goes forward
20°, not backward 340°.
<!-- data-angle attribute — wired via JS below -->
<button data-angle="90">90°</button>
// mousedown + preventDefault prevents the button taking focus —
// avoids a stuck hover appearance after clicking.
document.querySelectorAll('button[data-angle]').forEach(btn => {
btn.addEventListener('mousedown', (e) => {
e.preventDefault();
myEase360.angleTo(Number(btn.dataset.angle), 0.75);
});
});
myEase360.angle() returns the current angle as a getter — use it
to calculate relative jumps. Arrow key navigation is a small addition that
meaningfully improves accessibility and desktop feel.
document.addEventListener('keydown', (e) => {
if (e.key === 'ArrowLeft') myEase360.angleTo(myEase360.angle() - 60, 0.4);
if (e.key === 'ArrowRight') myEase360.angleTo(myEase360.angle() + 60, 0.4);
});
spinOver(speed) starts a continuous spin at the given speed.
Positive values spin forward, negative reverse. spinOut() stops it.
Combine with mouseenter and mouseleave for a
rollover spin effect — the pattern used in thumbnail grids and product cards.
const left = document.querySelector('.controllers .left');
const right = document.querySelector('.controllers .right');
// Spin continuously on hover, step 60° on click
left.addEventListener('mouseenter', () => myEase360.spinOver(1));
left.addEventListener('mouseleave', () => myEase360.spinOut());
left.addEventListener('click', () => myEase360.angleTo(myEase360.angle() - 60, 0.4));
right.addEventListener('mouseenter', () => myEase360.spinOver(-1));
right.addEventListener('mouseleave', () => myEase360.spinOut());
right.addEventListener('click', () => myEase360.angleTo(myEase360.angle() + 60, 0.4));