The animator shipped a hundred and twenty hero frames as PNGs. I converted all of them to lossless WebP with cwebp and put them in the assets folder of the store theme I was working on, as seq-001.webp through seq-120.webp. The naming is deliberately flat, a prefix plus a zero padded index, so that alphabetical order inside the folder matches frame order.
The loader I already had rested on a different assumption. There, a frame's address is assembled from two pieces, a directory and a four digit zero padded number, so index seven becomes 0007 and the address is just a join away: .../img_0007.jpg. Inside a Shopify theme's assets folder, that recipe breaks in two places at once.
First, Shopify's assets folder is flat. There are no subdirectories inside it, so there is no directory to hold as the first piece. Second, asset_url does not hand back a plain address. It returns a CDN URL with a version cache buster attached, and the value can differ from one file to the next:
{{ 'seq-001.webp' | asset_url }}comes back looking roughly like this:
https://cdn.shopify.com/.../seq-001.webp?v=12345
So there is no directory to concatenate a number onto, and generating per frame URLs on top of a flat folder like this really is awkward.
A hundred and twenty URLs inside the HTML
The straightest path is calling asset_url a hundred and twenty times through a Liquid loop and dumping the result into one JSON attribute. My rough estimate at the time was somewhere around twenty kilobytes of attribute, a number I never actually measured, and all of it bloats the DOM for content that is really one pattern with a changing number.
One URL, one placeholder
All I needed was one correct URL and a way to swap the numbered part. Liquid can do that with no loop at all, and the same block leaves a way out when the override field in the theme editor is filled in:
{%- if section.settings.sequence_url != blank -%}
{%- assign sequence_url = section.settings.sequence_url -%}
{%- else -%}
{%- assign base_url = 'seq-001.webp' | asset_url -%}
{%- assign sequence_url = base_url | replace: 'seq-001.webp', 'seq-{N}.webp' -%}
{%- endif -%}The replace filter works in the middle of an already finished URL, so the CDN host, the path, and the ?v= tail all stay attached. The only thing that changes is the filename in the middle, which now carries a {N} placeholder for JavaScript to swap later.
The override field itself is just a text setting in the schema, marked optional and left empty when the bundled assets are used. Its point is letting the client move to an external CDN such as Cloudinary or Bunny later without code changes, by pasting a URL that carries {N}.
The markup becomes one wrapper element holding three data attributes, with the canvas inside it:
<div data-sequence
data-sequence-url="{{ sequence_url }}"
data-sequence-count="{{ sequence_count }}"
data-sequence-pad="{{ sequence_pad | default: 3 }}">
<canvas data-sequence-canvas></canvas>
</div>Padding became an attribute because animator output may use three, four, or five digit indices. This set happens to be three digits, but as long as the value lives as a constant inside the JavaScript, every new set means touching code again. In the schema, the padding control ranges from one to six digits with a default of three, and the frame count ranges from thirty to two hundred and forty in steps of ten, defaulting to a hundred and twenty.
On the JavaScript side, the address builder keeps both environments inside a single function:
const frameSrc = (i) => {
const idx = String(i).padStart(pad, '0');
if (url.includes('{N}')) return url.replace(/\{N\}/g, idx);
// legacy fallback: treat url as a folder
return `${url.replace(/\/$/, '')}/img_${String(i).padStart(4, '0')}.jpg`;
};The first branch serves the theme, where the address is already complete and only the placeholder needs swapping. The second is the legacy fallback that treats the URL as a folder and appends a filename with four digit padding hardcoded into it.
The canvas stays blank until someone scrolls
With the URLs sorted, the canvas was still blank on page load. The first frame never painted. The moment the user scrolled, the image appeared.
The root cause
Image preloading is asynchronous. Without a load listener on each image, nothing triggers the first draw once the opening frame finishes decoding, and the drawing path that is wired up is the one driven by scroll position. Before anyone scrolls, nothing ever tells the canvas to paint.
In my head the lines read sequentially, as if preload finishing automatically meant the first frame was visible. Preloading only prepares the material. Putting that material on screen is a separate job you have to call yourself.
The fix
One load listener per image, scheduling a redraw the moment a frame lands:
for (let i = 1; i <= count; i++) {
const img = new Image();
img.decoding = 'async';
img.addEventListener('load', queueDraw, { once: true });
img.src = frameSrc(i);
frames.push(img);
}queueDraw paints through requestAnimationFrame to avoid blocking, and the result is that the first frame is drawn as soon as it decodes, without waiting for a scroll. The listener goes on every image, not only the first, and the { once: true } option keeps each image to a single trigger for its lifetime.
Filling the box, not fitting the whole image
The scale calculation uses Math.max over the width and height ratios:
const ratio = Math.max(canvas.width / img.naturalWidth, canvas.height / img.naturalHeight);Math.min in that spot is not cover but contain: the entire image fits, and whichever side does not match is left with empty bands showing the background through. Math.max keeps the canvas always full.
What I let go of
The replace filter carries the first frame's version parameter across every URL it generates. Frame two may well have a different v= value of its own, but the file at that CDN path still loads, and the most that shows up is a slight cache miss. I took it as a deliberate tradeoff rather than a bug left pending, because the alternative is printing a hundred and twenty complete URLs into the HTML.
Some time later the shop owner decided a single still image was enough for the hero, so this frame sequence did not survive as the final result. The addressing pattern itself held up: one asset URL, one placeholder, one loader that reads it, and one override field that opens the door to an external CDN without touching code again.