Patterns
Photo in the address
The viewer can follow your state instead of its triggers. Pass open and index, and it opens, moves and closes as they change. Clicks, swipes and keys still work straight away, and the change callbacks tell your state what happened. Here that state is ?photo= in the address.
Loading the live example…
url.tsx
import { itemLabel } from "@ogdakke/fluidbox";
import { Fluidbox } from "@ogdakke/fluidbox/react";
import { useEffect, useState } from "react";
import { photos } from "./data";
/** The photo in the address: `?photo=2` is the second. */
function photoInUrl(): number | null {
const photo = Number(new URLSearchParams(location.search).get("photo")) - 1;
return Number.isInteger(photo) && photo >= 0 && photo < photos.length ? photo : null;
}
/** Put the photo on show in the address. Opening adds an entry, so Back closes the viewer. */
function followInUrl(photo: number | null) {
const shown = photoInUrl();
if (photo === shown) return;
const url = new URL(location.href);
if (photo === null) {
// Go back past the entry opening added, or drop the photo from a link you came in on.
if (history.state?.photo) return history.back();
url.searchParams.delete("photo");
return history.replaceState(null, "", url);
}
url.searchParams.set("photo", String(photo + 1));
if (shown === null) history.pushState({ photo: true }, "", url);
else history.replaceState(history.state, "", url);
}
export function Url() {
// Read the address after the first render, so the server and the browser render the same.
const [photo, setPhoto] = useState<number | null>(null);
useEffect(() => {
const onPopState = () => setPhoto(photoInUrl());
onPopState();
addEventListener("popstate", onPopState);
return () => removeEventListener("popstate", onPopState);
}, []);
return (
<Fluidbox.Root
items={photos}
open={photo !== null}
index={photo ?? undefined}
onOpenChange={(open) => {
if (open) return;
followInUrl(null);
setPhoto(null);
}}
onIndexChange={(index) => {
followInUrl(index);
setPhoto(index);
}}
>
<div className="pattern-grid">
{photos.map((photo) => (
<Fluidbox.Trigger
key={photo.src}
item={photo}
className="pattern-thumb"
>
<img src={photo.src} alt={itemLabel(photo)} />
</Fluidbox.Trigger>
))}
</div>
</Fluidbox.Root>
);
}Url.vue
<script setup lang="ts">
import { itemLabel } from "@ogdakke/fluidbox";
import { Fluidbox } from "@ogdakke/fluidbox/vue";
import { onBeforeUnmount, onMounted, ref } from "vue";
import { photos } from "./data";
/** The photo in the address: `?photo=2` is the second. */
function photoInUrl(): number | null {
const photo = Number(new URLSearchParams(location.search).get("photo")) - 1;
return Number.isInteger(photo) && photo >= 0 && photo < photos.length ? photo : null;
}
/** Put the photo on show in the address. Opening adds an entry, so Back closes the viewer. */
function followInUrl(photo: number | null) {
const shown = photoInUrl();
if (photo === shown) return;
const url = new URL(location.href);
if (photo === null) {
// Go back past the entry opening added, or drop the photo from a link you came in on.
if (history.state?.photo) return history.back();
url.searchParams.delete("photo");
return history.replaceState(null, "", url);
}
url.searchParams.set("photo", String(photo + 1));
if (shown === null) history.pushState({ photo: true }, "", url);
else history.replaceState(history.state, "", url);
}
// Read the address once mounted, so the server and the browser render the same.
const photo = ref<number | null>(null);
const onPopState = () => (photo.value = photoInUrl());
onMounted(() => {
onPopState();
addEventListener("popstate", onPopState);
});
onBeforeUnmount(() => removeEventListener("popstate", onPopState));
function onOpenChange(open: boolean) {
if (open) return;
followInUrl(null);
photo.value = null;
}
function onIndexChange(index: number) {
followInUrl(index);
photo.value = index;
}
</script>
<template>
<Fluidbox.Root
:items="photos"
:open="photo !== null"
:index="photo ?? undefined"
@update:open="onOpenChange"
@update:index="onIndexChange"
>
<div class="pattern-grid">
<Fluidbox.Trigger
v-for="photo in photos"
:key="photo.id"
:item="photo"
class="pattern-thumb"
>
<img :src="photo.src" :alt="itemLabel(photo)" />
</Fluidbox.Trigger>
</div>
</Fluidbox.Root>
</template>url.tsx
import { createSignal, For, onCleanup, onMount } from "solid-js";
import { itemLabel } from "@ogdakke/fluidbox";
import { Fluidbox } from "@ogdakke/fluidbox/solid";
import { photos } from "./data";
/** The photo in the address: `?photo=2` is the second. */
function photoInUrl(): number | null {
const photo = Number(new URLSearchParams(location.search).get("photo")) - 1;
return Number.isInteger(photo) && photo >= 0 && photo < photos.length ? photo : null;
}
/** Put the photo on show in the address. Opening adds an entry, so Back closes the viewer. */
function followInUrl(photo: number | null) {
const shown = photoInUrl();
if (photo === shown) return;
const url = new URL(location.href);
if (photo === null) {
// Go back past the entry opening added, or drop the photo from a link you came in on.
if (history.state?.photo) return history.back();
url.searchParams.delete("photo");
return history.replaceState(null, "", url);
}
url.searchParams.set("photo", String(photo + 1));
if (shown === null) history.pushState({ photo: true }, "", url);
else history.replaceState(history.state, "", url);
}
export function Url() {
// Read the address once mounted, so the server and the browser render the same.
const [photo, setPhoto] = createSignal<number | null>(null);
onMount(() => {
const onPopState = () => setPhoto(photoInUrl());
onPopState();
addEventListener("popstate", onPopState);
onCleanup(() => removeEventListener("popstate", onPopState));
});
return (
<Fluidbox.Root
items={photos}
open={photo() !== null}
index={photo() ?? undefined}
onOpenChange={(open) => {
if (open) return;
followInUrl(null);
setPhoto(null);
}}
onIndexChange={(index) => {
followInUrl(index);
setPhoto(index);
}}
>
<div class="pattern-grid">
<For each={photos}>
{(photo) => (
<Fluidbox.Trigger item={photo} class="pattern-thumb">
<img src={photo.src} alt={itemLabel(photo)} />
</Fluidbox.Trigger>
)}
</For>
</div>
</Fluidbox.Root>
);
}url.ts
import { afterNextRender, Component, HostListener, signal } from "@angular/core";
import { itemLabel } from "@ogdakke/fluidbox";
import { FluidboxModule } from "@ogdakke/fluidbox/angular";
import { photos } from "./data";
/** The photo in the address: `?photo=2` is the second. */
function photoInUrl(): number | null {
const photo = Number(new URLSearchParams(location.search).get("photo")) - 1;
return Number.isInteger(photo) && photo >= 0 && photo < photos.length ? photo : null;
}
/** Put the photo on show in the address. Opening adds an entry, so Back closes the viewer. */
function followInUrl(photo: number | null) {
const shown = photoInUrl();
if (photo === shown) return;
const url = new URL(location.href);
if (photo === null) {
// Go back past the entry opening added, or drop the photo from a link you came in on.
if (history.state?.photo) return history.back();
url.searchParams.delete("photo");
return history.replaceState(null, "", url);
}
url.searchParams.set("photo", String(photo + 1));
if (shown === null) history.pushState({ photo: true }, "", url);
else history.replaceState(history.state, "", url);
}
@Component({
selector: "pattern-url",
standalone: true,
imports: [FluidboxModule],
template: `
<div
fluidboxRoot
[items]="photos"
[open]="photo() !== null"
[index]="photo() ?? undefined"
(openChange)="onOpenChange($event)"
(indexChange)="onIndexChange($event)"
class="pattern-grid"
>
@for (photo of photos; track photo.id) {
<button [fluidboxTrigger]="photo" class="pattern-thumb">
<img [src]="photo.src" [alt]="label(photo)" />
</button>
}
</div>
`,
})
export class Url {
readonly photos = photos;
readonly label = itemLabel;
// Read the address once rendered, so the server and the browser render the same.
readonly photo = signal<number | null>(null);
constructor() {
afterNextRender(() => this.onPopState());
}
@HostListener("window:popstate")
onPopState() {
this.photo.set(photoInUrl());
}
onOpenChange(open: boolean) {
if (open) return;
followInUrl(null);
this.photo.set(null);
}
onIndexChange(index: number) {
followInUrl(index);
this.photo.set(index);
}
}url.html
<fluidbox-gallery class="pattern-grid">
<fluidbox-item item-id="photo-aurora">
<button slot="trigger" type="button" class="pattern-thumb">
<img src="/art/aurora.svg" alt="Aurora over the tundra" />
</button>
<template slot="content">
<img src="/art/aurora.svg" alt="Aurora over the tundra" width="1100" height="1650" />
</template>
</fluidbox-item>
<fluidbox-item item-id="photo-ridge">
<button slot="trigger" type="button" class="pattern-thumb">
<img src="/art/ridge.svg" alt="Night ridge" />
</button>
<template slot="content">
<img src="/art/ridge.svg" alt="Night ridge" width="1200" height="1500" />
</template>
</fluidbox-item>
<fluidbox-item item-id="photo-fjord">
<button slot="trigger" type="button" class="pattern-thumb">
<img src="/art/fjord.svg" alt="Fjord in the blue hour" />
</button>
<template slot="content">
<img src="/art/fjord.svg" alt="Fjord in the blue hour" width="1200" height="1600" />
</template>
</fluidbox-item>
<fluidbox-item item-id="photo-lagoon">
<button slot="trigger" type="button" class="pattern-thumb">
<img src="/art/lagoon.svg" alt="Lagoon after sunset" />
</button>
<template slot="content">
<img src="/art/lagoon.svg" alt="Lagoon after sunset" width="1800" height="1200" />
</template>
</fluidbox-item>
</fluidbox-gallery>url.ts
import { photos } from "./data";
/** The photo in the address: `?photo=2` is the second. */
function photoInUrl(): number | null {
const photo = Number(new URLSearchParams(location.search).get("photo")) - 1;
return Number.isInteger(photo) && photo >= 0 && photo < photos.length ? photo : null;
}
/** Put the photo on show in the address. Opening adds an entry, so Back closes the viewer. */
function followInUrl(photo: number | null) {
const shown = photoInUrl();
if (photo === shown) return;
const url = new URL(location.href);
if (photo === null) {
// Go back past the entry opening added, or drop the photo from a link you came in on.
if (history.state?.photo) return history.back();
url.searchParams.delete("photo");
return history.replaceState(null, "", url);
}
url.searchParams.set("photo", String(photo + 1));
if (shown === null) history.pushState({ photo: true }, "", url);
else history.replaceState(history.state, "", url);
}
export function url(root: HTMLElement) {
const gallery = root.querySelector("fluidbox-gallery")!;
// The gallery opens on its open and index attributes as they change.
const show = (photo: number | null) => {
if (photo !== null) gallery.setAttribute("index", String(photo));
gallery.toggleAttribute("open", photo !== null);
};
gallery.addEventListener("toggle", ({ newState }) => {
if (newState !== "closed") return;
followInUrl(null);
show(null);
});
gallery.addEventListener("itemchange", ({ index }) => {
followInUrl(index);
show(index);
});
const onPopState = () => show(photoInUrl());
addEventListener("popstate", onPopState);
show(photoInUrl());
return () => removeEventListener("popstate", onPopState);
}url.ts
import { createFluidbox, itemLabel } from "@ogdakke/fluidbox";
import { photos } from "./data";
/** The photo in the address: `?photo=2` is the second. */
function photoInUrl(): number | null {
const photo = Number(new URLSearchParams(location.search).get("photo")) - 1;
return Number.isInteger(photo) && photo >= 0 && photo < photos.length ? photo : null;
}
/** Put the photo on show in the address. Opening adds an entry, so Back closes the viewer. */
function followInUrl(photo: number | null) {
const shown = photoInUrl();
if (photo === shown) return;
const url = new URL(location.href);
if (photo === null) {
// Go back past the entry opening added, or drop the photo from a link you came in on.
if (history.state?.photo) return history.back();
url.searchParams.delete("photo");
return history.replaceState(null, "", url);
}
url.searchParams.set("photo", String(photo + 1));
if (shown === null) history.pushState({ photo: true }, "", url);
else history.replaceState(history.state, "", url);
}
export function mountUrl(root: HTMLElement) {
const fluidbox = createFluidbox({ items: photos });
const grid = document.createElement("div");
grid.className = "pattern-grid";
photos.forEach((photo, index) => {
const button = document.createElement("button");
button.type = "button";
button.className = "pattern-thumb";
button.append(Object.assign(new Image(), { src: photo.src, alt: itemLabel(photo) }));
grid.append(button);
fluidbox.bind(button, index);
});
root.append(grid);
// The address drives the viewer, and the viewer writes back to it.
const showUrl = () => {
const photo = photoInUrl();
void (photo === null ? fluidbox.close() : fluidbox.open(photo));
};
fluidbox.addEventListener("toggle", ({ newState }) => {
if (newState === "closed") followInUrl(null);
});
fluidbox.addEventListener("itemchange", ({ index }) => followInUrl(index));
addEventListener("popstate", showUrl);
showUrl();
return () => {
removeEventListener("popstate", showUrl);
fluidbox.destroy();
grid.remove();
};
}- Opening pushes a history entry and moving replaces it, so Back closes the viewer and Forward opens it again on the same photo. Push on every move instead if you want Back to step through the photos.
- A link with
?photo=2opens the second photo when the page loads, flying from its thumbnail. - The viewer ignores an
indexout of range, so a stale link opens nothing. - The callbacks fire for every input, the viewer's own buttons and keys included. In React, Solid and Vue each one also gets a
reason:"trigger","keyboard","pointer","wheel","api"or"items". - Leave
openout and the triggers own it again. Pass onlyindexto steer an open viewer from outside, as a carousel beside it would. - Each version reads the address after the first render, in an effect,
onMount,onMountedorafterNextRender. The server has nolocation, and its HTML then matches the browser's first render, with the viewer closed. Server-rendered pages, Next.js among them, take the pattern as it is.