Lucentive Labs
Lucentive Labs Docs
Packages / API

@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-wide

Declares 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
}
app/decision/page.tsx
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)}
    />
  );
}
  • theme takes precedence over config.theme (matching applyTheme precedence), applied as inline --loupe-* vars on the root.
  • onLockChange fires 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:

Live · Loupe componentLock a tile in each group
60-second direction
2 of 2 locked
01
Color system
Which palette carries the brand?
02
Headline voice
What does the type say before the words do?

Build brief

The deterministic handoff for the next build pass. Stays in sync with your locked tiles.

Banned

  • Generic SaaS gradient blobs.
  • Cold corporate blue as the brand.

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

ExportWhat it is
normalizeProps(props)maps a core connect() prop bag onto React DOM props (tabindextabIndex, drops undefined)
TILE_ARthe tile aspect ratio (4 / 3), matching loupe-dom
Config, Selections, LoupeStorere-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.