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 or paged layouts,
reach for Fresco.viewer / Fresco.canvas from the fresco package
instead.
Why a dedicated package?
This was Fresco.scroll_strip in fresco <= 0.5.9. Extracted to
fresco_strip in 0.1.0 so consumers who only need the viewer /
canvas surface stay lightweight, and so strip mode can iterate on
its own release cadence. The component, JS hook, handle API, and
extension contract are byte-for-byte the same as the old
Fresco.scroll_strip — only the module name and the JS file you
import changed.
Usage
<FrescoStrip.viewer
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 — the registry is shared
with fresco, so the same window.Fresco.onReady(id, cb) works
regardless of which package mounted the handle:
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 */ });
});Feature-detect the strip vs viewer/canvas handles via
"scrollTo" in handle.
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)
<FrescoStrip.viewer> doesn't use a %Fresco.Canvas{} struct — its
state is just :sources + :extensions, both passed directly via
assigns. Wire something like Etcher by keeping an :extensions map
in your LiveView assigns and re-rendering 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"""
<FrescoStrip.viewer
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.
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 layout — positions in scroll-container coordinates — these come live from each<img>'soffsetTop/offsetLeft/offsetWidth/offsetHeight, padding-box-relative to the scroll container (which is the image's offset parent). ThenaturalWidth/naturalHeightfields report the bitmap's true intrinsic dimensions once loaded, falling back to the consumer-passedsources[i].width/heightfor unloaded images. All values 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, left, width, height, element // } });Consumers that mutate
<img>layout via CSS after mount (a padding slider, an aspect-ratio correction class, container resize via the layout shell) should dispatch aresizeevent on the window after the mutation so peer libraries re-query:window.dispatchEvent(new Event("resize"));<FrescoStrip.viewer>itself doesn't need the nudge — its own geometry is implicit in the DOM — but extensions that snapshot layout (Etcher's overlay sizing, ML overlay placement) do.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.