Packages
Polished pan-zoom image viewer for Phoenix apps, with optional infinite-canvas mode for Figma/Miro-style workspaces around the image. Hand-rolled CSS-transform engine — no canvas, no external JS deps. The foundation for layered extensions (annotations, ML overlays); also useful standalone.
Current section
Files
Jump to
Current section
Files
README.md
# FrescoPolished 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```elixirdef deps do [ {:fresco, "~> 0.1"} ]end```Then in your `assets/js/app.js`, import the JS hook and spread it into your LiveSocket hooks:```jsimport "../../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```heex<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_canvas` mode. Override `.fresco-viewer` in your own CSS for dark mode or a different accent.- **Smooth animations** tuned snappy-but-not-jarring---## Infinite canvasOpt-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](https://hex.pm/packages/etcher) annotations) needs to draw shapes, callouts, or labels in the white space next to the image, Figma / Miro / Excalidraw style.```heex<Fresco.viewer id="photo" src={~p"/uploads/photo.jpg"} class="w-full h-[80vh] rounded" infinite_canvas/>```What changes when `infinite_canvas` is on:- `visibilityRatio` drops to `0` (image can fully scroll off-screen)- `constrainDuringPan` flips to `false` (no rubber-band during drag)- `minZoomImageRatio` lowers to `0.05` so the image can shrink to a thumbnail- The default `.fresco-viewer` dot-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--infinite` modifier 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 canvasPass `: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."```heex<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 `src` runs through the same source-provider chain as the single-image `:src`, so you can mix plain images with DZI tile pyramids handled by Tessera.- `:src` and `:sources` are mutually exclusive in practice — pass one. Both given, `:sources` wins.- Typically paired with `:infinite_canvas` so 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 `:sources` list re-open the viewer while preserving the current zoom/pan — same trick as `swapSourcePreservingBounds`.> ⚠️ **Caveat:** `handle.imageToScreen` / `screenToImage` currently 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 contentOpt-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.```heex<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 (`:rotate` invalidates the simple translate math), or if cumulative delta crosses ~50% of viewport height (overscan).### Coordinating overlays — the `fast-pan` eventThe 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`:```jswindow.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](https://hex.pm/packages/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 contentWhen 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.```heex<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 requirementsEach 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 contractLook up the strip handle the same way as a viewer (or use the `onReady` alias):```jswindow.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 scrollingFor chapter-resume / programmatic snapping:```elixirpush_event(socket, "phx:scroll-to", %{imageIdx: 5, y: 0, behavior: "smooth"})```The hook forwards the payload straight to `handle.scrollTo/1`.### Memory windowingDefault keeps `±1 / ±3` images loaded around the dominant visible image. Configure with `:window_before` and `:window_after`:```heex<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-snapFor IG-feed-style content (one image per screen):```heex<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 annotationsEtcher `>= 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.---## RotationOpt-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.```heex<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:```heex<Fresco.viewer id="photo" src={~p"/uploads/photo.jpg"} class="w-full h-[80vh] rounded" theme={:system}/>```- `:system` (default) — follow the OS / browser `prefers-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:** `: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 systemPass `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).```heex<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.```css.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.```css.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 extensionsFresco 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.```js// 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 hatchFresco'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.```jswindow.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.openSeadragon` is a real, current OpenSeadragon Viewer — anything in the [OSD API docs](https://openseadragon.github.io/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_VERSION` in `priv/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](https://hex.pm/packages/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 providersOverride Fresco's default "treat the URL as a plain image" behavior for specific URL patterns:```jswindow.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 packagesFresco is the foundation. Related published packages:- **[`tessera`](https://hex.pm/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.---## LicenseMIT — see [LICENSE](./LICENSE).