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.
@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.
npm install @morphuiapp/morphuipnpm add @morphuiapp/morphui
yarn add @morphuiapp/morphuiGetting 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.
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.
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)
Freeprefers-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. โ detailsNo more white flash on link clicks
No more bright iframes / images in dark mode
Back returns to where you actually were
Mobile near-miss on Cancel/Delete recovers
Silent click failures get logged
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.
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
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).
// 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".
// 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.
// 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.
// 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>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.
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:
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.
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:
<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
inertattribute, 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).
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>
</>
);
}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.
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 78for FR,(555) 123-4567for US,07700 900 123for GB.international:+33 6 12 34 56 78.
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.
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
Hooks
useMorph#
Read everything Morph knows about the current user's context. Re-renders when any of these change.
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.
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.
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.
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.
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>
)}
</>
);
}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.
/* 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-scaleboost in high-contrast mode (0px default, 2px HC)--cml-motionmultiplier (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.
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;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).
<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-forcere-enables Morph inside a skipped subtree. "Closest attribute wins" the firstdata-morph-skipordata-morph-forcewalking up from any element decides.data-morph-no-prenavon<a>skip pre-nav skeleton for this link.data-morph-no-shieldon<iframe>skip dark-mode shield for this iframe.data-morph-no-dimon<img>skip dark-mode multiply blend.data-morph-criticalon<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-saveon a form input exclude from<MorphForm>snapshots.data-morph-pinon 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-offopt 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-collapsableon a section (or ancestor) marks it as eligible forCollapseEngine. 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 catchheader-shadow-1or other utility classes)
2. Explicit attribute (data-morph-pin)
<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
<MorphZone id="hero" priority={0} fixed>
<Hero />
</MorphZone>Override: data-morph-pin-off
When auto-detect pinned an element you actually want tracked:
<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:
<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.
<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.
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
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.
<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
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
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.
| Metric | Impact |
|---|---|
| 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
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
MorphZonebehavioral tracking on long-form contentdata-morph-pin/data-morph-collapsablecontracts on the layout
View source ยท Live demo ยท Browse all examples
More examples coming
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 through
ThemeApplier.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
- Run the SQL migration on Supabase. The file is
db/migrations/20260501_invalidate_theme_cache_v0_4_0.sqlโ it backs up the existingchameleon_themestable tochameleon_themes_backup_v0_3_2and drops the v0.3.x rows. Add asdk_versioncolumn for future migrations. - Deploy the backend (the chameleon-backend theme service has the new pipeline).
- Bump the SDK in your app:bash
npm install @morphuiapp/morphui@^0.4.0 - 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)
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
npm uninstall next-themes// 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():
// 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
Support
Troubleshooting#
Theme isn't adapting at all
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)
<head> before React hydrates so the OS preference is applied before the first paint. Morph picks it up from there.<script dangerouslySetInnerHTML={{ __html: `
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
document.documentElement.classList.add('dark')
}
` }} /><MorphZone> isn't reordering
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
!important rules. Replace hardcoded colors with the generated CSS variables instead:/* Won't adapt */
.card { color: #000 !important; }
/* Adapts */
.card { color: var(--cml-text); }Anthropic API unreachable
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'
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
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?
MorphProvider config, the failing page's code, and the relevant browser-console output. Professional, Business, and Enterprise subscribers get priority via support@morphui.dev.