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.)
- The picture is an ordinary
<img>inside a stage element. Pan, zoom and rotation are one CSStransformon the stage (translate3d(…) rotate(…)), and the image is sized for the current scale — so moving around is a GPU-composited transform, smooth on iOS Safari, with no per-frame redraw. - Input comes straight from the browser: Pointer Events for drags and pinches, wheel events for mouse wheels, trackpads and trackpad pinches, the keyboard, and the native Fullscreen API.
<Fresco.canvas>is the same engine with N images laid out at canvas-pixel coordinates, plus an openextensionsmap that peer packages (like Etcher) keep their data in. A scene serializes to one.frescoJSON document.- Everything is in
priv/static/fresco.js; nothing is fetched from a CDN at runtime.
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:
- Pan: click-drag, middle-drag, touch-drag, two fingers on a trackpad, keyboard arrows. Middle-drag
works even where the left button is spoken for — an overlay's drawing tool
or marquee selection, or
pan_locked— so there's always a way to move around without switching tools. - Zoom: mouse wheel, pinch (touch or trackpad), ctrl/⌘ + scroll, double-click, nav buttons,
+/-keys. A double-click (or double-tap) zooms in on that spot and, from anywhere zoomed in, back out to the fitted view. - Rotation in quarter turns, from the nav or the handle, optionally persisted (see Rotation).
- Fit-to-view initial state regardless of image / container aspect ratio
- Nav overlay (Heroicons): zoom in / zoom out / rotate / reset view / fullscreen — a column by default, or a short row that fits a phone (see The nav on small screens).
- Viewport clamped so the image can't be panned off-screen (unless you opt into infinite canvas)
- 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. The grid is pinned to the canvas, like a board lying behind the picture: the same dots throughout, 24 px apart at the opening view, carried by a pan and spread out (growing a little) or drawn in by a zoom. Only on a board zoomed out so far that they would pack into a smear does the whole grid fade.handle.setGridVisible(false)turns it off. - Light / dark / system themes, or inherit your app's palette (see Theming).
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 image can be panned fully off-screen — pan is no longer clamped to keep it in view.
- The zoom-out floor drops to 5% of the fitted scale, so the image can shrink to a thumbnail.
- The dot-grid background shows in the void around the image, and the host div picks up a
.fresco-viewer--infinitemodifier class for infinite-mode-only styling.
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 />
- Height can be left out: it comes from each image's natural aspect ratio.
handle.imageToScreen/screenToImagework in canvas pixels, so an overlay addresses every image in the same space.- Single-image is just the N = 1 case;
<Fresco.viewer>remains the simpler component for one picture.
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);
sources— array of{ src, x?, y?, width?, height?, id?, z_index? }(thegetImages()shape).w/hare accepted as aliases forwidth/height.opts.reset_view—true(default) fits to the new canvas;falsekeeps the current pan/zoom (clamped to the new bounds).opts.extensions— replaces the canvas-level extension map atomically with the swap (e.g.extensions.etcher.annotations). Omitted → the current map is left untouched.opts.canvasWidth/opts.canvasHeight— explicit canvas extent; omitted → the new images' bounding box.- Returns a Promise that resolves after the first post-swap frame paints (the new images are positioned/sized), so
imageBoundsFor(...)/getBoundingClientRect()return measurable rects on the next line; rejects on empty/malformed input so you can fall back to a full navigation. - Resets the hidden-image set — new images don't inherit a previous set's
setImageVisible(id, false)bookkeeping. To preserve it, snapshothandle.getHiddenImageIds()before the swap and re-apply after. - Fires
openand a dedicatedsources-changedevent so overlays bound to them rebuild. It's programmatic-only (no "user did this" event) — you own URL history, server notification, and persistence. - Replace-everything by design: it doesn't diff old vs new (re-setting identical
srcs hits the browser cache). Canvas-only; the single-image viewer keepssetSource/swapSourcePreservingBounds.
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:
handle.setImageVisible(id, visible)— toggle one image without removing it from the layout. The inline visibility flips synchronously, so agetBoundingClientRect/imageBoundsForread on the next line sees the new state.handle.setImageSrc(id, url)— swap a single image'ssrc(real URL ↔ placeholder) without a full relayout; re-latches load tracking soimage-loadedfires on the new source.handle.whenLayoutSettled()— a Promise that resolves once the next frame has painted, for measuring between those mutations (setSourcesalready resolves on this gate).
// 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
/>
:initial_rotation(0 / 90 / 180 / 270) paints the first frame already rotated — no flash of the unrotated picture.:persist_rotationpushes a"fresco:rotate"event (%{"id", "rotation", "previous"}) to the owning LiveView on every change, so you can save it.- From JS:
handle.setRotation(deg),handle.rotateBy(±90),handle.rotateRight(),handle.rotateLeft(),handle.getRotation().
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:
- 100% means one image pixel per physical screen pixel. On a 2× (Retina) display that is half a CSS pixel, so 100% really does line the picture's pixels up with the screen's.
- A picture opens at fit or at 100%, whichever is smaller. A 4K photo on a laptop opens fitted (and reads something like 23%); a 50×50 icon opens at its real size, small and sharp, instead of blown up and blurred to fill the view. You can still zoom it in. Reset view, a double tap's way back out, and the swipe's "fully zoomed out" all mean that opening view.
- An "actual size" button — a magnifier with a 1 — sits between zoom out and zoom in (− 1 +). It goes to 100%, and from 100% to fit. The
1key does the same. - The zoom control sits in the bottom-right corner and always shows the percentage. Hover it with a mouse, or tap it, and a slider opens to its left. It has detents at 50%, 100%, 150%, 200% and 400%: they are spaced evenly along the slider whatever lies between them, so the stretch from 200% to 400% at the far end runs fast, and the thumb sticks at each one for a few pixels of travel before moving on (with a buzz, on a phone that has one). It runs from the zoom-out floor to 400%, or to the zoom ceiling if that is lower. Arrow keys nudge it; Page Up / Page Down go detent to detent.
- Bottom chrome of your own? Lift the control clear of it with
--fresco-zoom-bottom(and--fresco-zoom-right) on the viewer element. Etcher does this itself while its tool bar fills a phone's bottom edge.
<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:
nav_layout: :row— the buttons line up along the top instead.- It always fits. In either layout, buttons that don't fit move behind a "more" (⋯) button at the end and come back when there's room — a phone turned sideways, a split view dragged wider. Built-ins give up their place in this order: fullscreen, rotate left, rotate right, actual size, zoom out, zoom in, reset view. Buttons an extension added never move.
nav_overflow— built-ins that always live behind ⋯, for a short row of the basics:nav_overflow={[:fullscreen, :rotate_left, :home]}.nav_reverse— with a row, mirror it: the first buttons sit nearest the middle of the viewer and ⋯ out in the corner.--fresco-nav-reserve-end— a CSS custom property, set on any ancestor, for how much of the top edge your own chrome takes at the far end (a close button, say). The row stops short of it.
<Fresco.canvas id="board" canvas={@canvas} nav_layout={:row} nav_reverse nav_overflow={[:fullscreen, :home]} />
Gestures and events worth knowing
- Two fingers on a trackpad pan the picture the way they move a page's content: on a trackpad that scrolls naturally (a Mac's default), the picture follows the fingers, as if you were holding it. The browser reports them after the OS's own scroll-direction setting, so no default suits every laptop:
invert_two_finger_pan(orhandle.setTwoFingerPanInverted(true)) flips it — typically fed from a per-user preference. - Swipe between pictures. A one-finger swipe across a picture at its fitted view (its opening view, under
actual_size) firesswipe(%{direction: "left" | "right"}) on the handle and a bubblingfresco:swipeDOM event on the viewer element — your cue to show the next or previous one. Zoomed in, the same drag is a pan and is never announced. Touch and pen only. :gesturesis an allowlist ([:pan, :pinch, :wheel, :double_click, :keyboard, :swipe]) for hosts that want fewer.pan_lockedfrees the left drag for something else (a drawing tool) while middle-drag, pinch and two-finger pan still move the view.
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 (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:
etcher— annotation and markup tools (shapes, arrows, callouts, text, freehand) drawn on a<Fresco.canvas>; annotations live in the canvas'sextensionsmap.tessera— multi-resolution, progressive-quality layer: swaps to sharper sources as the user zooms in. See its README for tiling.fresco_strip— long-scroll reading over a stack of images.
You can use Fresco entirely on its own; you don't need any of the related packages.
License
MIT — see LICENSE.