React SDK

One provider. Twelve frustrations gone. The interface starts adapting the moment you wrap your app โ€” no per-component changes needed. Drop-in components and hooks are there when you want explicit control.

v0.3.0 ยท stableTracks the current @morphuiapp/morphui release. Older versions: see GitHub releases.

Getting started

Install#

One package. React 18+. Tree-shakable only the engines you opt into ship in your bundle.

bash
npm install @morphuiapp/morphui
bash
pnpm add @morphuiapp/morphui
yarn add @morphuiapp/morphui

Getting started

Quick start#

Wrap your app in MorphProvider at the root. That single line activates six engines at once the rest of the SDK is opt-in via components or hooks.

app/layout.tsxtsx
import { MorphProvider } from "@morphuiapp/morphui";

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body>
        <MorphProvider licenseKey="cha-free-demo">
          {children}
        </MorphProvider>
      </body>
    </html>
  );
}

That's it. Six engines just started.

Auto dark mode, pre-nav skeletons, white-island fixer in dark mode, scroll restoration, mobile near-miss recovery, and lost-tap detection. Each can be turned off with one prop see opt-outs.

Getting started

What you just unlocked#

The defaults are aggressive on purpose โ€” most of these frustrations are universal, and shipping them off-by-default would mean you forget to turn them on. Here's the menu.

Auto dark mode (system + AI palette)

Free
Detects prefers-color-scheme, contrast, color-blindness, language, time of day. Generates a brand-aware dark palette via Claude (cached) or a deterministic HSL fallback when offline. โ†’ details

No more white flash on link clicks

The skeleton of the destination page paints in ~80ms before the route resolves. โ†’ PreNavSkeleton

No more bright iframes / images in dark mode

Cross-origin iframes (Stripe, YouTube), white logos, and late-injected widgets get a tasteful blend so they stop stabbing the user's eyes. โ†’ WhiteIslandFixer

Back returns to where you actually were

Scroll position + last-clicked element restored on Back/Forward, with a 600ms highlight pulse on the element. โ†’ ScrollRestoration

Mobile near-miss on Cancel/Delete recovers

When the user's thumb catches the destructive button next to the one they meant, an Undo banner slides in for 4 seconds. โ†’ ImproperTapDetector

Silent click failures get logged

Clicks that visually fired but never reached your handler (z-index war, late overlay, pointer-events bug) get diagnosed in dev console with the suspected cause. โ†’ LostTapDetector

Just works

Auto dark mode#

The user's system is in dark, your app is in dark. The user's system flips back, your app flips back. Every CSS variable Morph publishes updates live no remount, no flash.

tsx
import { useMorph } from "@morphuiapp/morphui";

function ThemeIndicator() {
  const { theme, highContrast, colorBlindMode } = useMorph();
  return (
    <div>
      <p>Theme: {theme}</p>
      <p>High contrast: {highContrast}</p>
      <p>Color-blind mode: {colorBlindMode ?? "none"}</p>
    </div>
  );
}

The dark palette comes from Claude (with brand-color awareness + WCAG contrast validation) on first dark-mode entry, then is cached server-side and locally every subsequent user of your app gets the cached result, zero Claude calls. Falls back to a deterministic HSL flip when the backend is offline.

Just works

PreNav skeletons#

The user clicks a link. White flash for 600ms while the route resolves. They click again, get confused, hit Back. Lost.

PreNavSkeleton intercepts the click and paints the structural layout of the destination page in ~80ms using the snapshot of that route from the previous visit, or a heuristic shape if they've never been there. The skeleton fades out the moment real content lands.

tsx
// Auto-on with <MorphProvider>. Per-link opt-out:
<a href="/somewhere" data-morph-no-prenav>
  Quick link, no skeleton
</a>

// Or globally:
<MorphProvider preNav={false}>
  {children}
</MorphProvider>

Next.js loading.tsx detected

When Morph spots Next.js App Router with a loading.tsx boundary, it skips its own skeleton. Next already handles the transition. No double overlay.

Just works

White-island fixer#

Your app is in dark mode. A Stripe iframe stays white. A logo PNG stays white. A late-injected chat widget stays white. The user sees dark everywhere except those bright rectangles stabbing their eyes.

WhiteIslandFixer activates only in dark mode and patches the three things backgroundFixer can't reach: cross-origin iframes (translucent overlay with pointer-events:none so clicks pass through), PNG/SVG images (mix-blend-mode:multiply white pixels blend into the page bg), and 3rd-party DOM injected late (MutationObserver patches their bg to a mid-tone surface).

tsx
// Auto-on. Per-element opt-outs:
<iframe src="https:<<CMLN:1:CMLN>>
<img src="hero.png" alt="brand" data-morph-no-dim />

// Or globally:
<MorphProvider whiteIslandFix={false}>
  {children}
</MorphProvider>

Known-dark embeds (YouTube, Spotify, SoundCloud) are skipped automatically overlaying them would over-darken. JPEG photos are skipped too (multiply would tint the whole image).

Just works

Scroll restoration#

The user scrolls a long article, taps a link, hits Back, lands at the top. They have to find their place again. Most people don't.

Morph captures scroll position + the last interactive element on every navigation. On Back/Forward, it polls document.body.scrollHeight until the new content has stabilized, restores the exact pixel position, and plays a 600ms highlight pulse on the last-clicked element so the user feels "I'm back where I was".

tsx
// Auto-on. Owns history.scrollRestoration='manual' so the
// browser doesn't fight Morph. Opt-out when your router is
// already doing this correctly:
<MorphProvider scrollRestore={false}>
  {children}
</MorphProvider>

Just works

Mobile near-miss recovery#

The user means to tap Confirm. Their thumb catches Cancel right next to it. They've cancelled their order. On desktop, hover prevents this. On mobile, no hover, no recovery.

ImproperTapDetector watches every tap on touch devices. When the tap point falls in the outer 25% of a button AND a critical neighbor (Cancel / Delete / Decline) sits within 24px on the side the thumb was sliding toward, an Undo banner slides in from the bottom for 4 seconds long enough to recover, short enough not to clutter if they meant it.

tsx
// Two ways to mark a button "critical":

// 1. Explicit attribute
<button data-morph-critical onClick={cancelOrder}>
  Cancel order
</button>

// 2. Keyword auto-detection in textContent / className /
//    aria-label: cancel, delete, decline, refuse, destroy,
//    remove (+ FR equivalents: annuler, supprimer, refuser)
<button onClick={deleteAccount}>Delete account</button>

// Wire the Undo handler via a window-global function name:
<button
  data-morph-critical
  data-morph-undo="restoreCart"
  onClick={cancelOrder}
>
  Cancel
</button>

Just works

Lost-tap detection#

The user taps a button. The :active state triggers. They see the visual feedback. The click event never fires. They re-tap, or assume it worked, or leave. You never see it. your analytics don't flag "tap that did nothing".

LostTapDetector waits 300ms after every tappablepointerdown for a click event. If none lands, it diagnoses what likely ate the click (overlay covering the target, pointer-events:none ancestor, element moved during tap) and surfaces the signal.

tsx
// In dev mode, surfaces console.warn with the diagnostic.
// In production, pass onLostTap to forward to Sentry / Datadog:

import { initLostTap } from "@morphuiapp/morphui/internal";
// (or just disable and re-init manually with a callback)

<MorphProvider
  lostTap={false}
>
  {/* Then mount your own detector with reporting */}
</MorphProvider>
Most devs leave this on in dev only by production, the dev mode warnings have surfaced the real bugs. Pass lostTap={false} to disable in prod if you don't want the runtime overhead.

Drop-in components

<MorphForm>#

The user fills 8 fields. Submits. Server returns a validation error. React reloads. Form is empty. They start over. They abandon at the third try.

MorphForm is a drop-in <form> replacement that auto-saves every input on blur (passwords / file inputs excluded for security) and restores them on the next mount. The user can refresh, crash, navigate away, hit Back the form is still there.

tsx
import { MorphForm } from "@morphuiapp/morphui";

function CheckoutForm() {
  return (
    <MorphForm id="checkout" onSubmit={handleSubmit}>
      <input name="email" type="email" />
      <input name="phone" type="tel" />
      <textarea name="notes" />
      <button type="submit">Pay</button>
    </MorphForm>
  );
}

For async submits where the snapshot should only clear on real success:

tsx
const ref = useRef<MorphFormHandle>(null);

async function pay(e) {
  e.preventDefault();
  const ok = await api.checkout(formData);
  if (ok) ref.current?.clear(); // wipe snapshot on success only
}

<MorphForm
  ref={ref}
  id="checkout"
  clearOnSubmit={false}
  onSubmit={pay}
>
  โ€ฆ
</MorphForm>

Privacy by default

type="password", file, hidden, plus any field with autocomplete="off" or data-morph-no-save are NEVER persisted. Snapshots auto-expire after 7 days.

Drop-in components

<MorphLoader>#

A spinner shown for 200ms feels like 600ms the human eye notices the flash itself as a delay. Showing one at 0ms ADDS perceived latency to fast requests.

MorphLoader stays invisible under 200ms, shows a minimal shimmer between 200ms and 1s, adds an estimated progress bar between 1s and 3s, and surfaces a "This is taking longer than usual" message with an optional Cancel button past 3s.

tsx
import { MorphLoader } from "@morphuiapp/morphui";

function ProductList() {
  const { data, isLoading } = useSWR("/api/products");
  return (
    <MorphLoader loading={isLoading} onCancel={() => abort()}>
      <Grid items={data} />
    </MorphLoader>
  );
}

Or as a Suspense passthrough leave loading undefined and Morph stays transparent, letting the inner Suspense own the boundary:

tsx
<MorphLoader>
  <Suspense fallback={null}>
    <SlowComponent />
  </Suspense>
</MorphLoader>

Drop-in components

<MorphDialog>#

Modals nobody implements correctly: focus escapes to the page behind, Cmd+F searches the wrong thing, Escape closes more than the top-most one, focus is lost forever when the dialog dismisses.

MorphDialog gets six things right by default:

  • Focus trap : Tab cycles inside, never escapes.
  • Focus restoration : restores to the element focused before opening, even across stacked dialogs.
  • Escape stack : module-level LIFO, only top-most closes on Escape.
  • Background isolation : siblings get the inert attribute, so Cmd+F / screen readers / Tab respect modality.
  • Backdrop click : closes only the top-most dialog.
  • Body scroll lock : first-in / last-out, scroll position restored on the last close (no page jump).
tsx
import { MorphDialog } from "@morphuiapp/morphui";

function App() {
  const [open, setOpen] = useState(false);
  return (
    <>
      <button onClick={() => setOpen(true)}>Open</button>
      <MorphDialog open={open} onClose={() => setOpen(false)}>
        <h2>Confirm action</h2>
        <p>Are you sure?</p>
        <button onClick={() => setOpen(false)}>Cancel</button>
        <button onClick={confirm}>Confirm</button>
      </MorphDialog>
    </>
  );
}
Pass closeOnBackdrop={false} for must-decide dialogs (unsaved-changes prompt, etc.). Pass initialFocusRef to focus a specific element on open.

Drop-in components

<MorphPhoneInput>#

The user copies +33 6 12 34 56 78 from an SMS, pastes it into your "phone" field, your regex rejects "format invalide". They retype, mistype, fail KYC, abandon.

MorphPhoneInput accepts whatever they paste. Strips spaces, dashes, parens. Detects the country prefix (or falls back to defaultCountry). Reformats for display. Outputs a clean E.164 string via onChange.

tsx
import { MorphPhoneInput } from "@morphuiapp/morphui";

function SignupForm() {
  const [phone, setPhone] = useState({ e164: "", valid: false });
  return (
    <MorphPhoneInput
      defaultCountry="FR"
      value={phone.e164}
      onChange={(e164, valid) => setPhone({ e164, valid })}
      placeholder="06 12 34 56 78"
    />
  );
}

Built-in dial-code map covers ~200 countries. Display modes:

  • national (default) : 06 12 34 56 78 for FR, (555) 123-4567 for US, 07700 900 123 for GB.
  • international : +33 6 12 34 56 78.
Zero external dependency no libphonenumber bloat (which weighs ~150 KB). The smart-paste UX is what devs actually need; full E.164 conformance is server-side.

Drop-in components

<MorphZone>#

Wrap a content section to give it a stable identity. The behavioral engine tracks clicks, time spent, scroll depth per zone all stored locally in IndexedDB. Required if you want the suggestion engine and zone reordering to learn anything about your layout.

tsx
import { MorphZone } from "@morphuiapp/morphui";

export function HomePage() {
  return (
    <main className="flex flex-col gap-8">
      <MorphZone id="hero" priority={0}>
        <HeroSection />
      </MorphZone>

      <MorphZone id="features" priority={1}>
        <FeaturesGrid />
      </MorphZone>

      <MorphZone id="testimonials" priority={2}>
        <Testimonials />
      </MorphZone>
    </main>
  );
}

When the scorer collects enough signal AND the user accepts a suggestion card, Morph reorders zones via the CSS order property no DOM remount, no scroll jump. The container must use flex or grid for CSS order to apply.

Professional plan

Behavioral tracking, suggestion cards, and zone reorder are gated to Professional and above. โ†’ plans

Hooks

useMorph#

Read everything Morph knows about the current user's context. Re-renders when any of these change.

tsx
import { useMorph } from "@morphuiapp/morphui";

function Diagnostics() {
  const {
    theme,                  // 'light' | 'dark'
    systemPreference,       // 'dark' | 'light' | 'no-preference'
    timeOfDay,              // 'day' | 'night'
    appBrightness,          // your base theme โ€” 'light' | 'dark' | null
    adaptation,             // 'none' | 'darken' | 'lighten'
    isAdapted,              // true when Morph applied a flip
    wcag,                   // { passing, violations, level }
    highContrast,           // 'normal' | 'high'
    forcedColors,           // boolean
    colorBlindMode,         // null | 'deuteranopia' | 'protanopia' | 'tritanopia'
    prefersReducedMotion,   // boolean
    language,               // 'en' | 'fr' | โ€ฆ
    framework,              // detected: 'tailwind' | 'mui' | 'antd' | โ€ฆ
    safeMode,               // detect-only mode
  } = useMorph();
  // โ€ฆ
}

Hooks

useTheme / useAccessibility#

Smaller hooks for when you only want one slice. Cheaper than useMorph they only trigger re-renders for their slice.

tsx
import { useTheme, useAccessibility } from "@morphuiapp/morphui";

function ThemeDot() {
  const { theme, prefersReducedMotion } = useTheme();
  return <span data-theme={theme}>{theme}</span>;
}

function A11yLabel() {
  const { highContrast, forcedColors, colorBlindMode } = useAccessibility();
  return (
    <p>
      Contrast: {highContrast}
      {colorBlindMode && ` (${colorBlindMode})`}
    </p>
  );
}

Hooks

useStorage#

Same API as useState, but persisted across reloads via a fallback chain that NEVER throws: localStorage โ†’ sessionStorage โ†’ in-memory. Survives Safari iOS Private mode, quota errors, and disabled storage.

tsx
import { useStorage } from "@morphuiapp/morphui";

function Preferences() {
  const [prefs, setPrefs] = useStorage("user-prefs", {
    theme: "auto",
    density: "comfortable",
  });
  return (
    <button
      onClick={() =>
        setPrefs((p) => ({ ...p, theme: p.theme === "auto" ? "dark" : "auto" }))
      }
    >
      Theme: {prefs.theme}
    </button>
  );
}

On QuotaExceededError, Morph runs a one-shot cleanup of morph: namespaced keys older than 30 days, then retries before falling through to the next tier.

Hooks

useTabSync#

The user has your app open in 3 tabs. They update a value in tab 1. Tabs 2 and 3 see it instantly. Slack and Gmail do this; your app probably doesn't.

tsx
import { useTabSync } from "@morphuiapp/morphui";

function NotificationsCount() {
  const [count, setCount] = useTabSync("notifs-count", 0);
  // markAsRead in tab 1 โ†’ tabs 2 + 3 update in ~5ms
  return <span>{count}</span>;
}

Transport: BroadcastChannel when available (~5ms latency, works in private mode), storage event fallback for older browsers (~50ms). Each instance has a UUID for echo prevention. Persists to localStorage so a tab opened LATER picks up the latest value at mount.

Hooks

useAutoFillGuard#

The user changed their password 3 months ago. Their password manager still has the old one. They open the login page โ†’ fields auto-fill โ†’ tap Submit โ†’ 401. They blame your app.

useAutoFillGuard surfaces a flag the moment a browser password manager fills an input WITHOUT the user typing. Show a discreet "verify your password is up to date" before they submit.

tsx
import { useAutoFillGuard } from "@morphuiapp/morphui";

function LoginForm() {
  const { ref, isAutofilled, dismiss } = useAutoFillGuard();
  return (
    <>
      <input ref={ref} type="password" name="password" />
      {isAutofilled && (
        <p className="text-amber-500">
          โš  Auto-fill detected verify your password is up to date.
          <button onClick={dismiss}>OK</button>
        </p>
      )}
    </>
  );
}
Three combined detection signals: CSS animationstart on :-webkit-autofill / :autofill, keystroke counter, input event without prior keystroke. Auto-clears the warning the moment the user types one character.

Theming

CSS variables#

Every adapted color is published as a CSS custom property on :root. They update live when the system theme flips no JS round-trip required to re-style.

globals.csstsx
/* Use them anywhere */
.card {
  background: var(--cml-surface);
  color: var(--cml-text);
  border: 1px solid var(--cml-border);
}

button.primary {
  background: var(--cml-primary);
  color: var(--cml-primary-text);
}

Available variables:

  • --cml-background, --cml-surface, --cml-card-bg
  • --cml-text-primary, --cml-text-secondary
  • --cml-primary, --cml-primary-text
  • --cml-border
  • --cml-error, --cml-success, --cml-warning
  • --cml-font-scale boost in high-contrast mode (0px default, 2px HC)
  • --cml-motion multiplier (1 default, 0 reduced motion)

Theming

Tailwind + shadcn#

Map Morph's variables to Tailwind's color tokens. Your existing shadcn / Tailwind UI code keeps working only now it adapts.

tailwind.config.tstsx
import type { Config } from "tailwindcss";

export default {
  content: ["./src<<CMLN:0:CMLN>>*.{js,ts,jsx,tsx,mdx}"],
  darkMode: "class", // required Morph toggles .dark on <html>
  theme: {
    extend: {
      colors: {
        background: "var(--cml-background)",
        foreground: "var(--cml-text-primary)",
        primary: { DEFAULT: "var(--cml-primary)" },
        muted: "var(--cml-text-secondary)",
        border: "var(--cml-border)",
      },
    },
  },
} satisfies Config;
shadcn components built around bg-background text-foreground automatically adopt the adapted palette no per-component change required.

Configuration

Opt-out per engine#

All six auto-engines are on by default. Each has a single prop to disable. Pass safeMode to disable everything in one go (detect-only no DOM mutation, useful for tests and gradual rollouts).

tsx
<MorphProvider
  // Engine flags all default to true
  preNav={false}          // skip white-flash skeleton
  whiteIslandFix={false}  // skip dark-mode iframe/img/widget patching
  scrollRestore={false}   // skip Back/Forward scroll restoration
  improperTap={false}     // skip mobile near-miss Undo banner
  lostTap={false}         // skip console.warn on swallowed clicks

  // Or kill everything in one go (detect-only)
  safeMode

  // Override the colorBlind palette (otherwise auto-detected from system)
  colorBlindMode="deuteranopia"
>
  {children}
</MorphProvider>

Configuration

data-morph-* attributes#

Per-element opt-outs and hints. Sprinkle these on individual elements when the global behavior isn't what you want for a specific subtree.

  • data-morph-skip. Morph treats this subtree as inviolable. No theme override, no behavioral tracking, no engine touches anything inside. Use on brand logos, third-party embeds, signed-off design system regions.
  • data-morph-force re-enables Morph inside a skipped subtree. "Closest attribute wins" the first data-morph-skip or data-morph-force walking up from any element decides.
  • data-morph-no-prenav on <a> skip pre-nav skeleton for this link.
  • data-morph-no-shield on <iframe> skip dark-mode shield for this iframe.
  • data-morph-no-dim on <img> skip dark-mode multiply blend.
  • data-morph-critical on <button> mark as a destructive action for ImproperTapDetector (alternative to keyword auto-detect).
  • data-morph-undo="myFn" on a critical button name of a window-global function to call from the Undo banner.
  • data-morph-no-save on a form input exclude from <MorphForm> snapshots.
  • data-morph-pin on any element (or any ancestor up to <body>) tells every behavioral engine to leave the subtree alone โ€” no reorder, no collapse, no cold-zone fade, no font-scale. Use on hero sections, brand-critical regions, anything you want anchored in place.
  • data-morph-pin-off opt back IN to behavioral mutation when the auto-detect or an ancestor would otherwise pin the element. Useful when a <header>is auto-pinned by tag but you actually want it tracked.
  • data-morph-collapsable on a section (or ancestor) marks it as eligible for CollapseEngine. Without this attribute, no zone is ever collapsed โ€” the engine is opt-in to avoid surprise on content the dev didn't consent to hide.

Configuration

Excluding elements from behavioral mutations#

Three layered mechanisms to keep parts of your UI stable while V2's engines run elsewhere. The contract is the same across ScorerEngine (reorder), CollapseEngine, HeatmapAdapter (cold fade), AdaptationEngine (font-scale), and ContentAdaptEngine.

1. Auto-detected by tag / role / classname

With zero config Morph treats these as pinned:

  • Tags: <nav>, <header>, <footer>, <aside>
  • ARIA roles: banner, navigation, contentinfo, complementary
  • Classname or id containing the words hero, sticky, pinned (word boundary โ€” won't catch header-shadow-1 or other utility classes)

2. Explicit attribute (data-morph-pin)

tsx
<section data-morph-pin>
  <Hero />
</section>

<div data-morph-pin>
  {/* every descendant inherits the pin */}
  <CtaCard />
  <CtaCard />
</div>

Inherited up to 5 ancestor levels โ€” putting data-morph-pin on a wrapper protects every child without sprinkling it everywhere.

3. <MorphZone fixed> prop

tsx
<MorphZone id="hero" priority={0} fixed>
  <Hero />
</MorphZone>

Override: data-morph-pin-off

When auto-detect pinned an element you actually want tracked:

tsx
<header data-morph-pin-off>
  {/* auto-pin by <header> tag is overridden โ€” tracked normally */}
</header>

Opt-IN: data-morph-collapsable

Collapse is the inverse contract โ€” opt-in only, since hiding content the dev didn't mark is invasive. Add the attribute on any section the engine is allowed to fold up after enough low-engagement signals:

tsx
<section data-morph-collapsable>
  <RelatedArticles />
</section>

Forms, inputs, and contenteditable regions are never collapsed regardless of this attribute โ€” the engine refuses to hide anything the user might still need to type into.

Configuration

safeMode#

Pass safeMode on the provider and Morph becomes detect-only. Theme, contrast, language, reduced-motion, system preference all still available via useMorph. But no CSS override, no DOM mutation, no engine activates. Perfect for testing, debugging, or gradually rolling out adaptation in production.

tsx
<MorphProvider safeMode>
  <App />
</MorphProvider>

// Inside, everything is still detected:
const { theme, language, safeMode } = useMorph();
// theme is 'dark' or 'light' apply it yourself, or not.

License

Plans & gating#

Four tiers: Free, Professional ($29/mo), Business ($99/mo), and Enterprise (custom). The license key resolves to a plan at boot. Most features on this page work on Free; Professional unlocks behavioral tracking + suggestions; Business unlocks the analytics dashboard and AI insights; Enterprise adds SSO, SLA, and dedicated infrastructure.

tsx
import { useMorph } from "@morphuiapp/morphui";

function PlanBadge() {
  const { plan } = useMorph();
  // plan: 'free' | 'professional' | 'business' | 'enterprise'
  return <Badge>{plan.toUpperCase()}</Badge>;
}

// Gate a feature with a plain conditional every premium hook
// (useBehavior, MorphZone, โ€ฆ) silently no-ops on a plan that
// can't satisfy it, so a guard like this is purely cosmetic.
function AnalyticsLink() {
  const { plan } = useMorph();
  if (plan !== 'business' && plan !== 'enterprise') return null;
  return <a href="/admin/analytics">Open analytics</a>;
}

No code change to upgrade

The license key is the only thing that changes when a customer upgrades. Engines that were dark before light up automatically. No re-deploy, no code change.

License

Analytics dashboard#

When the dev opts into analytics AND the user gives consent, anonymized aggregates ship to /api/behavior/report on the configured interval. The dashboard at app.morphui.app renders the data - Business plan and above.

tsx
<MorphProvider
  licenseKey="morph-business-xxx"
  analytics={{
    enabled: true,
    userConsent: hasConsent,            // wire to your consent banner
    uploadInterval: 24 * 60 * 60_000,   // 24 hours in ms
    minInteractions: 20,                // skip noisy uploads
  }}
>
  <App />
</MorphProvider>

Privacy contract - what's sent vs never sent

Sent: per-zone scores, confirmed navigation sequences, scroll summary aggregates, opaque app hash (sha256), month-only timestamp.

Never sent: individual clicks, exact timestamps, user identity, device fingerprint, page content, geolocation, form values.

Reference

TypeScript reference#

The SDK ships JSX with bundled .d.ts stubs. Below is the canonical shape of what MorphProvider accepts and what useMorph() returns pasted into your own code they typecheck cleanly without importing extra types.

Provider props

typescript
interface MorphProviderProps {
  children: React.ReactNode;

  // License โ€” optional. Without it, the provider runs in Free with the
  // demo limits.
  licenseKey?: string;

  // Detect-only mode. Every signal is read but nothing applies โ€” see
  // the safeMode section above.
  safeMode?: boolean;

  // Color-blindness adaptation. 'auto' uses the OS preference if exposed.
  colorBlindMode?:
    | 'auto'
    | 'none'
    | 'deuteranopia'
    | 'protanopia'
    | 'tritanopia';

  // Auto-engine kill switches โ€” all default to true.
  preNav?: boolean;          // PreNavSkeleton on internal links
  whiteIslandFix?: boolean;  // dim bright iframes / PNGs in dark mode
  scrollRestore?: boolean;   // owns history.scrollRestoration
  improperTap?: boolean;     // confirm near-miss taps on critical buttons
  lostTap?: boolean;         // detect tap that didn't fire its handler
}

useMorph() return type

typescript
type Plan = 'free' | 'professional' | 'business' | 'enterprise';

interface MorphState {
  // Theme + appearance
  theme: 'light' | 'dark';
  systemPreference: 'light' | 'dark' | 'no-preference';
  timeOfDay: 'day' | 'night';
  appBrightness: 'light' | 'dark' | null;
  appAnalysis: unknown;            // internal palette analysis
  adaptation: 'darken' | 'lighten' | 'none';
  isAdapted: boolean;
  wcag: { passing: boolean; violations: unknown[]; level: 'AA' | 'AAA' };

  // Accessibility signals
  highContrast: boolean;
  forcedColors: boolean;
  colorBlindMode:
    | null
    | 'deuteranopia'
    | 'protanopia'
    | 'tritanopia';
  prefersReducedMotion: boolean;

  // Locale + framework
  language: string;
  framework: 'tailwind' | 'mui' | 'antd' | 'chakra' | 'css' | string;

  // Mode
  safeMode: boolean;

  // V2 โ€” Behavioral Intelligence (Pro+)
  v2Enabled: boolean;
  storageType: 'idb' | 'local' | 'session' | 'memory';
  zoneOrder: Record<string, number>;
  suggestions: unknown[];
  fontScaleApplied: boolean;
  patterns: unknown[];

  // Plan + gating
  plan: Plan;
  features: Record<string, boolean>;
  limits: { dailyApiCalls: number; maxLicenses: number };
  featuresReady: boolean; // false until /api/license/validate resolves
}

Hook variants

useTheme() returns just { theme, prefersReducedMotion }; useAccessibility() returns { highContrast, forcedColors, colorBlindMode }; useBehavior() returns the V2 surface (and a frozen no-op object on Free so you can call its methods unconditionally).

Reference

Performance impact#

Numbers from internal benchmarks against a typical Next.js App Router project on a mid-range Android. Treat as ballpark, not a guarantee. Your bundle and route-tree dominate.

MetricImpact
Initial JS bundle+12โ€“15 KB gzipped (provider + theme)
First Contentful Paint+0 ms (provider mounts post-FCP)
Largest Contentful Paint+0 ms (auto-engines lazy-loaded)
Time to Interactive+0โ€“50 ms (engine wiring)
First license validation+200โ€“400 ms one-shot, then cached 24h
MorphZone (V2 reorder)+25 KB only when imported
Auto TL;DR / TOC engines+18 KB only when used

Tree-shakable by default

The Drop-in components and V2 hooks all live behind named exports. If you only import MorphProvider and useMorph, you ship the V1 baseline and nothing else.

Reference

Examples#

Looking for production-ready code? The official examples are full apps you can clone, fork, and ship โ€” Morph integrated end-to-end.

Smart Blog โ€” Next.js 16

Full Next.js 16 + Tailwind v4 blog demonstrating:

  • Auto dark mode (Claude-generated, WCAG AA)
  • AI TL;DR card on every article
  • PreNav skeletons + scroll restoration on route changes
  • MorphZone behavioral tracking on long-form content
  • data-morph-pin / data-morph-collapsable contracts on the layout

View source ยท Live demo ยท Browse all examples

More examples coming

SaaS dashboard with V2 behavioral, e-commerce template, and a Flutter example are in progress. Watch the GitHub org for updates.

Migration

Upgrading to 0.4.0#

0.4.0 ships theme-generation stability fixes that change server-side behaviour. The public SDK API is unchanged โ€” same provider props, same hooks, same components โ€” but the cached themes from 0.3.x must be dropped because they were generated with non-deterministic AI calls.

What changed under the hood

  • Anthropic temperature: 0 on both the analyze and the generate calls. Same DOM signature in, same palette out โ€” refresh in dark mode no longer rolls a different theme each time.
  • Strict prompt rules โ€” hue preservation (ยฑ15ยฐ), WCAG AA contrast minimums (text/bg โ‰ฅ 4.5, primary โ‰ฅ 3.0), background luminance range constraints, and a no-pure-black/white guard.
  • Validation + retry + HSL fallback. Each Claude response goes through 8 WCAG checks. Clean themes ship as-is. Themes failing < 30% of checks are auto-patched. โ‰ฅ 30% triggers a retry (up to 3 attempts). All-failed โ†’ deterministic HSL flip โ€” always lisible, never a network call.
  • SAFE_DARK_VARS / SAFE_LIGHT_VARS โ€” vetted high-contrast palettes ship in the SDK and apply before React's first paint when there's no AI cache. No more brand-light โ†’ AI-dark strobe on a cold-cache refresh.
  • Atomic apply. Theme swaps now go throughThemeApplier.apply, which lets CSS transitions fade between palettes naturally.
  • Behavioral mutations are once-at-mount. Reorder / font-scale no longer fire mid-session โ€” they apply decisions persisted from the previous session at the next mount, never under the user's cursor. Collapse keeps a 30 s heartbeat (it only hides opt-in zones).

Migration steps

  1. Run the SQL migration on Supabase. The file is db/migrations/20260501_invalidate_theme_cache_v0_4_0.sql โ€” it backs up the existing chameleon_themes table to chameleon_themes_backup_v0_3_2 and drops the v0.3.x rows. Add a sdk_version column for future migrations.
  2. Deploy the backend (the chameleon-backend theme service has the new pipeline).
  3. Bump the SDK in your app:
    bash
    npm install @morphuiapp/morphui@^0.4.0
  4. Hard-refresh user browsers (โŒ˜โ‡งR / Ctrl+Shift+R) so the SDK pulls fresh themes generated by the new pipeline.

What you might also want to add

0.4.0 also surfaces the data-morph-pin / data-morph-collapsable contracts. They were implicit before, explicit now. Audit your hero, footer, and form sections โ€” most need data-morph-pin for stable layout, and any region you want collapse-eligible needs data-morph-collapsable. See the Behavioral exclusions section.

Migration

Migration from next-themes#

Morph and next-themes can coexist or you can fully migrate. The coexistence path is the safest if you already ship a manual dark/light toggle keep next-themes for the toggle UI and let Morph generate the actual colors.

Coexist (recommended for gradual adoption)

tsx
import { ThemeProvider } from 'next-themes';
import { MorphProvider } from '@morphuiapp/morphui';

export default function RootLayout({ children }) {
  return (
    <ThemeProvider attribute="class">
      <MorphProvider licenseKey={process.env.NEXT_PUBLIC_MORPH_KEY}>
        {children}
      </MorphProvider>
    </ThemeProvider>
  );
}

next-themes keeps owning the .dark class. Morph reads the resolved mode and generates the actual color values via Claude your --cml-* CSS variables update accordingly.

Full migration

bash
npm uninstall next-themes
tsx
// Before
<ThemeProvider attribute="class">
  <App />
</ThemeProvider>

// After
<MorphProvider licenseKey="morph-free-demo">
  <App />
</MorphProvider>

For toggle UI, read the resolved theme via useMorph() instead of useTheme():

tsx
// Before (next-themes)
const { theme, setTheme } = useTheme();

// After (Morph)  the theme follows system preference automatically.
// To force a manual override, toggle the .dark class yourself:
const { theme } = useMorph();
const toggle = () =>
  document.documentElement.classList.toggle('dark');

Don't fight system preference

Morph's default is respect the OS. The override pattern above is for apps that absolutely need a manual switch but most apps see better engagement when they just follow the user's system choice.

Support

Troubleshooting#

Theme isn't adapting at all

Make sure MorphProvider wraps your entire app at the root if it's nested too deep, components above it never receive the theme. In Next.js App Router, the right place is the root app/layout.tsx.

Dark-mode flash on first paint (Next.js)

Add a tiny script in <head> before React hydrates so the OS preference is applied before the first paint. Morph picks it up from there.
html
<script dangerouslySetInnerHTML={{ __html: `
  if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
    document.documentElement.classList.add('dark')
  }
` }} />

<MorphZone> isn't reordering

The container must use flex or grid for CSS order to apply. A plain block parent will ignore the reorder. Also confirm the plan is Professional or above useBehavior() silently no-ops on Free.

Colors stuck because of !important

Morph can't override !important rules. Replace hardcoded colors with the generated CSS variables instead:
css
/* Won't adapt */
.card { color: #000 !important; }

/* Adapts */
.card { color: var(--cml-text); }

Anthropic API unreachable

Morph has a deterministic HSL-flip fallback the dark theme is generated locally from your light palette without calling Claude. Themes are also cached per palette signature in localStorage (24h TTL). Your app never breaks on a network failure; it just gets a slightly less polished first-time-ever palette.

useMorph() throws 'must be used within MorphProvider'

You're calling the hook in a component that isn't a descendant of MorphProvider. Most often this is an error.tsx / not-found page in App Router that renders outside the layout. Wrap your error UI in a small provider scope, or check typeof window === 'undefined' before reading state on those routes.

License resolves to FREE despite a paid key

(1) Check the network tab POST /api/license/validate should return plan: 'professional' (or above). (2) Confirm your origin is in the license's allowed-origins list on the dashboard. (3) The 24h local cache is the most common culprit on dev call localStorage.removeItem('morph:license') and reload to force a fresh validate.

Still stuck?

Open an issue at github.com/polarismorph-code/morph-react/issues with your MorphProvider config, the failing page's code, and the relevant browser-console output. Professional, Business, and Enterprise subscribers get priority via support@morphui.dev.