Exploration · 2026-05-04

Image directory

A second-stage image-as-password: instead of one image opening one gate, a registry of many enrolled images each opens a different destination — link-shortener mechanics keyed by perceptual hashes. Built around two new primitives: findBestMatch() for the search and <ImageDirectoryGate> for the UX. The party flow: take a photo, caption it with the gate URL, auto-enroll, distribute.

Live demo

Try it

A live directory you can extend. Paste an image to see which entry it matches; enroll new entries via camera or upload. Everything is in-memory — refresh to clear.

Tolerance1 entry

Try the directory gate

Show your image

Pasted/uploaded images are matched against the live directory below.
Paste · Drop · Click
Click to focus, then paste, drag a file, or upload.

Last result

No attempts yet.

Live directory

1 entries
  • 5x5 Turns One!
    https://luma.com/9ynq3wzf
    p:70979f580f30f2c2 d:090235c3c0404040 a:0e1fe742c0c0c080

Enroll a new entry

Camera off. Click "Start camera" to request permission.
API

Using ImageDirectoryGate in a real page

The component takes any list of entries with the DirectoryEntry shape. EventEntry from src/lib/events.ts is the canonical superset.

ImageDirectoryGate (design system)
import ImageDirectoryGate from "@/components/ui/ImageDirectoryGate";
import { EVENTS } from "@/lib/events";

export default function Page() {
  return (
    <ImageDirectoryGate
      entries={EVENTS}
      tolerance="standard"
      onMatch={(m) => {
        // Redirect, render content, fire analytics, etc.
        window.location.href = m.entry.externalUrl ?? "/";
      }}
      onMiss={(closest) => {
        // Optional: log analytics about near-misses
      }}
      debug
    />
  );
}
findBestMatch (programmatic)
import { findBestMatch } from "@/lib/explorations/image-as-password";
import { EVENTS } from "@/lib/events";

const { matched, closest } = await findBestMatch(blob, EVENTS, {
  tolerance: "standard",
});

if (matched) {
  // matched.entry        — the directory entry that won
  // matched.result       — full per-hash distances/thresholds
  // matched.combinedDistance — sum of distances across hashes
}
Pattern

Party-distribution flow

The intended use case. Take photos at a party, caption each with the gate URL, distribute to guests with different destinations baked in.

  1. Pick the gate URL. Decide where guests will present their photos — e.g. 5x5.nyc/open-sesame. This is the page that hosts <ImageDirectoryGate>.
  2. Take photos. Use the camera flow above. Each photo gets a caption overlay (default: the gate URL itself) so it's self-describing — anyone holding the print/JPEG knows where to present it.
  3. Set per-photo destinations. Each enrolled entry carries an externalUrl. One photo could route to a luma RSVP, another to a private playlist, another to a thank-you page. The same gate handles all of them.
  4. Distribute. Print the photos, AirDrop them, drop them in a Slack thread. As long as the recipient can present a recognizable copy at the gate URL, they get routed to the right destination.
  5. Persist enrollments. The demo keeps additions in memory; for a real party, copy the EventEntry snippets into src/lib/events.ts and ship a deploy. Or wire up a backend (next section).
Why captions matter

The caption is rendered onto the photo bytes before hashing. Two consequences worth knowing:

  • The hash is now caption-aware. Re-captioning the same source photo produces a different hash. Useful for deduplication.
  • The text in the caption is a meaningful chunk of mid-frequency content for pHash, which makes the captioned photo more distinctive than a plain portrait or gradient.
Note

Persistence

The demo above keeps enrollments in component state — they vanish on refresh. For real use you need either a static-config build or a real backend.

The directory in the demo above lives in component state and resets on refresh. Three production strategies, in increasing order of complexity:

  1. Static config. Add to src/lib/events.ts, ship a deploy. Each enrollment is a PR. Best for curated experiences with low enrollment churn (party host generates everything ahead of time).
  2. Vercel KV / Postgres / Blob. Add an enrollment API route (POST /api/enroll) that stores hashes in KV. The gate page fetches the directory at request time. Enables real-time enrollment without rebuilds. Pair with auth so only the host can enroll.
  3. URL-encoded enrollment. Skip the database entirely: encode the entry (id + hashes + url) into a query string on the photo's caption (?e=base64...) and have the gate page read it on load. Fully client-side, but the URL becomes long and reveals the destination.

For 5x5's use case (party invites that don't change after the host generates them), option 1 is plenty.