picola
Docs

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}