Polished pan-zoom image viewer for Phoenix apps. The foundation for layered image experiences (deep zoom, annotations, ML overlays) — also useful standalone whenever you just need a good image viewer.
A fresco is the wet-plaster surface you paint on. Fresco the library is the surface every layered image experience sits on top of: extensions attach to the same viewer instance via a small extension API. Used alone, it's still a complete viewer with pan, zoom, fit-to-view, Heroicons nav, viewport clamping, and smooth animations.
Install
def deps do
[
{:fresco, "~> 0.1"}
]
endThen in your assets/js/app.js, import the JS hook and spread it into your LiveSocket hooks:
import "../../deps/fresco/priv/static/fresco.js"
let liveSocket = new LiveSocket("/live", Socket, {
hooks: { ...window.FrescoHooks, ...colocatedHooks }
})The hook name is FrescoViewer — if you maintain an explicit hooks map instead of spreading window.FrescoHooks, register it as { FrescoViewer: window.FrescoHooks.FrescoViewer }.
OpenSeadragon is lazy-loaded from jsDelivr on first viewer mount — no extra <script> tags needed.
Use it standalone
<Fresco.viewer
id="photo"
src={~p"/uploads/photo.jpg"}
class="w-full h-[80vh] rounded"
/>You get:
- Pan: click-drag, touch-drag, keyboard arrows
- Zoom: mouse wheel, pinch, double-click, dedicated buttons,
+/-keys - Fit-to-view initial state regardless of image / container aspect ratio
- Heroicons nav overlay: zoom-in / zoom-out / reset / fullscreen
- Viewport clamped so the image can't be panned off-screen
- Subtle dot-grid background on the viewer container (Figma/Miro style); shows through any padding around the image and lights up the void in
infinite_canvasmode. Override.fresco-viewerin your own CSS for dark mode or a different accent. - Smooth animations tuned snappy-but-not-jarring
Infinite canvas
Opt-in mode that unclamps the viewer — the user can pan past the image edges into surrounding empty space and zoom out until the image is a thumbnail in the middle of a vast canvas. Useful when a layered overlay (e.g. Etcher annotations) needs to draw shapes, callouts, or labels in the white space next to the image, Figma / Miro / Excalidraw style.
<Fresco.viewer
id="photo"
src={~p"/uploads/photo.jpg"}
class="w-full h-[80vh] rounded"
infinite_canvas
/>What changes when infinite_canvas is on:
visibilityRatiodrops to0(image can fully scroll off-screen)constrainDuringPanflips tofalse(no rubber-band during drag)minZoomImageRatiolowers to0.05so the image can shrink to a thumbnail- The default
.fresco-viewerdot-grid background that's present on every viewer becomes visible in the void around the image (in default clamped mode it's covered by OSD's canvas). The host div also picks up a.fresco-viewer--infinitemodifier class so you can target infinite-mode-only styling.
The home button (reset zoom) still returns to "image fits viewport" — the image stays the anchor point, just no longer the cage. Default is infinite_canvas={false}, so every existing viewer keeps the stock clamped behavior with no template changes required.
Multiple images on one canvas
Pass :sources (a list of maps) instead of :src to lay multiple images out on the same viewer. Each entry has src plus optional x, y, width in viewport units. The first image conventionally anchors the layout at x: 0, y: 0, width: 1, so x: 1.1 means "just to the right with a 10% gap."
<Fresco.viewer
id="gallery"
sources={[
%{src: "/uploads/a.jpg"},
%{src: "/uploads/b.jpg", x: 1.1},
%{src: "/uploads/c.jpg", x: 0, y: 1.1, width: 0.8}
]}
class="w-full h-[80vh] rounded"
infinite_canvas
/>- Height is derived from each image's natural aspect ratio — don't specify it.
- Each entry's
srcruns through the same source-provider chain as the single-image:src, so you can mix plain images with DZI tile pyramids handled by Tessera. :srcand:sourcesare mutually exclusive in practice — pass one. Both given,:sourceswins.- Typically paired with
:infinite_canvasso the user can pan freely across the layout. Without it, "Reset view" fits all sources into the viewport at mount. - Live re-renders that change the
:sourceslist re-open the viewer while preserving the current zoom/pan — same trick asswapSourcePreservingBounds.
⚠️ Caveat:
handle.imageToScreen/screenToImagecurrently operate on the first source only. If you're building an extension that needs to address pixels in source #2+ (e.g. annotations on a second image in the layout), you'll need to apply the offset yourself for now. Multi-image coordinate disambiguation is planned but not yet implemented.
Optimized pan for long-scroll content
Opt-in mode tuned for the long-scroll reading use case (manhwa, manga, comics, document viewers) where the user is panning continuously, not zooming. By default, OpenSeadragon repaints the canvas via ctx.drawImage(tile) every pan frame — fine for desktop, painfully slow on iOS Safari even on recent hardware. pan_optimized swaps the per-frame redraw for a GPU-composited transform: translate3d glide while pan is in flight, dropping per-frame cost from ~10–20ms to <1ms.
<Fresco.viewer
id="reader"
src={~p"/uploads/chapter.jpg"}
class="w-full h-screen"
pan_optimized
/>What changes when pan_optimized is on:
- On pan-start, OSD's drawer is temporarily swapped for a no-op; the canvas stops repainting per frame.
- Each pan tick applies
transform: translate3d(dx, dy, 0)to the canvas element so the existing pixels visually glide with the user's gesture. - On pan-end (or zoom-change / overscan bail), the transform is cleared, OSD's drawer is restored, and the canvas repaints once at the committed position.
- The fast path bails immediately if the user starts zooming, if rotation is active (
:rotateinvalidates the simple translate math), or if cumulative delta crosses ~50% of viewport height (overscan).
Coordinating overlays — the fast-pan event
The fast path emits a synthetic fast-pan event on the handle so overlay extensions (annotations, ML highlights, custom HUDs) can transform in lockstep with the canvas. Three phases via e.phase:
window.Fresco.onViewerReady("reader", function (handle) {
var overlay = document.getElementById("my-overlay");
handle.on("fast-pan", function (e) {
if (e.phase === "start") {
overlay.style.willChange = "transform";
}
if (e.phase === "start" || e.phase === "delta") {
overlay.style.transform = "translate3d(" + e.x + "px, " + e.y + "px, 0)";
}
if (e.phase === "end") {
overlay.style.transform = "";
overlay.style.willChange = "";
// OSD's viewport is now committed; your overlay can read fresh
// coordinates from `handle.imageToScreen(...)` again.
}
});
});Etcher >= 0.2.8 listens automatically — its SVG annotation layer transforms in lockstep with no consumer setup required. Older Etcher versions paired with Fresco pan_optimized will see annotations visibly drift during the pan window. Consumers without overlays can opt in unconditionally.
Default is off. Existing viewers see no behavior change unless they explicitly pass
pan_optimized.
Strip mode for long-scroll content
When the user is reading by scrolling through a stack of full-width images — manhwa / manga, long-form comics, IG-style feeds, documentation snapshots — the OpenSeadragon-backed <Fresco.viewer> is the wrong architecture. OSD redraws its <canvas> on every pan frame; on iOS Safari that burns most of the 16ms 60fps budget for content that doesn't need zoom. The pan_optimized fast-path partially helps but breaks down on large snaps that move past the painted viewport area.
<Fresco.scroll_strip> is a sibling component for this exact case. Native browser scroll on DOM <img> elements. No canvas. No spring math. No per-frame JS. Memory windowing evicts off-screen image src attributes so a 50-image chapter doesn't pin hundreds of MB of decoded pixels.
<Fresco.scroll_strip
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"
/>When to use which
<Fresco.viewer> | <Fresco.scroll_strip> | |
|---|---|---|
| Use case | Deep-zoom imagery; single mega-image you pan/zoom around | Long-scroll reading; stack of full-width images |
| Rendering | OpenSeadragon canvas | DOM <img> + native scroll |
| Zoom | Yes (wheel, pinch, buttons) | No (one zoom level — full width) |
| Pan | OSD viewport math + spring | Native browser scroll |
| Mobile 60fps | Tricky (canvas redraw); pan_optimized helps | Free — native scroll is GPU-composited |
| Memory | OSD's tile lifecycle | Manual src evict outside ±N window |
| Etcher overlays | Yes, via OSD coords | Yes (Etcher >= 0.3 required — uses per-image coords) |
Source-map requirements
Each source must include :url, :width, and :height (in source pixels). Width and height drive the aspect-ratio CSS on each <img>, which is what makes memory windowing safe — removing src doesn't collapse the slot, so the scroll position never jumps. Omitting either dimension raises ArgumentError at render time.
The handle contract
Look up the strip handle the same way as a viewer (or use the onReady alias):
window.Fresco.onReady("reader", function (handle) {
// Scroll commands — replace panTo / panBy
handle.scrollTo({imageIdx: 3, y: 0, behavior: "smooth"});
handle.scrollBy({dy: 500, behavior: "instant"});
// State for progress UI / chapter resume
handle.getScrollState();
// → { scrollTop, scrollHeight, viewportH, currentImageIdx, fractionWithin }
// Coordinate adapters (per-image)
handle.imageToScreen({imageIdx: 0, x: 100, y: 200});
handle.screenToImage({x: 400, y: 800});
// → { imageIdx, x, y }
// Events
handle.on("scroll", function (e) { /* e.scrollTop, e.scrollHeight (rAF-throttled) */ });
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("open", function (e) { /* e.sources — fires once on mount */ });
// Nav extension (strip ships with no built-in nav by default)
handle.appendNavButton(svgString, "My button", function () { /* … */ });
});Server-pushed scrolling
For chapter-resume / programmatic snapping:
push_event(socket, "phx:scroll-to", %{imageIdx: 5, y: 0, behavior: "smooth"})The hook forwards the payload straight to handle.scrollTo/1.
Memory windowing
Default keeps ±1 / ±3 images loaded around the dominant visible image. Configure with :window_before and :window_after:
<Fresco.scroll_strip
id="reader"
sources={@sources}
window_before={2}
window_after={5}
/>Images outside the window get src evicted; on re-entry, src is restored and image-loaded fires. The aspect-ratio style on every <img> (computed from your source's width/height) keeps the layout perfectly stable through evict/restore cycles — no scroll jumps.
Optional CSS scroll-snap
For IG-feed-style content (one image per screen):
<Fresco.scroll_strip id="feed" sources={@sources} snap_to_image={:mandatory} />Accepts :off (default), :mandatory, :proximity. For tall continuous content (manhwa pages), keep at :off — snap would either lock you to image tops or yank mid-read.
Etcher annotations
Etcher >= 0.3 is required to render annotations on strip mode. Etcher's renderer adapter feature-detects via "scrollTo" in handle and dispatches to a strip-positioning module that inserts overlay nodes as siblings of the <img> elements — they scroll with the content natively, no per-frame positioning needed. Annotation payloads gain an imageIdx field (defaults to 0 for back-compat with viewer annotations).
handle.openSeadragon is intentionally a throwing getter on the strip handle — accessing it usually means an overlay was written for the viewer host without a renderer adapter, and the thrown message points at the fix. Etcher 0.2 paired with a strip handle will see the error and fail loudly; that's by design.
⚠️ No zoom in strip mode. If your reader needs occasional zoom (pinch / double-tap), use
<Fresco.viewer>instead — strip mode is one zoom level by design.
Rotation
Opt-in 90° rotation button. Adds a fifth button to the nav column that rotates the image 90° clockwise each click. Rotation is tracked independently of zoom/pan, so "Reset view" recenters without un-rotating.
<Fresco.viewer
id="photo"
src={~p"/uploads/photo.jpg"}
class="w-full h-[80vh] rounded"
rotate
/>Default is rotate={false} — every existing viewer keeps the stock four-button layout.
Theming (light / dark / system / inherit)
Fresco ships with light + dark palettes for the viewer host background, dot grid, and nav buttons. Pass :theme to pick one:
<Fresco.viewer
id="photo"
src={~p"/uploads/photo.jpg"}
class="w-full h-[80vh] rounded"
theme={:system}
/>:system(default) — follow the OS / browserprefers-color-scheme.:light— force light palette regardless of OS preference.:dark— force dark palette regardless of OS preference.:inherit— emit only the host structure; the parent app's CSS supplies the palette. Use this to follow a parent theme system (see below).
Heads up:
:systemis the default since0.1.4. Viewers on dark-OS machines render dark out of the box. Passtheme={:light}to lock the old always-light look.
Theming is implemented as CSS custom properties on .fresco-viewer:
| Variable | Purpose |
|---|---|
--fresco-bg | Host background color |
--fresco-grid-dot | Dot grid color |
--fresco-nav-bg | Nav button background |
--fresco-nav-bg-hover | Nav button hover background |
--fresco-nav-fg | Nav button icon color |
--fresco-nav-focus | Focus-ring color |
Integrating with a parent theme system
Pass theme={:inherit} and define the six --fresco-* variables on .fresco-viewer[data-fresco-theme="inherit"] in your own CSS. Fresco skips its own var declarations for inherit-mode viewers, so the parent's values land directly. The mapping is open-ended — wire each --fresco-* to whatever your design system exposes (CSS custom properties, fixed colors, theme tokens, anything that resolves to a CSS color).
<Fresco.viewer
id="photo"
src={~p"/uploads/photo.jpg"}
class="w-full h-[80vh] rounded"
theme={:inherit}
/>Example: daisyUI tokens. Each --fresco-* maps to a daisyUI theme token; flipping daisyUI's data-theme on <html> flips Fresco's palette automatically.
.fresco-viewer[data-fresco-theme="inherit"] {
--fresco-bg: var(--color-base-100);
--fresco-grid-dot: var(--color-base-300);
--fresco-nav-bg: var(--color-neutral);
--fresco-nav-bg-hover: var(--color-base-content);
--fresco-nav-fg: var(--color-neutral-content);
--fresco-nav-focus: var(--color-primary);
}Example: bare colors. No design system required — just pin each --fresco-* to whatever you want. Useful if you only have one viewer or want a one-off palette.
.fresco-viewer[data-fresco-theme="inherit"] {
--fresco-bg: #1a1a2e;
--fresco-grid-dot: rgba(255, 255, 255, 0.08);
--fresco-nav-bg: #16213e;
--fresco-nav-bg-hover: #0f3460;
--fresco-nav-fg: #e94560;
--fresco-nav-focus: #f8b400;
}The [data-fresco-theme="inherit"] selector matches Fresco's other theme branches at specificity 20, so any override at this selector always wins.
Use it as a foundation for extensions
Fresco publishes each live viewer to window.Fresco.viewerFor(domId). Peer libraries (Tessera for deep zoom, future Etcher for annotations, etc.) look up the handle and attach without forking the viewer.
// In another LiveView hook on the same page:
window.Fresco.onViewerReady("photo", function(handle) {
// Coordinate adapters
handle.imageToScreen({x: 100, y: 50});
handle.screenToImage({x: 800, y: 400});
// Viewport
handle.getViewportBounds();
handle.fitBounds(rect, /* immediately */ true);
// Swap the source while preserving the user's zoom/pan
handle.swapSourcePreservingBounds("/path/to/new-source");
// Subscribe to viewer events
const unsub = handle.on("zoom", function(e) { /* … */ });
});Advanced: OSD escape hatch
Fresco's handle exposes the underlying OpenSeadragon Viewer instance at handle.openSeadragon. Use this when you need an OSD API that Fresco doesn't surface first-class — pan/zoom constraints, raw event handlers (canvas-double-click, canvas-key, …), OSD plugin registration, gesture rebinding, etc.
window.Fresco.onViewerReady("photo", function(handle) {
// Disable panning entirely (e.g., for a reader pinned at fit-zoom):
handle.openSeadragon.panHorizontal = false;
handle.openSeadragon.panVertical = false;
// Listen to OSD events Fresco doesn't bridge:
handle.openSeadragon.addHandler("canvas-double-click", function(e) {
// your custom double-click behavior
});
// Override OSD constraints after mount:
handle.openSeadragon.viewport.minZoomImageRatio = 1.0;
});The contract
handle.openSeadragonis a real, current OpenSeadragon Viewer — anything in the OSD API docs works.- Reaching for the escape hatch couples your code to OSD's API and version, not just Fresco's. If we ever swap the underlying engine, escape-hatch consumers will need to migrate.
- Fresco pins the OSD CDN version — see
OSD_VERSIONinpriv/static/fresco.js. The CHANGELOG flags any OSD version bump. - If you find yourself reaching for the escape hatch routinely, file an issue. Common patterns should become first-class Fresco APIs.
handle.viewer (back-compat alias)
handle.viewer is the original name for this field — it has existed (undocumented) since Fresco's first release, and Etcher already depends on it in production. It's retained indefinitely as a back-compat alias for handle.openSeadragon. New code should prefer openSeadragon — it disambiguates from "the Fresco viewer" (the component / handle itself, the colloquial referent in Fresco's own docs) and signals that you're crossing into OSD territory.
Source providers
Override Fresco's default "treat the URL as a plain image" behavior for specific URL patterns:
window.Fresco.registerSourceProvider(
function(url) { return url.toLowerCase().endsWith(".dzi"); },
function(url) { return url; } // OSD takes a DZI URL directly
);This is how Tessera (the deep-zoom layer that builds on Fresco) attaches: it registers a .dzi source provider so DZI manifests automatically trigger tile loading.
Family of packages
Fresco is the foundation. Related published packages:
tessera— deep zoom for very high-resolution images via DZI tile pyramids. Built on Fresco.- Etcher (planned) — annotation + markup tools (drawing, arrows, text, comment threads on regions of an image). Will build on Fresco.
You can use Fresco entirely on its own; you don't need any of the related packages.
License
MIT — see LICENSE.