Lucentive Labs
Loupe Docs · Lucentive Labs
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?

Review & hand off

Confirm your decisions, then hand them to the build pass.

  1. Color systemInk + one signal
  2. Headline voiceBold sans

Banned

  • Generic SaaS gradient blobs.
  • Cold corporate blue as the brand.
Raw brief · markdown for the build pass

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.