@lucentive-labs/loupe-react
The React 19 adapter — the Loupe component and the headless useLoupe hook.
A thin React 19 view over loupe-core. All selection state, a11y, keyboard handling, crop math, and the preview/brief derivations live in core; this package maps core's connect() descriptors onto React props and renders the same structure as the canonical loupe-dom renderer — identical class names, data-loupe-part / data-group / data-option, and ARIA — so the shared loupe-dom/styles.css styles it directly.
State flows through useSyncExternalStore against the core store's stable snapshot + deterministic server snapshot, so SSR and hydration agree with no client-only flash.
import { Loupe, useLoupe } from "@lucentive-labs/loupe-react";
import "@lucentive-labs/loupe-dom/styles.css"; // import once, app-wideDeclares react / react-dom >=19 as peer deps. The component is marked "use client".
<Loupe />
The full interactive decision-lock UI — structurally equivalent to loupe-dom's renderApp.
interface LoupeProps {
config: Config;
theme?: TThemeTokens; // overrides config.theme
onLockChange?: (selections: Selections) => void; // fires after each commit
storageKey?: string; // persist picks; omit = ephemeral
}import { Loupe } from "@lucentive-labs/loupe-react";
import "@lucentive-labs/loupe-dom/styles.css";
import { config } from "./loupe.config";
export default function Page() {
return (
<Loupe
config={config}
theme={{ "color-primary": "#54deec" }}
onLockChange={(sel) => console.log("locked:", sel)}
/>
);
}themetakes precedence overconfig.theme(matchingapplyThemeprecedence), applied as inline--loupe-*vars on the root.onLockChangefires on every lock / clear / reset, deferred to a microtask so it never runs synchronously during render. This is how the tutorial keeps a live brief beside the picker.
Pass a stable config — a module constant or a useMemo'd value. The internal store is keyed on config identity, so a new object every render would reset the user's picks.
Here it is running, themed with the Night Atlas tokens:
useLoupe(config, opts?): UseLoupeResult
The headless hook behind <Loupe />. Own a core store for config and track its snapshot through useSyncExternalStore. Use it to build a custom UI while keeping core's state and SSR-safety.
interface UseLoupeOptions {
storageKey?: string; // localStorage key; omit = ephemeral
initial?: Selections; // initial picks (overrides storage + recommended)
storage?: StoragePort; // a storage port directly (overrides storageKey)
store?: LoupeStore; // an existing store (e.g. shared across components)
}
interface UseLoupeResult {
store: LoupeStore; // lock / clear / reset / clearAll
selections: Selections; // current picks (stable cached snapshot)
}function MyPicker({ config }: { config: Config }) {
const { store, selections } = useLoupe(config, { storageKey: "my-lock" });
return (
<button onClick={() => store.lock("color", "ink")}>
{selections.color ?? "open"}
</button>
);
}The store is created once and reused while config keeps the same identity; initial / storage / storageKey are read only at creation time (the store owns state thereafter).
Also exported
| Export | What it is |
|---|---|
normalizeProps(props) | maps a core connect() prop bag onto React DOM props (tabindex → tabIndex, drops undefined) |
TILE_AR | the tile aspect ratio (4 / 3), matching loupe-dom |
Config, Selections, LoupeStore | re-exported types |
Because the React output matches loupe-dom part-for-part, the same loupe-dom/styles.css and the same Playwright assertions cover both renderers. Build a custom UI only when you need to — <Loupe /> is the whole experience already styled.