The <nano-search> embed
Put your library's search on any website with two lines of HTML. A single dependency-free web component — about 26 KB gzipped, no build step, no framework — talking to GET /api/v1/search with a publishable key. Try it on the live demo.
Install
<script type="module" src="https://nanostudiopro.com/embed/search.js"></script>
<nano-search publishable-key="pk_sf_live_YOUR_KEY"></nano-search>htmlThe module is served from https://nanostudiopro.com/embed/search.js and updates in place — fixes reach embedded pages within minutes, which is also why we don't publish SRI hashes for it. Feel free to read the source before embedding.
Get a key
Mint one in Settings → API Tokens → Publishable search keys. Publishable (pk_sf_live_) keys are public by design — safe to ship in HTML because their containment is server-side: they are read-only, they can call exactly one endpoint (search), and every response is narrowed by the key's scope. When creating a key you choose:
projects— the key sees only these projects; absent means the whole library.origins— an exact-match Origin allowlist (e.g.https://shop.example.com). A browser-only control; the hard limits are the scope and rate limits.max_image_size— caps how large the signed image URLs can get, whatever the widget asks for.include_regions/include_narrative— whether results may carry detection boxes and AI narratives. Both default off for publishable keys.
Secret (sk_) keys are refused by the widget itself: it renders the error state, logs an explanation to the console, and never sends a request — a leaked-secret embed must fail before it can appear to work.
Attributes
publishable-keydefault: requiredA pk_sf_live_ key from Settings → API Tokens. A missing key shows the error state; a secret (sk_) key is refused loudly before a single request is sent.
themedefault: autodark · light · auto. Auto follows the visitor's OS color scheme; any other value falls back to auto. Switching the attribute re-themes live.
placeholderdefault: "Search photos"Input placeholder — also used as the input's accessible label.
page-sizedefault: 24Results per page, clamped to 1–100 (the API's own limit). Non-numeric values fall back to 24.
initial-querydefault: emptyRuns one search immediately on mount, and doubles as the widget's standing browse content: whenever the visitor clears the input, these results come back (the box itself stays empty). Later changes to this attribute do not re-run it. Without it, a cleared input shows the plain prompt.
load-moredefault: autoauto · button. Auto loads the next page as the end of the results approaches (about 600px ahead of the viewport) — no button. button restores the manual Load more button. In auto mode the button still appears in browsers without IntersectionObserver, and automatic loading always sits out rate-limit waits.
regionsdefault: offoff · boxes · dots · match — detection overlays on each photo. boxes draws the classic outlined box with its label on card hover; dots marks each detection's center with an always-visible point whose label appears on hover; match outlines ONLY the detections that matched the search — always visible, judged server-side by the same matcher the app's own overlays use. Requires the key's include_regions scope — without it the server strips regions and the widget simply shows none. Flipping between overlay styles re-dresses in place; turning overlays on or off re-runs the current search.
link-templatedefault: noneSwitches card clicks from the lightbox to navigation. Placeholders {id}, {type}, {name}, {project_id} expand URL-encoded; unknown {placeholders} pass through untouched.
projectdefault: noneComma-separated project ids. Narrows within the key's scope — the server intersects, so this is a relevance knob, never a security boundary.
serverdefault: script originAPI base. Defaults to the origin the widget module was loaded from; override only for unusual setups (proxies, staging).
label-load-more / label-empty / label-errordefault: EnglishThe i18n surface: "Load more", "No results found", "Search unavailable". Overrides flow into screen-reader announcements too.
All attributes are live: changing one updates the widget in place, and changing publishable-key resets its state and re-runs the current query.
Events
Three CustomEvents, all bubbling and composed, so you can listen on the element, the document, or anywhere between:
nano:select— a card was clicked;detail = { item }(the full result item). The only cancelable event:preventDefault()suppresses the built-in lightbox or link navigation, which is how you take over click handling entirely.nano:search— a request completed successfully;detail = { query, total, mode }, wheremodesays what triggered it: initial (anyinitial-queryrun — mount, key change, or the input being cleared back to browse), input, submit, suggestion, or load-more (button click and automatic scroll loading alike).nano:error— the widget entered an error state;detail = { code, message }with codes config, auth, rate_limited, server, network. Visitors see yourlabel-error; the message is for your code.
const el = document.querySelector("nano-search");
el.addEventListener("nano:select", (e) => {
e.preventDefault(); // suppress the built-in lightbox / link
console.log(e.detail.item); // the full result item
});
el.addEventListener("nano:search", (e) => {
// e.detail = { query, total, mode }
// mode: "initial" | "input" | "submit" | "suggestion" | "load-more"
});
el.addEventListener("nano:error", (e) => {
// e.detail = { code, message }
// code: "config" | "auth" | "rate_limited" | "server" | "network"
});jsTheming
Ten CSS custom properties are the public theming API. The theme attribute picks a preset — dark, light, or auto (the default, following the visitor's OS preference) — and any variable you set on nano-search in your own stylesheet wins over every preset:
| Variable | Dark | Light | Drives |
|---|---|---|---|
| --ns-accent | #b8965a | #8b6d3f | Card hover frame + caption, input caret and focus ring, load-more, suggestion chips |
| --ns-bg | transparent | transparent | Widget background — transparent blends into your page |
| --ns-surface | #1a1a1c | #ffffff | Input, cards, lightbox panel |
| --ns-text | #ededef | #1c1c1e | Primary text |
| --ns-muted | #9a9aa0 | #6b6b70 | Captions, counts, empty and error copy |
| --ns-border | rgba(255,255,255,.08) | rgba(0,0,0,.08) | Input and card hairlines |
| --ns-radius | 12px | 12px | Corner radius: input, cards, lightbox |
| --ns-font | inherit | inherit | One family for everything — inherits your typography |
| --ns-card-min | 220px | 220px | Grid column minimum width |
| --ns-gap | 12px | 12px | Grid gap |
| --ns-region | #ffffff | #ffffff | Detection overlays: box outline, center dot, label text. White in both presets (overlays paint on photos, not the page); a hairline dark halo keeps it readable on light photos |
The light preset carries a darker accent on purpose — the default gold on white falls below AA contrast for accent-colored text, so the preset handles it for you.
nano-search {
--ns-accent: #0ea5e9; /* your brand color */
--ns-radius: 4px; /* your corners */
}
nano-search::part(card) {
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
}
/* compact thumbnails: hide every caption with one rule */
nano-search::part(caption) {
display: none;
}cssFor the rest, thirteen ::part()hooks expose the widget's anatomy without promising its internal DOM:
input grid card image caption region region-dot load-more suggestion empty error lightbox lightbox-image
Deliberately not themeable: cards always render at each photo's real aspect ratio — no variable exists to crop them uniform — and the layout algorithm, spacing scale, and lightbox anatomy are fixed. Themes change color, shape, and density, not anatomy. The grid reads left to right and packs like a masonry wall: every card spans exactly its own height, so a short photo never leaves a hole under itself. The search input is sticky — put the widget in a fixed-height box (a sidebar rail, a compact card with overflow-y: auto) and the input stays pinned while the results scroll beneath it. Each card also reserves its exact shape from the photo's real dimensions before the image arrives — a shimmering placeholder fades into the loaded image (an instant swap under prefers-reduced-motion), so the grid never shifts while images load.
Rate limits
A publishable key serves a whole site through one credential, so two windows apply: 600 requests/min per key and 60 requests/min per visitor (key + IP). Both are enforced — the stricter one wins. On a 429 the widget keeps the last results on screen and waits out the server's Retry-After before requesting again: automatic scroll loading sits the wait out entirely (the next scroll to the end after the wait resumes it), and in button mode the Load more button is quietly disabled.
Content-Security-Policy
If your site sets a CSP, allow this host in three directives — the module, the API calls, and the signed image URLs all come from it:
Content-Security-Policy:
script-src ... https://nanostudiopro.com;
connect-src ... https://nanostudiopro.com;
img-src ... https://nanostudiopro.com;httpAccessibility
- The input is
type="search"with an accessible label mirroring the placeholder; results render as a real list and every card is a real button named by its photo. - One polite live region announces result counts, empty and error states, and the lightbox position — your
label-*overrides flow into the announcements. - The lightbox is an
aria-modaldialog: focus moves in on open, Tab is trapped inside, Esc closes, arrow keys navigate, and focus returns to the card that opened it. - Everything is reachable by keyboard alone, and
prefers-reduced-motiondisables every animation and transition — including the loading shimmer and the image fade-in, which becomes an instant swap.
Known limits
- Chrome strings beyond the
label-*attributes and placeholder are fixed English: the lightbox button labels (Close, Previous, Next), the "N results" announcement, and developer console messages. - Text search only for now — the API's color and similar modes are not yet wired into the widget.
- The lightbox upgrades photos to the next image-size rung through the same scoped endpoint, so the key's
max_image_sizecaps it and the request counts against the same rate limits. - Image URLs are short-lived signed URLs — they refresh on natural refetches and are not for hotlinking or caching.
- The widget stores nothing in the visitor's browser: no cookies, no localStorage.
Building your own UI instead? The widget's only endpoint is the Search API — the same publishable key works from any client with Authorization: Bearer.