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>
  );
}
  • 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=2 opens the second photo when the page loads, flying from its thumbnail.
  • The viewer ignores an index out 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 open out and the triggers own it again. Pass only index to 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, onMounted or afterNextRender. The server has no location, 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.