Fresco

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"}
  ]
end

Then 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 }
})

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:


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

Caveat:handle.imageToScreen / screenToImage currently operate on the first source. Multi-image coordinate disambiguation is planned but not yet implemented.


Rotation

Opt-in 90° rotation button. Adds a fifth icon 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)

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

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 (daisyUI, Tailwind, custom palettes)

Fresco stays independent of any specific theme system. To wire it to a parent palette, override the variables on .fresco-viewer in your own CSS. Example for daisyUI:

.fresco-viewer {
  --fresco-bg: var(--color-base-100);
  --fresco-grid-dot: var(--color-base-300);
  --fresco-nav-bg: var(--color-neutral);
  --fresco-nav-fg: var(--color-neutral-content);
}

With that block in your app.css, every Fresco viewer follows whichever daisyUI theme is active on <html> — no fresco-side change needed.


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) { /* … */ });
});

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:

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


License

MIT — see LICENSE.