Fresco.ScrollStrip (fresco v0.5.2)

Copy Markdown View Source

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

scroll_strip(assigns)

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
    }

    width and height are mandatory so the component can emit aspect-ratio: <w> / <h> on each <img>. That preserves layout through memory-windowing evict/restore cycles (removing src doesn'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 to w-full h-screen. Defaults to "w-full h-screen".

  • theme (:atom) - Color scheme for the strip's container background and scrollbar. Same semantics as Fresco.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. Default 1. Images outside the [current - window_before, current + window_after] range get their src evicted 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. Default 3 (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. Default 0 (manhwa / long-comic convention — gutters live inside the image, not between images). Set to 8 or 16 for IG-feed-style layouts where each image is its own card.

    Defaults to 0.

  • snap_to_image (:atom) - CSS scroll-snap behavior for the container.

    • :off (default) — no snap; native scroll.
    • :mandatoryscroll-snap-type: y mandatory. Always locks the viewport to an image top. Right for short-image-per-screen content (IG-style feeds, slide decks).
    • :proximityscroll-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 the view-focus / view-blur event channel for reading- time / engagement analytics on the strip. Same semantics as <Fresco.canvas>'s :view_tracking: when true, 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 the viewport-change event. 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 false so consumers who don't subscribe pay zero cost.

    Defaults to false.

  • view_settle_ms (:integer) - Milliseconds the dominant image must hold before view-focus fires. Only consulted when :view_tracking is true. Default 150.

    Defaults to 150.

  • Global attributes are accepted.