Fresco

Polished pan-zoom image viewer for Phoenix apps. The foundation for layered image experiences (annotations, progressive quality, 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, rotation, fit-to-view, a nav that fits a phone, and viewport clamping.


How it works

Fresco is its own small pan/zoom engine — no OpenSeadragon, no <canvas> element, no external JavaScript. (Versions before 0.5.0 were built on OpenSeadragon; 0.5.0 replaced it. See the CHANGELOG.)


Install

def deps do
  [
    {:fresco, "~> 0.14"}
  ]
end

Then in your assets/js/app.js, import the JS hooks and spread them into your LiveSocket hooks:

import "../../deps/fresco/priv/static/fresco.js"

let liveSocket = new LiveSocket("/live", Socket, {
  hooks: { ...window.FrescoHooks, ...colocatedHooks }
})

The hooks are FrescoViewer (for <Fresco.viewer>) and FrescoCanvas (for <Fresco.canvas>) — if you maintain an explicit hooks map instead of spreading window.FrescoHooks, register both.


Use it standalone

<Fresco.viewer
  id="photo"
  src={~p"/uploads/photo.jpg"}
  class="w-full h-[80vh] rounded"
/>

You get:


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:

The reset-view button 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 — <Fresco.canvas>

<Fresco.canvas> lays any number of images out on one surface, at canvas-pixel coordinates, with the same gestures as the viewer. The scene is a Fresco.Canvas struct — images plus an extensions map for peer packages — that serializes to a single .fresco JSON document.

canvas =
  Fresco.Canvas.new(width: 2000, height: 1200)
  |> Fresco.Canvas.add_image(%{src: "/uploads/a.jpg", x: 0, y: 0, width: 900})
  |> Fresco.Canvas.add_image(%{src: "/uploads/b.jpg", x: 1000, y: 0, width: 900})
<Fresco.canvas id="board" canvas={canvas} class="w-full h-[80vh] rounded" infinite_canvas />

Swapping the image set in place — handle.setSources

On a <Fresco.canvas>, handle.setSources(sources, opts) replaces the entire image set without remounting the DOM — so state tied to the page session survives the swap: an engaged Pointer Lock, an audio/video pipeline, and peer overlays bound via handle.on(...). It's the in-place alternative to window.location.href = …; paged readers use it for instant chapter transitions.

const handle = window.Fresco.viewerFor("reader");
await handle.setSources(nextChapter.images, {
  reset_view: true,                                   // fit-to-canvas after swap (default)
  extensions: { etcher: { annotations: nextChapter.shapes } }, // optional, replaced atomically
});
// state that would die on a full navigation (Pointer Lock, etc.) is still alive
history.pushState({}, "", nextChapter.url);

Per-image helpers for paged readers

A reader that shows one page at a time and cycles a "load window" of in-flight images pairs setSources with:

// navigate to page N within the loaded chapter
handle.setImageSrc("page-" + n, pageMeta.url);   // swap placeholder → real
handle.setImageVisible("page-" + cur, false);    // hide the old page
handle.setImageVisible("page-" + n, true);       // show the new one (synchronous)
handle.fitBounds(handle.imageBoundsFor("page-" + n));  // measures correctly now

Long-scroll reading — fresco_strip

For reading by scrolling through a stack of full-width images (manhwa, long comics, feeds), use the sibling package fresco_strip: native browser scroll over DOM <img>s with memory windowing, no zoom. It was <Fresco.scroll_strip> until it moved into its own package; it registers its handles in the same window.Fresco registry, so onReady works for both.


Rotation

Quarter-turn rotation is built in. The nav carries a clockwise button (:rotate) and its counter-clockwise twin (:rotate_left); choose which show with :nav_buttons. Rotation is tracked separately from zoom and pan: reset view re-fits the picture and returns it to its mount-time angle (initial_rotation), and a double-click zooms back out without touching the rotation.

<Fresco.viewer
  id="photo"
  src={~p"/uploads/photo.jpg"}
  class="w-full h-[80vh] rounded"
  initial_rotation={@photo.rotation}
  persist_rotation
/>

Real pixels — actual_size

By default a picture is fitted to the viewer, however big or small it is. A photo app does something better, and actual_size turns it on:

<Fresco.viewer id="photo" src={@url} actual_size />

100% is measured in natural pixels for <Fresco.viewer> and in canvas units for <Fresco.canvas> — so lay a canvas out in the source image's own pixels (an image width equal to the file's width), even while a smaller copy is what loads, and the numbers read true. handle.getZoomPercent() reads it; handle.toggleActualSize(), handle.zoomToActualSize() and handle.fitToView() drive it; and the zoom event carries percent alongside scale.


The nav on small screens

A column of buttons down the left edge is fine on a desktop and runs most of the way down a phone's picture. Fresco gives you a few dials:

<Fresco.canvas id="board" canvas={@canvas} nav_layout={:row} nav_reverse nav_overflow={[:fullscreen, :home]} />

Gestures and events worth knowing


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}
/>

Heads up: :system is the default since 0.1.4. Viewers on dark-OS machines render dark out of the box. Pass theme={: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 (Etcher for annotations, Tessera for progressive quality, 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) { /* … */ });
});

Adding nav buttons

handle.appendNavButton(svg, title, onClick, opts) adds a button to the viewer's nav and returns a remover (with .setIcon, .setTitle and .el). Pass { slot: n } to put it at the start of the nav instead of the end, ordered by slot — so an extension's main control leads the row whichever script attached first. Etcher's pencil, style-panel switch and eye use slots 0, 1 and 2.

Source providers

Override how Fresco treats a source URL for specific patterns. The factory returns a source descriptor; the engine renders {type: "image", url} (an ordinary image), which is also the default for every URL:

window.Fresco.registerSourceProvider(
  function(url) { return url.startsWith("/private/"); },
  function(url) { return { type: "image", url: url + "?signed=" + token }; }
);

Any other type throws a clear error rather than failing silently — the engine draws images, not tile pyramids.


Family of packages

Fresco is the foundation. Related published packages:

You can use Fresco entirely on its own; you don't need any of the related packages.


License

MIT — see LICENSE.