The report arrived as a single sentence from the client, short and without one technical term in it: "sometimes box appears, sometimes not rotating".
What they meant was the storytelling section where three product sachets flow through a gallery and rotate slowly as you scroll. That one sentence held two symptoms side by side, a wrong image showing up intermittently, and rotation that did not always fire.
The temptation is obvious: two symptoms arriving together from one section feel like one bug wearing two faces, so you find one cause and assume the rest heals with it. It did not work that way. Those two symptoms had two independent causes, and the only thing connecting them was the client's sentence.
The shape of the section
The section is a 350vh parallax gallery. Its content is sticky at 100vh and the gallery flows in a zigzag, odd items to one side and even items to the other, with a 50vh gap between them. Each sachet gets a rotation as it enters the screen, five degrees at first, and later the maximum angle was raised to 90 degrees.
Cause one: a setting that should never have been exposed
Those three sachets are not content. They are part of the design, with their zigzag layout and their rotation already settled. But in the schema, all three were exposed as image_picker, one setting per sachet.
{
"type": "image_picker",
"id": "sachet_2",
"label": "Sachet 2"
}A setting like that only means something once something reads it, and the reader wins over the theme asset. The shape was roughly this:
{% assign sachet = section.settings.sachet_2 %}
{% if sachet %}
{{ sachet | image_url: width: sachet.width | image_tag }}
{% else %}
<img src="{{ 'sachet-2.webp' | asset_url }}" alt="">
{% endif %}As long as nobody filled that setting in, everything looked right. The suspicion behind the wrong image is that one of those settings had been pointed at a boxed package render through the admin. A suspicion, not a certainty: the working notes hedge at exactly this point, and I have no trace of who changed it or when.
What is not a suspicion is why it was possible at all. Settings typed image_picker, video, and file_reference do accumulate client-uploaded media, and that media lives only in the live template JSON. The same theme had a separate incident that showed this the expensive way: the client uploaded a custom image through the theme editor for another section's image setting, that setting existed only in the live JSON, the local copy did not have the field, and a push from local overwrote the live JSON until the setting was wiped and the page fell back to the Liquid default. The theme editor is where the client works, and a setting I expose there is a setting somebody will eventually fill.
So changing the value through the admin is not a fix, it just refills a hole that is still wide open. The fix removes the choice: the sachet assets were baked straight into the markup, and the image_picker settings were dropped from the schema entirely.
<img src="{{ 'sachet-2.webp' | asset_url }}" alt="">In the schema cleanup notes, all three settings are recorded as removed for the same reason, the markup is hardcoded now. The stale values already saved into the template JSON do not vanish, but nothing reads them anymore, so they stop meaning anything.
Cause two: a handler that only knew change
The second cause lived in the scroll handler itself, and it was two shortcomings inside one function. The handler was not batched with requestAnimationFrame, and it never applied an initial transform.
The second one is what made the symptom look random. With no initial pass, the rotation state is wrong until the first scroll event arrives. If a visitor lands at the top and works down slowly, the first scroll event fires long before the section is visible, so the rotation is already correct by the time they get there. If the page opens with that section already on screen, for instance because the browser restored the scroll position after the back button, the sachets sit perfectly straight until somebody nudges the scroll. The handler knew how to respond to change, it never knew state.
The other shortcoming is easier to explain. Without batching, every scroll event measures geometry and then writes a style. On a device that fires many scroll events inside one frame, that work is repeated over and over for the same frame, and everything except the last pass is wasted.
Rewriting the handler
The new version brings four things at once: batching through requestAnimationFrame, a rotation lifecycle that is correct for the viewport position, one initial pass at init, and an image-load handler so the first paint is already right.
The rotation lifecycle has three explicit states. While a sachet is still below the viewport the angle is zero, while it is on screen the angle ramps, and once it has passed above the angle rests at the maximum.
const MAX_DEG = 90;
const figures = section.querySelectorAll(".story-figure");
let ticking = false;
function apply() {
const vh = window.innerHeight;
figures.forEach((fig) => {
const img = fig.querySelector("img");
const rect = fig.getBoundingClientRect();
// rect.top >= vh : still below the viewport, progress 0
// rect.top <= 0 : already scrolled past, progress 1
// in between : ramps with position
const progress = Math.min(1, Math.max(0, 1 - rect.top / vh));
img.style.transform = `rotate(${(progress * MAX_DEG).toFixed(2)}deg)`;
});
ticking = false;
}
function onScroll() {
if (ticking) return;
ticking = true;
requestAnimationFrame(apply);
}
window.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("resize", onScroll, { passive: true });The clamp on the progress line is what folds those three states into one expression. Without it, a sachet that has scrolled past the top keeps rotating beyond the maximum angle, and one still far below gets a negative value.
The ticking flag changes the shape of the work. However many scroll events arrive, only one apply is scheduled per frame, and the measuring and style writing happen in the slot the browser actually provides for that.
Then the two calls that close the original symptom:
apply(); // the initial pass, the thing that used to be missing
section.querySelectorAll(".story-figure img").forEach((img) => {
if (img.complete) return;
img.addEventListener("load", apply, { once: true });
});The first call makes the initial state correct without waiting for anyone to move the scroll. The second closes the subtler remainder. All apply reads is rect.top, and a figure's rect.top shifts if the figure above it is still changing height while its images are still arriving. Once an image finishes loading and the layout settles, the repeat pass puts the angle where it belongs. That onload pass was part of the fix itself, not a later addition.
The zigzag on small screens, and why the shift lives on the parent
The responsive values for this section were tiered from the start: section height 220vh, 280vh, then 350vh, sachet width 70vw, 50vw, then 36vw, and a gallery gap of 30vh, 40vh, then 50vh. Below 768px the gallery items were centered, so the zigzag only kicked in from 768px up.
The client asked for the zigzag on mobile and tablet to appear the same as on desktop, so the zigzag rule moved to the base and the 768px media query went away. The rest turned into four rounds back and forth.
The first round shrank the sachets to 55vw on mobile and 42vw on tablet to make room for the shift. The client's verdict: too small. The second round pushed them back up to 60vw and 44vw, and the verdict was that the zigzag was not visible enough. Both rounds hit the same wall, because the shifting room and the sachet size were competing for the same width.
The third round stopped trading one against the other. The sachets get pushed past the edge and the gallery does the cropping.
@media (max-width: 900px) {
.story-figure {
transform: translateX(18%);
}
.story-figure:nth-child(2n) {
transform: translateX(-18%);
}
}The wrapping gallery already has overflow: hidden, so the bleed is trimmed cleanly. Sachets that look clipped at the edge are what make the zigzag read strongly, and their size was not reduced at all. On desktop the values are left natural.
What matters here is which element carries it. The shift sits on the parent figure element, not on the img inside it, because the image's transform is owned by JavaScript: the rotation handler overwrites it through an inline style every frame. Touching the img means fighting the animation. Putting it on the parent gives the two transforms two separate elements, and they stack on their own without competing.
The fourth round raised the size again to 65vw on mobile and 48vw on tablet, with the bleed percentage left constant so the proportion of the zigzag stays put. That is the final state.
One fix in the same section that did not survive
Before the zigzag work there was another fix in this section with a short life. The sachet rotation felt janky on mobile because the per-image transform kept being updated as you scrolled, so the rotation was disabled there. It was done with a matchMedia('(max-width: 767px)') check, an apply function that early-returns on mobile or when the user asks for reduced motion, and will-change: transform moved into a 768px-and-up media query, since there is no point reserving a GPU layer on a viewport that animates nothing.
The client asked for that to be reverted, and the rotation was enabled on mobile again. The isMobile constant went away, the early return went back to only guarding the motion preference and an empty gallery, and will-change: transform returned to the base rule for all viewports. What stayed untouched was the phase two cross-fade, and it was never the problem because it is driven by a CSS class rather than by a transform rewritten every frame.
A third bug, a third cause
The same section also had a phase IntersectionObserver that never fired. The initial implementation observed the outer 350vh section with a threshold of 0.2, but 20 percent of 350vh is 70vh, and 70vh is not an accurate point to trigger on when the sticky content is centered in the viewport. The fix observes the 100vh sticky panel instead, with threshold 0 and a rootMargin of -5%, plus a four second fallback timer for the case where the observer does not fire while the section is plainly on screen.
Different cause, different fix, exact same section. That is the same point as above, approached from another side.
What I took away
If an image is genuinely part of the design rather than content someone is meant to change, it belongs in the markup, not in the schema. A setting I expose in the theme editor is a setting that will be filled sooner or later, and media uploaded through it lives only in the live template JSON, where it is fragile in a completely different way.
The second lesson is about reading reports. One sentence of feedback can hold two unrelated bugs, and the moment a convincing cause turns up for part of the symptoms, it is very easy to stop looking too early. Split the sentence into separate symptoms first, then make sure each symptom has a cause of its own.
And the last one: a scroll handler describes change, it never describes state. As long as no initial pass runs at init time, a scroll-driven animation will always have a window where the rendering is lying, and that window sits precisely in the first second a visitor sees. The same pattern showed up in another module of the same theme, a canvas that stayed blank until the visitor scrolled, and the fix had the same shape: a per-image onload that calls requestAnimationFrame, so frame zero paints as soon as the decode finishes.