Recipes
Fly-from-thumbnail transitions, custom toolbars and captions, deferred loading, reactive slides and headless mode.
Common patterns, each building on the Quick start. Every
recipe is optional — picola works with just slides and bind:open.
Fly from the thumbnail
Register thumbnails with an attachment and the open / close transitions animate from and to them:
<script lang="ts"> import { Lightbox, createGalleryOrigins } from 'picola/svelte'; const gallery = createGalleryOrigins();</script>
{#each slides as slide, i} <img {@attach gallery.attach(i)} src={slide.placeholder} alt={slide.alt} />{/each}
<Lightbox bind:open bind:index {slides} origin={gallery.origin} />A plain origin={(i) => thumbEls[i]} resolver works too. When the origin is an
<img> with object-fit: cover, the transition animates a clip reveal instead
of a plain scale.
Custom toolbar buttons
The toolbar snippet receives a fully typed context — the core viewer, the
active slide (including your meta), and navigation helpers:
<script lang="ts"> import { Lightbox, ToolbarButton } from 'picola/svelte'; import Download from '@lucide/svelte/icons/download';</script>
<Lightbox bind:open bind:index {slides}> {#snippet toolbar(ctx)} <ToolbarButton label="Download" onclick={() => download(ctx.slide)}> <Download /> </ToolbarButton> {/snippet}</Lightbox>Custom and interactive captions
If a slide has alt, picola renders a clamped caption with expand-on-tap and a
toolbar toggle. Replace it entirely with the caption snippet — it receives the
context plus expanded and toggle() — or disable the feature with
showCaption={false}:
<Lightbox bind:open {slides}> {#snippet caption(ctx)} <MyCaption text={ctx.slide?.alt} expanded={ctx.expanded} ontoggle={ctx.toggle} /> {/snippet}</Lightbox>Want a tap on the caption to open your own full-text modal instead? Render it inside the snippet (it lives inside the lightbox root, so stacking and the focus trap just work) and suspend picola’s keyboard handling while it is up so Escape reaches your modal first:
{#snippet caption(ctx)} <button onclick={() => { altModal = true; ctx.setKeyboardEnabled(false); }}> {ctx.slide?.alt} </button> {#if altModal} <MyAltModal text={ctx.slide?.alt} onclose={() => { altModal = false; ctx.setKeyboardEnabled(true); }} /> {/if}{/snippet}Defer full resolution until the user zooms
Instead of fetching originals for every viewed slide, defer them to actual zoom
intent — zoomintent fires once per slide on the first pinch, double-tap, wheel
zoom or +:
<Lightbox bind:open {slides} onzoomintent={async ({ index }) => { const url = await fetchOriginalUrl(index); lightbox.updateSlide(index, { src: url }); }} bind:this={lightbox}/>While the new source decodes, the previous image stays visible (double buffering), so the swap is seamless even mid-zoom.
Upgrade resolution after opening
Open instantly with what you have, then swap in a better source. Zoom and pan are preserved across the swap:
<Lightbox bind:open {slides} onchange={async ({ index }) => { const url = await fetchOriginalUrl(index); lightbox.updateSlide(index, { src: url }); }} bind:this={lightbox}/>Reactive slides and pagination
The slides prop is reactive. Replacing the array — appending more images after
pagination, reordering, filtering — syncs into the open viewer, clamping the
active index and preserving zoom state:
<script lang="ts"> let slides = $state<Slide[]>(firstPage);
async function onchange({ index }: { index: number }) { if (index >= slides.length - 2) { slides = [...slides, ...(await fetchNextPage())]; } }</script>
<Lightbox bind:open bind:index {slides} {onchange} />Headless mode
With chrome={false} picola renders only the image surface — gestures, zoom and
transitions — and none of the built-in UI. Drive it entirely from your own
components through bind:viewer:
<script lang="ts"> import { Lightbox, type Viewer } from 'picola/svelte'; let open = $state(false); let viewer = $state<Viewer | null>(null);</script>
<Lightbox bind:open bind:viewer {slides} chrome={false} />
{#if open && viewer} <nav class="my-chrome"> <button onclick={() => viewer.prev()}>Prev</button> <button onclick={() => viewer.next()}>Next</button> <button onclick={() => viewer.close()}>Close</button> </nav>{/if}