Phoenix LiveView function component for vertical-image-strip scrolling.
Use this for content that is read by scrolling continuously through
a stack of full-width images: manhwa, long-form comics, IG-style feeds,
documentation snapshots. For deep-zoom imagery (museum scans, single
mega-images you pan/zoom around), use Fresco.viewer instead.
Why a separate component?
Fresco.viewer wraps OpenSeadragon, which redraws its <canvas> on
every pan frame. For long-scroll content on mobile (especially iOS
Safari) this burns 10-20ms per frame and chokes 60fps scroll. Native
browser scroll on DOM <img> is GPU-composited and effectively free
per frame.
ScrollStrip skips OSD entirely: one <img loading="lazy"> per
source, native scroll, no canvas, no spring math, no per-frame JS.
Memory windowing evicts off-screen image src attributes (preserving
layout via aspect-ratio) so a 50-image chapter doesn't pin 600 MB of
decoded pixels.
Usage
<Fresco.scrollStrip
id="reader"
sources={[
%{url: "/img/page-01.jpg", width: 720, height: 9200},
%{url: "/img/page-02.jpg", width: 720, height: 8800},
%{url: "/img/page-03.jpg", width: 720, height: 9100}
]}
class="w-full h-lvh"
/>Each source map MUST include :width and :height in source pixels —
used to set inline aspect-ratio per <img>, which keeps the layout
stable through memory-windowing evict/restore cycles. Omitting them
raises ArgumentError at render time.
Handle API
Look up the strip handle once it's mounted:
window.Fresco.onReady("reader", function (handle) {
handle.scrollTo({imageIdx: 3, y: 0, behavior: "smooth"});
handle.scrollBy({dy: 500, behavior: "instant"});
handle.getScrollState(); // { scrollTop, scrollHeight, viewportH, currentImageIdx, fractionWithin }
handle.on("viewport-change", function (e) {
// e.currentImageIdx, e.fractionWithin
});
handle.on("image-loaded", function (e) { /* e.imageIdx */ });
handle.on("image-evicted", function (e) { /* e.imageIdx */ });
handle.on("scroll", function (e) { /* e.scrollTop, e.scrollHeight */ });
handle.on("open", function (e) { /* e.sources */ });
});See README "Strip mode for long-scroll content" for the full contract,
including: the difference between viewer and strip handles, why
handle.openSeadragon throws on strip (use feature detection like
"scrollTo" in handle), and the Etcher >= 0.3 requirement for
annotations on strip mode.
Server-pushed scrolling
Push phx:scroll-to from your LiveView to programmatically scroll —
useful for chapter-resume restoration:
push_event(socket, "phx:scroll-to", %{imageIdx: 5, y: 0, behavior: "smooth"})The hook forwards the payload straight to handle.scrollTo/1.
Summary
Functions
Renders a vertical-image-strip scroll container.
Functions
Renders a vertical-image-strip scroll container.
Each source becomes a <img loading="lazy"> inside the scroll
container, with inline aspect-ratio set from the source's
width/height. The companion JS hook (FrescoScrollStrip) attaches
on mount and wires the scroll bridge + memory windowing + handle
registry.
Attributes
id(:string) (required) - DOM id; must be unique on the page.sources(:list) (required) - Ordered list of images to render as a vertical strip. Each entry is a map:%{ url: "/uploads/page-01.jpg", # required — image URL width: 720, # required — source pixel width height: 9000 # required — source pixel height }widthandheightare mandatory so the component can emitaspect-ratio: <w> / <h>on each<img>. That preserves layout through memory-windowing evict/restore cycles (removingsrcdoesn't collapse the slot to 0px → no scroll-position jumps) and avoids cumulative layout shift before images decode.class(:string) - CSS classes for the scroll container. Defaults tow-full h-screen. Defaults to"w-full h-screen".theme(:atom) - Color scheme for the strip's container background and scrollbar. Same semantics asFresco.viewer's:theme. With:inherit, define the--fresco-*custom properties on.fresco-strip[data-fresco-theme="inherit"]in your CSS.Defaults to
:system. Must be one of:system,:light,:dark, or:inherit.window_before(:integer) - Memory windowing: how many images before the current dominant image to keep loaded. Default1. Images outside the[current - window_before, current + window_after]range get theirsrcevicted to free decoded-image memory; they restore on re-entry.Defaults to
1.window_after(:integer) - Memory windowing: how many images after the current dominant image to keep loaded. Default3(skewed forward because scroll is typically downward and prefetching ahead avoids visible loads).Defaults to
3.gap_px(:integer) - Spacing between images, in CSS pixels. Default0(manhwa / long-comic convention — gutters live inside the image, not between images). Set to8or16for IG-feed-style layouts where each image is its own card.Defaults to
0.snap_to_image(:atom) - CSSscroll-snapbehavior for the container.:off(default) — no snap; native scroll.:mandatory—scroll-snap-type: y mandatory. Always locks the viewport to an image top. Right for short-image-per-screen content (IG-style feeds, slide decks).:proximity—scroll-snap-type: y proximity. Snaps only if the user releases near a snap point.
For tall continuous content (manhwa pages at 7-9k px), keep at
:off— snap would either lock you to image tops (:mandatory) or yank mid-read (:proximity).Defaults to
:off. Must be one of:off,:mandatory, or:proximity.view_tracking(:boolean) - Enables theview-focus/view-blurevent channel for reading- time / engagement analytics on the strip. Same semantics as<Fresco.canvas>'s:view_tracking: whentrue, the engine watches which image is dominant and emits paired focus/blur events when it changes.The strip's notion of "dominant" is the existing
currentImageIdx(image whose center is closest to the viewport center) — same image that drives theviewport-changeevent. The view-tracking layer adds a settle-time gate (so fast scrolls don't emit a focus for every page flown past) and a Page Visibility pause.Defaults to
falseso consumers who don't subscribe pay zero cost.Defaults to
false.view_settle_ms(:integer) - Milliseconds the dominant image must hold beforeview-focusfires. Only consulted when:view_trackingistrue. Default150.Defaults to
150.Global attributes are accepted.