Direct DOM Swipe-Back: Finishing What the Ownership Audit Started
The Revert That Wasn’t Final
The ownership audit identified a failed optimization: writing node.style.transform directly during swipe gestures instead of going through $state. The problem was an element mismatch — use:swipeBack lived on a <div> inside ReaderView.svelte, but Svelte’s style="transform:translateX(...)" binding was on #view-reader in +page.svelte. Two different elements. The action moved the wrong one.
The revert was correct at the time. But the underlying performance problem remained. Profiling the swipe-back gesture showed ~8ms of main thread work per frame:
swipeProgresswritten as$stateon everytouchmove(~60Hz)- Svelte diffs the reactive graph:
swipeProgress→$derived(swipeProgress * 100)→ templatestyle=binding - Four view-layer divs re-evaluate their
class:swipe-back,class:swipe-active,class:swipe-animatingbindings - Each binding that changes triggers a DOM write through Svelte’s update cycle
box-shadowon.swipe-activeforces a repaint every frame (non-compositable)- 25 GalleryRow sprite strips paint from
visibility:hidden→visiblein one frame
The sibling manga-reader app — same swipe action, same architecture — didn’t have this problem. It has one ChapterList with ~10 rows, not 25 GalleryRows with 300px sprite strips each. The reactive overhead that’s invisible with 10 rows becomes a stutter with 25.
The Element Mismatch Fix
The previous attempt failed because the action didn’t know which element to transform. The action’s node is whatever element has use:swipeBack — a wrapper div inside the view component. The element that needs the transform is the .view-layer in +page.svelte.
The fix is one line:
activeLayer = node.closest('.view-layer') as HTMLElement;
Walk up from the action’s node to the view layer. Now the action owns the right element. For the back view, use the ID convention already established:
const backId = opts.peekBack();
backLayer = backId ? document.getElementById('view-' + backId) : null;
This is why the id="view-list", id="view-reader" naming convention matters. It gives the action a stable way to find the back layer without any reactive state.
Ownership Transfer: From Svelte to the Action
The previous architecture split ownership across two layers:
+page.svelte (template) swipeBack.ts (action)
├─ class:swipe-active={...} ├─ opts.ui.isSwiping = true
├─ class:swipe-back={...} ├─ opts.ui.swipeProgress = 0.45
├─ class:swipe-animating={...} ├─ opts.ui.swipeAnimating = true
├─ style="transform:..." └─ opts.ui.swipeProgress = 1
└─ class:view-hidden={...}
The action wrote $state. Svelte read it and applied DOM changes. Every frame, the reactive graph mediated between the gesture producer and the DOM consumer. That mediation is the overhead.
The new architecture gives the action exclusive ownership during the gesture:
swipeBack.ts (action)
├─ activeLayer.classList.add('swipe-active')
├─ backLayer.classList.add('swipe-back')
├─ activeLayer.style.transform = `translateX(${progress * 100}%)`
├─ activeLayer.classList.add('swipe-animating')
└─ cleanup() removes everything
Zero $state writes. Zero Svelte reactive evaluations. The action writes directly to the DOM elements it found via closest() and getElementById(). Progress is a plain let — not reactive, not observable, just a number updated on touchmove.
The CSS Cascade Trick
Removing Svelte’s class: bindings means the action adds swipe-back to a view that Svelte has marked view-hidden. Both classes exist simultaneously:
.view-layer.view-hidden {
visibility: hidden;
pointer-events: none;
}
.view-layer.swipe-back {
visibility: visible;
pointer-events: none;
}
Same specificity, but .swipe-back is defined after .view-hidden in the stylesheet. CSS cascade: last declaration wins. The back view becomes visible during the swipe without Svelte knowing or caring.
When the swipe completes: cleanup() removes swipe-back, then onClose() calls popView() which changes viewMode, then Svelte removes view-hidden from the now-active view. All synchronous. No flash between states.
Compositable Shadow
The old .swipe-active had box-shadow: -10px 0 30px rgba(0,0,0,0.3). Box shadows can’t be composited — the browser must repaint the entire element every frame the shadow is visible. During a swipe, that’s a full-viewport repaint at 60fps.
Replaced with a pseudo-element:
.view-layer.swipe-active::after {
content: '';
position: fixed;
top: 0;
bottom: 0;
left: -30px;
width: 30px;
background: linear-gradient(to right, transparent, rgba(0, 0, 0, 0.3));
pointer-events: none;
}
The pseudo-element is a separate layer. It composites with the parent’s transform for free — the GPU just moves the texture. No repaint.
Off-Screen Row Rendering
When the back view becomes visible mid-swipe, the browser needs to paint its content. With 25 GalleryRow components, each containing a 300px-tall sprite strip, that’s a lot of paint work in one frame.
.manga-row {
content-visibility: auto;
contain-intrinsic-size: auto 100% 300px;
}
content-visibility: auto tells the browser to skip rendering for off-screen rows. When the back view goes from visibility:hidden to visibility:visible, only the rows in or near the viewport paint. The rest have a placeholder size (300px — matching SPRITE_THUMB_HEIGHT) but no pixel work. As the user scrolls the back view later, rows render on demand.
RAII-Style Cleanup
The cleanup() function is the Drop implementation:
function cleanup() {
if (cleanedUp) return;
cleanedUp = true;
clearTimeout(fallbackTimer);
activeLayer.removeEventListener('transitionend', onTransitionEnd);
activeLayer.classList.remove('swipe-active', 'swipe-animating');
activeLayer.style.transform = '';
activeLayer.style.willChange = '';
backLayer?.classList.remove('swipe-back');
backLayer?.style.willChange = '';
}
The cleanedUp flag ensures it runs exactly once — it’s called from both transitionend and a 300ms fallback timeout (in case transitionend doesn’t fire, which happens if the element is removed or the transition is interrupted). Whichever fires first cleans up; the other is a no-op.
Every DOM mutation the action makes during the gesture is reversed here. The view layers return to the exact state Svelte expects — only view-hidden classes, no inline styles, no gesture artifacts. Svelte’s class:view-hidden binding remains correct throughout because it was never the thing being overridden; the cascade was.
What Changed in +page.svelte
The template went from this:
<div
id="view-reader"
class="view-layer"
class:view-hidden={!inReader}
class:swipe-active={inReader && isSwiping}
class:swipe-animating={inReader && swipeAnimating}
style="{inReader && isSwiping ? `transform:translateX(${swipeProgress * 100}%)` : ''}"
bind:this={readerRoot}
>
To this:
<div id="view-reader" class="view-layer" class:view-hidden={viewMode !== 'reader'} bind:this={readerRoot}>
Seven deriveds removed (isSwiping, swipeAnimating, swipeProgress, backView, inReader, inFavorites, inSaved). Three $state fields removed from UIState (isSwiping, swipeProgress, swipeAnimating). The page component now has a single derived: viewMode. Everything else is the action’s responsibility.
The Lesson
The ownership audit was right to revert direct DOM — the action was reaching for an element it didn’t own. But the conclusion “use $state for swipe” was a local fix, not the right architecture. The real fix was giving the action the tools to find and own the correct elements: closest() for the active layer, getElementById() for the back layer, and a cleanup function that guarantees the DOM returns to Svelte’s expected state.
Direct DOM during high-frequency gestures isn’t an anti-pattern in Svelte 5. It’s an ownership pattern: acquire the elements, own them exclusively for the gesture duration, release them cleanly when done. The reactive graph handles steady-state view management. The action handles transient gesture physics. They don’t overlap.