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.
Attaching annotation tools (or other peer libraries)
<Fresco.scroll_strip> doesn't use a %Fresco.Canvas{} struct — its
state is just :sources + (since 0.5.3) :extensions, both passed
directly via assigns. To wire something like Etcher (when its
strip-renderer port lands), keep an :extensions map in your
LiveView assigns and re-render through it:
def mount(_params, _session, socket) do
sources = ... # %{url, width, height} list, loaded from your storage
extensions = ... # %{"etcher" => %{"version" => "1", "annotations" => [...]}}
# — or %{} if you don't have any yet
{:ok, assign(socket, sources: sources, extensions: extensions)}
end
def handle_event("etcher:annotations-changed", %{"annotations" => annotations}, socket) do
new_extensions =
Map.put(socket.assigns.extensions, "etcher", %{
"version" => "1",
"annotations" => annotations
})
{:noreply, assign(socket, extensions: new_extensions)}
end
def render(assigns) do
~H"""
<Fresco.scroll_strip
id="reader"
sources={@sources}
extensions={@extensions}
class="w-full h-lvh"
/>
<Etcher.layer fresco_id="reader" />
"""
endEtcher (or any peer library) reads its initial state via the strip
handle at mount — handle.getExtension("etcher") — and uses
handle.getImages() to discover per-image positions for overlay
placement. Mutating @extensions and re-assigning re-renders the
strip host with the new data-extensions; the handle's
getExtension returns the fresh data on the next call.
Symmetric with <Fresco.canvas>: the on-the-wire shape inside
extensions.etcher is identical, so a consumer that already
handles etcher:annotations-changed for canvas can reuse the
exact handler for strip — the only difference is that strip-mode
annotations carry an additional image_idx field in their
payload (which Etcher's strip-renderer will populate).
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.extensions(:map) - Open map for peer-library state (annotation tools, ML overlays, comment threads, …). Rendered asdata-extensions={Jason.encode!(...)}on the strip host so the JS engine can expose it viahandle.getExtension(name). Mirrors<Fresco.canvas>'s:extensionscontract so consumers can persist the same shapes across both components.Default
%{}— nodata-extensionsattribute emitted; existing strip consumers see no change.Attaching extensions
A peer library like Etcher reads its initial state via the strip handle at mount, then renders per-image overlays as siblings of each
<img>. Usehandle.getImages()to discover per-image positions (top / height in scroll-container coordinates) — these are read live from each<img>'soffsetTop/offsetHeightand stay valid across memory-windowing evict/restore because the component setsaspect-ratioper image.window.Fresco.onReady("reader", function (handle) { var etcher = handle.getExtension("etcher"); var pages = handle.getImages(); // pages[i] = { idx, url, naturalWidth, naturalHeight, top, height, element } });Mutating the map server-side and re-assigning re-renders the strip host with the new
data-extensions; consumers readinghandle.getExtension(name)after the re-render see the fresh data.Defaults to
%{}.Global attributes are accepted.