) => {
+ const newValue = e.target.value;
+ setLocalValue(newValue);
+
+ // Validate if pattern provided
+ if (validationRegexp) {
+ setIsValid(new RegExp(validationRegexp).test(newValue));
+ }
+
+ // Two-way binding: update data model
+ if (textPath) {
+ setValue(textPath, newValue);
+ }
+ },
+ [validationRegexp, textPath, setValue]
+ );
+
+ const inputType =
+ fieldType === 'number'
+ ? 'number'
+ : fieldType === 'date'
+ ? 'date'
+ : 'text';
+ const isTextArea = fieldType === 'longText';
+
+ // Structure mirrors Lit's TextField component:
+ // ← :host equivalent
+ //
+ //
+
+ // Apply --weight CSS variable on root div (:host equivalent) for flex layouts
+ const hostStyle: React.CSSProperties = node.weight !== undefined
+ ? { '--weight': node.weight } as React.CSSProperties
+ : {};
+
+ return (
+
+
+
+ );
+});
+
+export default TextField;
diff --git a/renderers/react/src/components/interactive/index.ts b/renderers/react/src/components/interactive/index.ts
new file mode 100644
index 000000000..9c5c00f6b
--- /dev/null
+++ b/renderers/react/src/components/interactive/index.ts
@@ -0,0 +1,6 @@
+export { Button } from './Button';
+export { TextField } from './TextField';
+export { CheckBox } from './CheckBox';
+export { Slider } from './Slider';
+export { DateTimeInput } from './DateTimeInput';
+export { MultipleChoice } from './MultipleChoice';
diff --git a/renderers/react/src/components/layout/Card.tsx b/renderers/react/src/components/layout/Card.tsx
new file mode 100644
index 000000000..aa114f9a9
--- /dev/null
+++ b/renderers/react/src/components/layout/Card.tsx
@@ -0,0 +1,53 @@
+import { memo } from 'react';
+import type { Types } from '@a2ui/lit/0.8';
+import type { A2UIComponentProps } from '../../types';
+import { useA2UIComponent } from '../../hooks/useA2UIComponent';
+import { classMapToString, stylesToObject } from '../../lib/utils';
+import { ComponentNode } from '../../core/ComponentNode';
+
+/**
+ * Card component - a container that visually groups content.
+ *
+ * Structure mirrors Lit's Card component:
+ * ← :host equivalent
+ * ← theme classes (border, padding, background)
+ * {children} ← ::slotted(*) equivalent
+ *
+ *
+ *
+ * All styles come from componentSpecificStyles CSS, no inline styles needed.
+ */
+export const Card = memo(function Card({ node, surfaceId }: A2UIComponentProps) {
+ const { theme } = useA2UIComponent(node, surfaceId);
+ const props = node.properties;
+
+ // Card can have either a single child or multiple children
+ const rawChildren = props.children ?? (props.child ? [props.child] : []);
+ const children = Array.isArray(rawChildren) ? rawChildren : [];
+
+ // Apply --weight CSS variable on root div (:host equivalent) for flex layouts
+ const hostStyle: React.CSSProperties = node.weight !== undefined
+ ? { '--weight': node.weight } as React.CSSProperties
+ : {};
+
+ return (
+
+
+ {children.map((child, index) => {
+ const childId = typeof child === 'object' && child !== null && 'id' in child
+ ? (child as Types.AnyComponentNode).id
+ : `child-${index}`;
+ const childNode = typeof child === 'object' && child !== null && 'type' in child
+ ? (child as Types.AnyComponentNode)
+ : null;
+ return ;
+ })}
+
+
+ );
+});
+
+export default Card;
diff --git a/renderers/react/src/components/layout/Column.tsx b/renderers/react/src/components/layout/Column.tsx
new file mode 100644
index 000000000..7a6f69883
--- /dev/null
+++ b/renderers/react/src/components/layout/Column.tsx
@@ -0,0 +1,48 @@
+import { memo } from 'react';
+import type { Types } from '@a2ui/lit/0.8';
+import type { A2UIComponentProps } from '../../types';
+import { useA2UIComponent } from '../../hooks/useA2UIComponent';
+import { classMapToString, stylesToObject } from '../../lib/utils';
+import { ComponentNode } from '../../core/ComponentNode';
+
+/**
+ * Column component - arranges children vertically using flexbox.
+ *
+ * Supports distribution (justify-content) and alignment (align-items) properties.
+ */
+export const Column = memo(function Column({ node, surfaceId }: A2UIComponentProps) {
+ const { theme } = useA2UIComponent(node, surfaceId);
+ const props = node.properties;
+
+ // Match Lit's default values
+ const alignment = props.alignment ?? 'stretch';
+ const distribution = props.distribution ?? 'start';
+
+ const children = Array.isArray(props.children) ? props.children : [];
+
+ // Apply --weight CSS variable on root div (:host equivalent) for flex layouts
+ const hostStyle: React.CSSProperties = node.weight !== undefined
+ ? { '--weight': node.weight } as React.CSSProperties
+ : {};
+
+ return (
+
+
+ {children.map((child, index) => {
+ const childId = typeof child === 'object' && child !== null && 'id' in child
+ ? (child as Types.AnyComponentNode).id
+ : `child-${index}`;
+ const childNode = typeof child === 'object' && child !== null && 'type' in child
+ ? (child as Types.AnyComponentNode)
+ : null;
+ return ;
+ })}
+
+
+ );
+});
+
+export default Column;
diff --git a/renderers/react/src/components/layout/List.tsx b/renderers/react/src/components/layout/List.tsx
new file mode 100644
index 000000000..375f14477
--- /dev/null
+++ b/renderers/react/src/components/layout/List.tsx
@@ -0,0 +1,47 @@
+import { memo } from 'react';
+import type { Types } from '@a2ui/lit/0.8';
+import type { A2UIComponentProps } from '../../types';
+import { useA2UIComponent } from '../../hooks/useA2UIComponent';
+import { classMapToString, stylesToObject } from '../../lib/utils';
+import { ComponentNode } from '../../core/ComponentNode';
+
+/**
+ * List component - renders a scrollable list of items.
+ *
+ * Supports direction (vertical/horizontal) properties.
+ */
+export const List = memo(function List({ node, surfaceId }: A2UIComponentProps) {
+ const { theme } = useA2UIComponent(node, surfaceId);
+ const props = node.properties;
+
+ // Match Lit's default value
+ const direction = props.direction ?? 'vertical';
+
+ const children = Array.isArray(props.children) ? props.children : [];
+
+ // Apply --weight CSS variable on root div (:host equivalent) for flex layouts
+ const hostStyle: React.CSSProperties = node.weight !== undefined
+ ? { '--weight': node.weight } as React.CSSProperties
+ : {};
+
+ return (
+
+
+ {children.map((child, index) => {
+ const childId = typeof child === 'object' && child !== null && 'id' in child
+ ? (child as Types.AnyComponentNode).id
+ : `child-${index}`;
+ const childNode = typeof child === 'object' && child !== null && 'type' in child
+ ? (child as Types.AnyComponentNode)
+ : null;
+ return ;
+ })}
+
+
+ );
+});
+
+export default List;
diff --git a/renderers/react/src/components/layout/Modal.tsx b/renderers/react/src/components/layout/Modal.tsx
new file mode 100644
index 000000000..c2f42de18
--- /dev/null
+++ b/renderers/react/src/components/layout/Modal.tsx
@@ -0,0 +1,110 @@
+import { useState, useCallback, useRef, useEffect, memo } from 'react';
+import type { Types } from '@a2ui/lit/0.8';
+import type { A2UIComponentProps } from '../../types';
+import { useA2UIComponent } from '../../hooks/useA2UIComponent';
+import { classMapToString, stylesToObject } from '../../lib/utils';
+import { ComponentNode } from '../../core/ComponentNode';
+
+/**
+ * Modal component - displays content in a dialog overlay.
+ *
+ * Matches Lit's rendering approach:
+ * - When closed: renders section with entry point child
+ * - When open: renders dialog with content child (entry point is replaced)
+ *
+ * The dialog is rendered in place (no portal) so it stays inside .a2ui-surface
+ * and CSS selectors work correctly. showModal() handles the top-layer overlay.
+ */
+export const Modal = memo(function Modal({ node, surfaceId }: A2UIComponentProps) {
+ const { theme } = useA2UIComponent(node, surfaceId);
+ const props = node.properties;
+
+ const [isOpen, setIsOpen] = useState(false);
+ const dialogRef = useRef(null);
+
+ const openModal = useCallback(() => {
+ setIsOpen(true);
+ }, []);
+
+ const closeModal = useCallback(() => {
+ setIsOpen(false);
+ }, []);
+
+ // Show dialog when isOpen becomes true, and sync state when dialog closes (e.g., via Escape)
+ useEffect(() => {
+ const dialog = dialogRef.current;
+ if (!dialog) return;
+
+ if (isOpen && !dialog.open) {
+ dialog.showModal();
+ }
+
+ // Listen for native close event (triggered by Escape key)
+ const handleClose = () => {
+ setIsOpen(false);
+ };
+ dialog.addEventListener('close', handleClose);
+ return () => dialog.removeEventListener('close', handleClose);
+ }, [isOpen]);
+
+ // Handle backdrop clicks (only close if clicking directly on dialog, not its content)
+ const handleBackdropClick = useCallback(
+ (e: React.MouseEvent) => {
+ if (e.target === e.currentTarget) {
+ closeModal();
+ }
+ },
+ [closeModal]
+ );
+
+ // Handle Escape key (for jsdom test compatibility - real browsers use native dialog behavior)
+ const handleKeyDown = useCallback(
+ (e: React.KeyboardEvent) => {
+ if (e.key === 'Escape') {
+ closeModal();
+ }
+ },
+ [closeModal]
+ );
+
+ // Apply --weight CSS variable on root div (:host equivalent) for flex layouts
+ const hostStyle: React.CSSProperties = node.weight !== undefined
+ ? { '--weight': node.weight } as React.CSSProperties
+ : {};
+
+ // Match Lit's render approach: closed shows section with entry, open shows dialog
+ if (!isOpen) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+ );
+});
+
+export default Modal;
diff --git a/renderers/react/src/components/layout/Row.tsx b/renderers/react/src/components/layout/Row.tsx
new file mode 100644
index 000000000..72867c5e5
--- /dev/null
+++ b/renderers/react/src/components/layout/Row.tsx
@@ -0,0 +1,48 @@
+import { memo } from 'react';
+import type { Types } from '@a2ui/lit/0.8';
+import type { A2UIComponentProps } from '../../types';
+import { useA2UIComponent } from '../../hooks/useA2UIComponent';
+import { classMapToString, stylesToObject } from '../../lib/utils';
+import { ComponentNode } from '../../core/ComponentNode';
+
+/**
+ * Row component - arranges children horizontally using flexbox.
+ *
+ * Supports distribution (justify-content) and alignment (align-items) properties.
+ */
+export const Row = memo(function Row({ node, surfaceId }: A2UIComponentProps) {
+ const { theme } = useA2UIComponent(node, surfaceId);
+ const props = node.properties;
+
+ // Match Lit's default values
+ const alignment = props.alignment ?? 'stretch';
+ const distribution = props.distribution ?? 'start';
+
+ const children = Array.isArray(props.children) ? props.children : [];
+
+ // Apply --weight CSS variable on root div (:host equivalent) for flex layouts
+ const hostStyle: React.CSSProperties = node.weight !== undefined
+ ? { '--weight': node.weight } as React.CSSProperties
+ : {};
+
+ return (
+
+
+ {children.map((child, index) => {
+ const childId = typeof child === 'object' && child !== null && 'id' in child
+ ? (child as Types.AnyComponentNode).id
+ : `child-${index}`;
+ const childNode = typeof child === 'object' && child !== null && 'type' in child
+ ? (child as Types.AnyComponentNode)
+ : null;
+ return ;
+ })}
+
+
+ );
+});
+
+export default Row;
diff --git a/renderers/react/src/components/layout/Tabs.tsx b/renderers/react/src/components/layout/Tabs.tsx
new file mode 100644
index 000000000..d556c579b
--- /dev/null
+++ b/renderers/react/src/components/layout/Tabs.tsx
@@ -0,0 +1,72 @@
+import { useState, memo } from 'react';
+import type { Types } from '@a2ui/lit/0.8';
+import type { A2UIComponentProps } from '../../types';
+import { useA2UIComponent } from '../../hooks/useA2UIComponent';
+import { classMapToString, stylesToObject, mergeClassMaps } from '../../lib/utils';
+import { ComponentNode } from '../../core/ComponentNode';
+
+/**
+ * Tabs component - displays content in switchable tabs.
+ */
+export const Tabs = memo(function Tabs({ node, surfaceId }: A2UIComponentProps) {
+ const { theme, resolveString } = useA2UIComponent(node, surfaceId);
+ const props = node.properties;
+
+ const [selectedIndex, setSelectedIndex] = useState(0);
+
+ const tabItems = props.tabItems ?? [];
+
+ // Apply --weight CSS variable on root div (:host equivalent) for flex layouts
+ const hostStyle: React.CSSProperties = node.weight !== undefined
+ ? { '--weight': node.weight } as React.CSSProperties
+ : {};
+
+ return (
+
+
+ {/* Tab buttons - uses Tabs.element for the container */}
+
+ {tabItems.map((tab, index) => {
+ const title = resolveString(tab.title);
+ const isSelected = index === selectedIndex;
+
+ // Lit merges all + selected classes when selected
+ const classes = isSelected
+ ? mergeClassMaps(
+ theme.components.Tabs.controls.all,
+ theme.components.Tabs.controls.selected
+ )
+ : theme.components.Tabs.controls.all;
+
+ return (
+
+ );
+ })}
+
+
+ {/* Tab content */}
+ {tabItems[selectedIndex] && (
+
+ )}
+
+
+ );
+});
+
+export default Tabs;
diff --git a/renderers/react/src/components/layout/index.ts b/renderers/react/src/components/layout/index.ts
new file mode 100644
index 000000000..28023efd6
--- /dev/null
+++ b/renderers/react/src/components/layout/index.ts
@@ -0,0 +1,6 @@
+export { Row } from './Row';
+export { Column } from './Column';
+export { List } from './List';
+export { Card } from './Card';
+export { Tabs } from './Tabs';
+export { Modal } from './Modal';
diff --git a/renderers/react/src/core/A2UIProvider.tsx b/renderers/react/src/core/A2UIProvider.tsx
new file mode 100644
index 000000000..6d5812f53
--- /dev/null
+++ b/renderers/react/src/core/A2UIProvider.tsx
@@ -0,0 +1,210 @@
+import {
+ createContext,
+ useContext,
+ useRef,
+ useState,
+ useMemo,
+ type ReactNode,
+} from 'react';
+import { Data, type Types } from '@a2ui/lit/0.8';
+import type { A2UIContextValue, A2UIActions } from './store';
+import { ThemeProvider } from '../theme/ThemeContext';
+import type { OnActionCallback } from '../types';
+
+/**
+ * Context for stable actions (never changes reference, prevents re-renders).
+ * Components that only need to dispatch actions or read data won't re-render.
+ */
+const A2UIActionsContext = createContext(null);
+
+/**
+ * Context for reactive state (changes trigger re-renders).
+ * Only components that need to react to state changes subscribe to this.
+ */
+const A2UIStateContext = createContext<{ version: number } | null>(null);
+
+/**
+ * Props for the A2UIProvider component.
+ */
+export interface A2UIProviderProps {
+ /** Callback invoked when a user action is dispatched (button click, etc.) */
+ onAction?: OnActionCallback;
+ /** Theme configuration. Falls back to default theme if not provided. */
+ theme?: Types.Theme;
+ /** Child components */
+ children: ReactNode;
+}
+
+/**
+ * Provider component that sets up the A2UI context for descendant components.
+ *
+ * This provider uses a two-context architecture for performance:
+ * - A2UIActionsContext: Stable actions that never change (no re-renders)
+ * - A2UIStateContext: Reactive state that triggers re-renders when needed
+ *
+ * @example
+ * ```tsx
+ * function App() {
+ * const handleAction = async (message) => {
+ * const response = await fetch('/api/a2ui', {
+ * method: 'POST',
+ * body: JSON.stringify(message)
+ * });
+ * const newMessages = await response.json();
+ * };
+ *
+ * return (
+ *
+ *
+ *
+ * );
+ * }
+ * ```
+ */
+export function A2UIProvider({ onAction, theme, children }: A2UIProviderProps) {
+ // Create message processor only once using ref
+ const processorRef = useRef(null);
+ if (!processorRef.current) {
+ processorRef.current = Data.createSignalA2uiMessageProcessor();
+ }
+ const processor = processorRef.current;
+
+ // Version counter for triggering re-renders
+ const [version, setVersion] = useState(0);
+
+ // Store onAction in a ref so callbacks always have the latest value
+ const onActionRef = useRef(onAction ?? null);
+ onActionRef.current = onAction ?? null;
+
+ // Create stable actions object once - stored in ref, never changes
+ const actionsRef = useRef(null);
+ if (!actionsRef.current) {
+ actionsRef.current = {
+ processMessages: (messages: Types.ServerToClientMessage[]) => {
+ processor.processMessages(messages);
+ setVersion((v) => v + 1);
+ },
+
+ setData: (
+ node: Types.AnyComponentNode | null,
+ path: string,
+ value: Types.DataValue,
+ surfaceId: string
+ ) => {
+ processor.setData(node, path, value, surfaceId);
+ setVersion((v) => v + 1);
+ },
+
+ dispatch: (message: Types.A2UIClientEventMessage) => {
+ if (onActionRef.current) {
+ onActionRef.current(message);
+ }
+ },
+
+ clearSurfaces: () => {
+ processor.clearSurfaces();
+ setVersion((v) => v + 1);
+ },
+
+ getSurface: (surfaceId: string) => {
+ return processor.getSurfaces().get(surfaceId);
+ },
+
+ getSurfaces: () => {
+ return processor.getSurfaces();
+ },
+
+ getData: (node: Types.AnyComponentNode, path: string, surfaceId: string) => {
+ return processor.getData(node, path, surfaceId);
+ },
+
+ resolvePath: (path: string, dataContextPath?: string) => {
+ return processor.resolvePath(path, dataContextPath);
+ },
+ };
+ }
+ const actions = actionsRef.current;
+
+ // State context value - only changes when version changes
+ const stateValue = useMemo(() => ({ version }), [version]);
+
+ return (
+
+
+ {children}
+
+
+ );
+}
+
+/**
+ * Hook to access stable A2UI actions (won't cause re-renders).
+ * Use this when you only need to dispatch actions or read data.
+ *
+ * @returns Stable actions object
+ * @throws If used outside of an A2UIProvider
+ */
+export function useA2UIActions(): A2UIActions {
+ const actions = useContext(A2UIActionsContext);
+ if (!actions) {
+ throw new Error('useA2UIActions must be used within an A2UIProvider');
+ }
+ return actions;
+}
+
+/**
+ * Hook to subscribe to A2UI state changes.
+ * Components using this will re-render when state changes.
+ *
+ * @returns Current version number
+ * @throws If used outside of an A2UIProvider
+ */
+export function useA2UIState(): { version: number } {
+ const state = useContext(A2UIStateContext);
+ if (!state) {
+ throw new Error('useA2UIState must be used within an A2UIProvider');
+ }
+ return state;
+}
+
+/**
+ * Hook to access the full A2UI context (actions + state).
+ * Components using this will re-render when state changes.
+ *
+ * @returns The A2UI context value
+ * @throws If used outside of an A2UIProvider
+ */
+export function useA2UIContext(): A2UIContextValue {
+ const actions = useA2UIActions();
+ const state = useA2UIState();
+
+ // Memoize combined value - only changes when state changes
+ // Actions are stable, so this only re-creates when version changes
+ return useMemo(
+ () => ({
+ ...actions,
+ processor: null as unknown as Types.MessageProcessor, // Not exposed directly
+ version: state.version,
+ onAction: null, // Use dispatch instead
+ }),
+ [actions, state.version]
+ );
+}
+
+/**
+ * @deprecated Use useA2UIContext instead. This alias exists for backward compatibility only.
+ */
+export const useA2UIStore = useA2UIContext;
+
+/**
+ * @deprecated This selector pattern does not provide performance benefits with React Context.
+ * Components will re-render on any context change regardless of what you select.
+ * Use useA2UIContext() or useA2UI() directly instead.
+ *
+ * @param selector - Function to select a slice of state
+ * @returns The selected state
+ */
+export function useA2UIStoreSelector(selector: (state: A2UIContextValue) => T): T {
+ const context = useA2UIContext();
+ return selector(context);
+}
diff --git a/renderers/react/src/core/A2UIRenderer.tsx b/renderers/react/src/core/A2UIRenderer.tsx
new file mode 100644
index 000000000..2c44a6b9b
--- /dev/null
+++ b/renderers/react/src/core/A2UIRenderer.tsx
@@ -0,0 +1,130 @@
+import { Suspense, useMemo, memo, type ReactNode } from 'react';
+import { useA2UI } from '../hooks/useA2UI';
+import { ComponentNode } from './ComponentNode';
+import { type ComponentRegistry } from '../registry/ComponentRegistry';
+import { cn } from '../lib/utils';
+
+/** Default loading fallback - memoized to prevent recreation */
+const DefaultLoadingFallback = memo(function DefaultLoadingFallback() {
+ return (
+
+ Loading...
+
+ );
+});
+
+export interface A2UIRendererProps {
+ /** The surface ID to render */
+ surfaceId: string;
+ /** Additional CSS classes for the surface container */
+ className?: string;
+ /** Fallback content when surface is not yet available */
+ fallback?: ReactNode;
+ /** Loading fallback for lazy-loaded components */
+ loadingFallback?: ReactNode;
+ /** Optional custom component registry */
+ registry?: ComponentRegistry;
+}
+
+/**
+ * A2UIRenderer - renders an A2UI surface.
+ *
+ * This is the main entry point for rendering A2UI content in your React app.
+ * It reads the surface state from the A2UI store and renders the component tree.
+ *
+ * Memoized to prevent unnecessary re-renders when props haven't changed.
+ *
+ * @example
+ * ```tsx
+ * function App() {
+ * return (
+ *
+ *
+ *
+ * );
+ * }
+ * ```
+ */
+export const A2UIRenderer = memo(function A2UIRenderer({
+ surfaceId,
+ className,
+ fallback = null,
+ loadingFallback,
+ registry,
+}: A2UIRendererProps) {
+ const { getSurface, version } = useA2UI();
+
+ // Get surface - this will re-render when version changes
+ const surface = getSurface(surfaceId);
+
+ // Memoize surface styles to prevent object recreation
+ // Matches Lit renderer's transformation logic in surface.ts
+ const surfaceStyles = useMemo(() => {
+ if (!surface?.styles) return {};
+
+ const styles: React.CSSProperties & Record = {};
+
+ for (const [key, value] of Object.entries(surface.styles)) {
+ switch (key) {
+ // Generate a color palette from the primary color.
+ // Values range from 0-100 where 0=black, 100=white, 50=primary color.
+ // Uses color-mix to create intermediate values.
+ case 'primaryColor': {
+ styles['--p-100'] = '#ffffff';
+ styles['--p-99'] = `color-mix(in srgb, ${value} 2%, white 98%)`;
+ styles['--p-98'] = `color-mix(in srgb, ${value} 4%, white 96%)`;
+ styles['--p-95'] = `color-mix(in srgb, ${value} 10%, white 90%)`;
+ styles['--p-90'] = `color-mix(in srgb, ${value} 20%, white 80%)`;
+ styles['--p-80'] = `color-mix(in srgb, ${value} 40%, white 60%)`;
+ styles['--p-70'] = `color-mix(in srgb, ${value} 60%, white 40%)`;
+ styles['--p-60'] = `color-mix(in srgb, ${value} 80%, white 20%)`;
+ styles['--p-50'] = String(value);
+ styles['--p-40'] = `color-mix(in srgb, ${value} 80%, black 20%)`;
+ styles['--p-35'] = `color-mix(in srgb, ${value} 70%, black 30%)`;
+ styles['--p-30'] = `color-mix(in srgb, ${value} 60%, black 40%)`;
+ styles['--p-25'] = `color-mix(in srgb, ${value} 50%, black 50%)`;
+ styles['--p-20'] = `color-mix(in srgb, ${value} 40%, black 60%)`;
+ styles['--p-15'] = `color-mix(in srgb, ${value} 30%, black 70%)`;
+ styles['--p-10'] = `color-mix(in srgb, ${value} 20%, black 80%)`;
+ styles['--p-5'] = `color-mix(in srgb, ${value} 10%, black 90%)`;
+ styles['--p-0'] = '#000000';
+ break;
+ }
+
+ case 'font': {
+ styles['--font-family'] = String(value);
+ styles['--font-family-flex'] = String(value);
+ break;
+ }
+ }
+ }
+ return styles;
+ }, [surface?.styles]);
+
+ // No surface yet
+ if (!surface || !surface.componentTree) {
+ return <>{fallback}>;
+ }
+
+ // Use provided fallback or default memoized component
+ const actualLoadingFallback = loadingFallback ?? ;
+
+ return (
+
+
+
+
+
+ );
+});
+
+export default A2UIRenderer;
diff --git a/renderers/react/src/core/A2UIViewer.tsx b/renderers/react/src/core/A2UIViewer.tsx
new file mode 100644
index 000000000..34a624a27
--- /dev/null
+++ b/renderers/react/src/core/A2UIViewer.tsx
@@ -0,0 +1,217 @@
+'use client';
+
+import React, { useId, useMemo, useEffect, useRef } from 'react';
+import type { Types } from '@a2ui/lit/0.8';
+import { A2UIProvider, useA2UIActions } from './A2UIProvider';
+import { A2UIRenderer } from './A2UIRenderer';
+import { initializeDefaultCatalog } from '../registry/defaultCatalog';
+import { litTheme } from '../theme/litTheme';
+import { injectStyles } from '../styles';
+import type { OnActionCallback } from '../types';
+
+/**
+ * Component instance format for static A2UI definitions.
+ */
+export interface ComponentInstance {
+ id: string;
+ component: Record;
+}
+
+/**
+ * Action event dispatched when a user interacts with a component.
+ */
+export interface A2UIActionEvent {
+ actionName: string;
+ sourceComponentId: string;
+ timestamp: string;
+ context: Record;
+}
+
+export interface A2UIViewerProps {
+ /** The root component ID */
+ root: string;
+ /** Array of component definitions */
+ components: ComponentInstance[];
+ /** Data model for the surface */
+ data?: Record;
+ /** Callback when an action is triggered */
+ onAction?: (action: A2UIActionEvent) => void;
+ /** Custom theme (defaults to litTheme) */
+ theme?: Types.Theme;
+ /** Additional CSS class */
+ className?: string;
+}
+
+// Initialize the component catalog and styles once
+let initialized = false;
+function ensureInitialized() {
+ if (!initialized) {
+ initializeDefaultCatalog();
+ injectStyles(); // Inject structural CSS for litTheme utility classes
+ initialized = true;
+ }
+}
+
+/**
+ * A2UIViewer renders an A2UI component tree from static JSON definitions.
+ *
+ * Use this when you have component definitions and data as props rather than
+ * streaming messages from a server. For streaming use cases, use A2UIProvider
+ * with A2UIRenderer and useA2UI instead.
+ *
+ * @example
+ * ```tsx
+ * const components = [
+ * { id: 'root', component: { Card: { child: 'text' } } },
+ * { id: 'text', component: { Text: { text: { path: '/message' } } } },
+ * ];
+ *
+ * console.log('Action:', action)}
+ * />
+ * ```
+ */
+export function A2UIViewer({
+ root,
+ components,
+ data = {},
+ onAction,
+ theme = litTheme,
+ className,
+}: A2UIViewerProps) {
+ ensureInitialized();
+
+ // Generate a stable surface ID based on the definition
+ const baseId = useId();
+ const surfaceId = useMemo(() => {
+ const definitionKey = `${root}-${JSON.stringify(components)}`;
+ let hash = 0;
+ for (let i = 0; i < definitionKey.length; i++) {
+ const char = definitionKey.charCodeAt(i);
+ hash = (hash << 5) - hash + char;
+ hash = hash & hash;
+ }
+ return `surface${baseId.replace(/:/g, '-')}${hash}`;
+ }, [baseId, root, components]);
+
+ // Convert onAction callback to internal format
+ const handleAction: OnActionCallback | undefined = useMemo(() => {
+ if (!onAction) return undefined;
+
+ return (message: Types.A2UIClientEventMessage) => {
+ const userAction = message.userAction;
+ if (userAction) {
+ onAction({
+ actionName: userAction.name,
+ sourceComponentId: userAction.sourceComponentId,
+ timestamp: userAction.timestamp,
+ context: userAction.context ?? {},
+ });
+ }
+ };
+ }, [onAction]);
+
+ return (
+
+
+
+ );
+}
+
+/**
+ * Inner component that processes messages within the provider context.
+ */
+function A2UIViewerInner({
+ surfaceId,
+ root,
+ components,
+ data,
+ className,
+}: {
+ surfaceId: string;
+ root: string;
+ components: ComponentInstance[];
+ data: Record;
+ className?: string;
+}) {
+ const { processMessages } = useA2UIActions();
+ const lastProcessedRef = useRef('');
+
+ // Process messages when props change
+ useEffect(() => {
+ const key = `${surfaceId}-${JSON.stringify(components)}-${JSON.stringify(data)}`;
+ if (key === lastProcessedRef.current) return;
+ lastProcessedRef.current = key;
+
+ const messages: Types.ServerToClientMessage[] = [
+ { beginRendering: { surfaceId, root, styles: {} } },
+ { surfaceUpdate: { surfaceId, components } },
+ ];
+
+ // Add data model updates
+ if (data && Object.keys(data).length > 0) {
+ const contents = objectToValueMaps(data);
+ if (contents.length > 0) {
+ messages.push({
+ dataModelUpdate: { surfaceId, path: '/', contents },
+ });
+ }
+ }
+
+ processMessages(messages);
+ }, [processMessages, surfaceId, root, components, data]);
+
+ return (
+
+ );
+}
+
+/**
+ * Converts a nested JavaScript object to the ValueMap[] format
+ * expected by A2UI's dataModelUpdate message.
+ */
+function objectToValueMaps(obj: Record): Types.ValueMap[] {
+ return Object.entries(obj).map(([key, value]) => valueToValueMap(key, value));
+}
+
+/**
+ * Converts a single key-value pair to a ValueMap.
+ */
+function valueToValueMap(key: string, value: unknown): Types.ValueMap {
+ if (typeof value === 'string') {
+ return { key, valueString: value };
+ }
+ if (typeof value === 'number') {
+ return { key, valueNumber: value };
+ }
+ if (typeof value === 'boolean') {
+ return { key, valueBoolean: value };
+ }
+ if (value === null || value === undefined) {
+ return { key };
+ }
+ if (Array.isArray(value)) {
+ const valueMap = value.map((item, index) =>
+ valueToValueMap(String(index), item)
+ );
+ return { key, valueMap };
+ }
+ if (typeof value === 'object') {
+ const valueMap = objectToValueMaps(value as Record);
+ return { key, valueMap };
+ }
+ return { key };
+}
+
+export default A2UIViewer;
diff --git a/renderers/react/src/core/ComponentNode.tsx b/renderers/react/src/core/ComponentNode.tsx
new file mode 100644
index 000000000..f36eb0bf7
--- /dev/null
+++ b/renderers/react/src/core/ComponentNode.tsx
@@ -0,0 +1,74 @@
+import { Suspense, useMemo, memo } from 'react';
+import type { Types } from '@a2ui/lit/0.8';
+import { ComponentRegistry } from '../registry/ComponentRegistry';
+
+/** Memoized loading fallback to avoid recreating on each render */
+const LoadingFallback = memo(function LoadingFallback() {
+ return (
+
+ Loading...
+
+ );
+});
+
+interface ComponentNodeProps {
+ /** The component node to render (can be null/undefined for safety) */
+ node: Types.AnyComponentNode | null | undefined;
+ /** The surface ID this component belongs to */
+ surfaceId: string;
+ /** Optional custom registry. Falls back to singleton. */
+ registry?: ComponentRegistry;
+}
+
+/**
+ * ComponentNode - dynamically renders an A2UI component based on its type.
+ *
+ * Looks up the component in the registry and renders it with the appropriate props.
+ * Supports lazy-loaded components via React.Suspense.
+ *
+ * No wrapper div is rendered - the component's root div (e.g., .a2ui-image) is the
+ * direct flex child, exactly matching Lit's structure where the :host element IS
+ * the flex item. Each component handles --weight CSS variable on its root div.
+ *
+ * Memoized to prevent unnecessary re-renders when parent updates but node hasn't changed.
+ */
+export const ComponentNode = memo(function ComponentNode({
+ node,
+ surfaceId,
+ registry,
+}: ComponentNodeProps) {
+ const actualRegistry = registry ?? ComponentRegistry.getInstance();
+
+ // useMemo must be called unconditionally (Rules of Hooks)
+ // We handle invalid nodes by returning null component type
+ const nodeType = node && typeof node === 'object' && 'type' in node ? node.type : null;
+
+ const Component = useMemo(
+ () => (nodeType ? actualRegistry.get(nodeType) : null),
+ [actualRegistry, nodeType]
+ );
+
+ // Handle null/undefined/invalid nodes gracefully
+ if (!nodeType) {
+ if (node) {
+ console.warn('[A2UI] Invalid component node (not resolved?):', node);
+ }
+ return null;
+ }
+
+ if (!Component) {
+ console.warn(`[A2UI] Unknown component type: ${nodeType}`);
+ return null;
+ }
+
+ // No wrapper div - component's root div is the :host equivalent
+ // Suspense doesn't add DOM elements, preserving the correct hierarchy
+ // Type assertion is safe: we've already validated node is valid (nodeType check above)
+ return (
+ }>
+
+
+ );
+});
+
+export default ComponentNode;
diff --git a/renderers/react/src/core/store.ts b/renderers/react/src/core/store.ts
new file mode 100644
index 000000000..30c206ab5
--- /dev/null
+++ b/renderers/react/src/core/store.ts
@@ -0,0 +1,56 @@
+import type { Types } from '@a2ui/lit/0.8';
+import type { OnActionCallback } from '../types';
+
+/**
+ * Stable actions that never change (won't cause re-renders).
+ * These are stored in a ref and exposed via A2UIActionsContext.
+ */
+export interface A2UIActions {
+ /** Process incoming server messages */
+ processMessages: (messages: Types.ServerToClientMessage[]) => void;
+
+ /** Update data in the data model (for two-way binding) */
+ setData: (
+ node: Types.AnyComponentNode | null,
+ path: string,
+ value: Types.DataValue,
+ surfaceId: string
+ ) => void;
+
+ /** Dispatch a user action to the server */
+ dispatch: (message: Types.A2UIClientEventMessage) => void;
+
+ /** Clear all surfaces */
+ clearSurfaces: () => void;
+
+ /** Get a surface by ID */
+ getSurface: (surfaceId: string) => Types.Surface | undefined;
+
+ /** Get all surfaces */
+ getSurfaces: () => ReadonlyMap;
+
+ /** Get data from the data model */
+ getData: (
+ node: Types.AnyComponentNode,
+ path: string,
+ surfaceId: string
+ ) => Types.DataValue | null;
+
+ /** Resolve a relative path to an absolute path */
+ resolvePath: (path: string, dataContextPath?: string) => string;
+}
+
+/**
+ * The shape of the A2UI context value.
+ * Combines stable actions with reactive state.
+ */
+export interface A2UIContextValue extends A2UIActions {
+ /** The underlying message processor from @a2ui/lit */
+ processor: Types.MessageProcessor;
+
+ /** Version counter for triggering React re-renders */
+ version: number;
+
+ /** Callback for dispatching actions to the server */
+ onAction: OnActionCallback | null;
+}
diff --git a/renderers/react/src/hooks/useA2UI.ts b/renderers/react/src/hooks/useA2UI.ts
new file mode 100644
index 000000000..675c5848e
--- /dev/null
+++ b/renderers/react/src/hooks/useA2UI.ts
@@ -0,0 +1,63 @@
+import type { Types } from '@a2ui/lit/0.8';
+import { useA2UIActions, useA2UIState } from '../core/A2UIProvider';
+
+/**
+ * Result returned by the useA2UI hook.
+ */
+export interface UseA2UIResult {
+ /** Process incoming server messages */
+ processMessages: (messages: Types.ServerToClientMessage[]) => void;
+
+ /** Get a surface by ID */
+ getSurface: (surfaceId: string) => Types.Surface | undefined;
+
+ /** Get all surfaces */
+ getSurfaces: () => ReadonlyMap;
+
+ /** Clear all surfaces */
+ clearSurfaces: () => void;
+
+ /** The current version number (increments on state changes) */
+ version: number;
+}
+
+/**
+ * Main API hook for A2UI. Provides methods to process messages
+ * and access surface state.
+ *
+ * Note: This hook subscribes to state changes. Components using this
+ * will re-render when the A2UI state changes. For action-only usage
+ * (no re-renders), use useA2UIActions() instead.
+ *
+ * @returns Object with message processing and surface access methods
+ *
+ * @example
+ * ```tsx
+ * function ChatApp() {
+ * const { processMessages, getSurface } = useA2UI();
+ *
+ * useEffect(() => {
+ * const ws = new WebSocket('wss://agent.example.com');
+ * ws.onmessage = (event) => {
+ * const messages = JSON.parse(event.data);
+ * processMessages(messages);
+ * };
+ * return () => ws.close();
+ * }, [processMessages]);
+ *
+ * return ;
+ * }
+ * ```
+ */
+export function useA2UI(): UseA2UIResult {
+ const actions = useA2UIActions();
+ const state = useA2UIState();
+
+ return {
+ processMessages: actions.processMessages,
+ getSurface: actions.getSurface,
+ getSurfaces: actions.getSurfaces,
+ clearSurfaces: actions.clearSurfaces,
+ version: state.version,
+ };
+}
diff --git a/renderers/react/src/hooks/useA2UIComponent.ts b/renderers/react/src/hooks/useA2UIComponent.ts
new file mode 100644
index 000000000..510877f0a
--- /dev/null
+++ b/renderers/react/src/hooks/useA2UIComponent.ts
@@ -0,0 +1,228 @@
+import { useCallback, useId, useMemo } from 'react';
+import type { Types, Primitives } from '@a2ui/lit/0.8';
+import { useA2UIActions, useA2UIState } from '../core/A2UIProvider';
+import { useTheme } from '../theme/ThemeContext';
+
+/**
+ * Result returned by the useA2UIComponent hook.
+ */
+export interface UseA2UIComponentResult {
+ /** The current theme */
+ theme: Types.Theme;
+
+ /** Resolve a StringValue to its actual string value */
+ resolveString: (value: Primitives.StringValue | null | undefined) => string | null;
+
+ /** Resolve a NumberValue to its actual number value */
+ resolveNumber: (value: Primitives.NumberValue | null | undefined) => number | null;
+
+ /** Resolve a BooleanValue to its actual boolean value */
+ resolveBoolean: (value: Primitives.BooleanValue | null | undefined) => boolean | null;
+
+ /** Set a value in the data model (for two-way binding) */
+ setValue: (path: string, value: Types.DataValue) => void;
+
+ /** Get a value from the data model */
+ getValue: (path: string) => Types.DataValue | null;
+
+ /** Dispatch a user action */
+ sendAction: (action: Types.Action) => void;
+
+ /** Generate a unique ID for accessibility */
+ getUniqueId: (prefix: string) => string;
+}
+
+/**
+ * Base hook for A2UI components. Provides data binding, theme access,
+ * and action dispatching.
+ *
+ * @param node - The component node from the A2UI message processor
+ * @param surfaceId - The surface ID this component belongs to
+ * @returns Object with theme, data binding helpers, and action dispatcher
+ *
+ * @example
+ * ```tsx
+ * function TextField({ node, surfaceId }: A2UIComponentProps) {
+ * const { theme, resolveString, setValue } = useA2UIComponent(node, surfaceId);
+ *
+ * const label = resolveString(node.properties.label);
+ * const value = resolveString(node.properties.text) ?? '';
+ *
+ * return (
+ *
+ *
+ * setValue(node.properties.text?.path!, e.target.value)}
+ * />
+ *
+ * );
+ * }
+ * ```
+ */
+export function useA2UIComponent(
+ node: T,
+ surfaceId: string
+): UseA2UIComponentResult {
+ // Use stable actions - won't cause re-renders when version changes
+ const actions = useA2UIActions();
+ const theme = useTheme();
+ const baseId = useId();
+
+ // Subscribe to data model version - triggers re-render when data changes via setData.
+ // This ensures components with path bindings see updated values.
+ // memo() doesn't block context-triggered re-renders.
+ useA2UIState();
+
+ /**
+ * Resolve a StringValue to its actual string value.
+ * Checks literalString, literal, then path in that order.
+ * Note: This reads from data model via stable actions reference.
+ */
+ const resolveString = useCallback(
+ (value: Primitives.StringValue | null | undefined): string | null => {
+ if (!value) return null;
+ if (typeof value !== 'object') return null;
+
+ if (value.literalString !== undefined) {
+ return value.literalString;
+ }
+ if (value.literal !== undefined) {
+ return String(value.literal);
+ }
+ if (value.path) {
+ const data = actions.getData(node, value.path, surfaceId);
+ return data !== null ? String(data) : null;
+ }
+ return null;
+ },
+ [actions, node, surfaceId]
+ );
+
+ /**
+ * Resolve a NumberValue to its actual number value.
+ */
+ const resolveNumber = useCallback(
+ (value: Primitives.NumberValue | null | undefined): number | null => {
+ if (!value) return null;
+ if (typeof value !== 'object') return null;
+
+ if (value.literalNumber !== undefined) {
+ return value.literalNumber;
+ }
+ if (value.literal !== undefined) {
+ return Number(value.literal);
+ }
+ if (value.path) {
+ const data = actions.getData(node, value.path, surfaceId);
+ return data !== null ? Number(data) : null;
+ }
+ return null;
+ },
+ [actions, node, surfaceId]
+ );
+
+ /**
+ * Resolve a BooleanValue to its actual boolean value.
+ */
+ const resolveBoolean = useCallback(
+ (value: Primitives.BooleanValue | null | undefined): boolean | null => {
+ if (!value) return null;
+ if (typeof value !== 'object') return null;
+
+ if (value.literalBoolean !== undefined) {
+ return value.literalBoolean;
+ }
+ if (value.literal !== undefined) {
+ return Boolean(value.literal);
+ }
+ if (value.path) {
+ const data = actions.getData(node, value.path, surfaceId);
+ return data !== null ? Boolean(data) : null;
+ }
+ return null;
+ },
+ [actions, node, surfaceId]
+ );
+
+ /**
+ * Set a value in the data model for two-way binding.
+ */
+ const setValue = useCallback(
+ (path: string, value: Types.DataValue) => {
+ actions.setData(node, path, value, surfaceId);
+ },
+ [actions, node, surfaceId]
+ );
+
+ /**
+ * Get a value from the data model.
+ */
+ const getValue = useCallback(
+ (path: string): Types.DataValue | null => {
+ return actions.getData(node, path, surfaceId);
+ },
+ [actions, node, surfaceId]
+ );
+
+ /**
+ * Dispatch a user action to the server.
+ * Resolves all context bindings before dispatching.
+ */
+ const sendAction = useCallback(
+ (action: Types.Action) => {
+ const actionContext: Record = {};
+
+ if (action.context) {
+ for (const item of action.context) {
+ if (item.value.literalString !== undefined) {
+ actionContext[item.key] = item.value.literalString;
+ } else if (item.value.literalNumber !== undefined) {
+ actionContext[item.key] = item.value.literalNumber;
+ } else if (item.value.literalBoolean !== undefined) {
+ actionContext[item.key] = item.value.literalBoolean;
+ } else if (item.value.path) {
+ const resolvedPath = actions.resolvePath(item.value.path, node.dataContextPath);
+ actionContext[item.key] = actions.getData(node, resolvedPath, surfaceId);
+ }
+ }
+ }
+
+ actions.dispatch({
+ userAction: {
+ name: action.name,
+ sourceComponentId: node.id,
+ surfaceId,
+ timestamp: new Date().toISOString(),
+ context: actionContext,
+ },
+ });
+ },
+ [actions, node, surfaceId]
+ );
+
+ /**
+ * Generate a unique ID for accessibility purposes.
+ * Uses React's useId() for SSR and Concurrent Mode compatibility.
+ */
+ const getUniqueId = useCallback(
+ (prefix: string) => {
+ return `${prefix}${baseId}`;
+ },
+ [baseId]
+ );
+
+ return useMemo(
+ () => ({
+ theme,
+ resolveString,
+ resolveNumber,
+ resolveBoolean,
+ setValue,
+ getValue,
+ sendAction,
+ getUniqueId,
+ }),
+ [theme, resolveString, resolveNumber, resolveBoolean, setValue, getValue, sendAction, getUniqueId]
+ );
+}
diff --git a/renderers/react/src/index.ts b/renderers/react/src/index.ts
new file mode 100644
index 000000000..ce9a8be8e
--- /dev/null
+++ b/renderers/react/src/index.ts
@@ -0,0 +1,79 @@
+// Core components and provider
+export {
+ A2UIProvider,
+ useA2UIActions,
+ useA2UIState,
+ useA2UIContext,
+ useA2UIStore, // @deprecated - use useA2UIContext
+ useA2UIStoreSelector, // @deprecated - use useA2UIContext or useA2UI
+} from './core/A2UIProvider';
+export type { A2UIProviderProps } from './core/A2UIProvider';
+export { A2UIRenderer } from './core/A2UIRenderer';
+export type { A2UIRendererProps } from './core/A2UIRenderer';
+export { A2UIViewer } from './core/A2UIViewer';
+export type { A2UIViewerProps, ComponentInstance, A2UIActionEvent } from './core/A2UIViewer';
+export { ComponentNode } from './core/ComponentNode';
+
+// Hooks
+export { useA2UI } from './hooks/useA2UI';
+export type { UseA2UIResult } from './hooks/useA2UI';
+export { useA2UIComponent } from './hooks/useA2UIComponent';
+export type { UseA2UIComponentResult } from './hooks/useA2UIComponent';
+
+// Registry
+export { ComponentRegistry } from './registry/ComponentRegistry';
+export { registerDefaultCatalog, initializeDefaultCatalog } from './registry/defaultCatalog';
+
+// Theme
+export { ThemeProvider, useTheme, useThemeOptional } from './theme/ThemeContext';
+export { litTheme, defaultTheme } from './theme/litTheme';
+
+// Utilities
+export { cn, classMapToString, stylesToObject } from './lib/utils';
+
+// Types - re-export from types
+export type {
+ Types,
+ Primitives,
+ AnyComponentNode,
+ Surface,
+ SurfaceID,
+ Theme,
+ ServerToClientMessage,
+ A2UIClientEventMessage,
+ Action,
+ DataValue,
+ MessageProcessor,
+ StringValue,
+ NumberValue,
+ BooleanValue,
+ A2UIComponentProps,
+ ComponentRegistration,
+ ComponentLoader,
+ OnActionCallback,
+ A2UIProviderConfig,
+} from './types';
+
+// Content components
+export { Text } from './components/content/Text';
+export { Image } from './components/content/Image';
+export { Icon } from './components/content/Icon';
+export { Divider } from './components/content/Divider';
+export { Video } from './components/content/Video';
+export { AudioPlayer } from './components/content/AudioPlayer';
+
+// Layout components
+export { Row } from './components/layout/Row';
+export { Column } from './components/layout/Column';
+export { List } from './components/layout/List';
+export { Card } from './components/layout/Card';
+export { Tabs } from './components/layout/Tabs';
+export { Modal } from './components/layout/Modal';
+
+// Interactive components
+export { Button } from './components/interactive/Button';
+export { TextField } from './components/interactive/TextField';
+export { CheckBox } from './components/interactive/CheckBox';
+export { Slider } from './components/interactive/Slider';
+export { DateTimeInput } from './components/interactive/DateTimeInput';
+export { MultipleChoice } from './components/interactive/MultipleChoice';
diff --git a/renderers/react/src/lib/utils.ts b/renderers/react/src/lib/utils.ts
new file mode 100644
index 000000000..b6826bed1
--- /dev/null
+++ b/renderers/react/src/lib/utils.ts
@@ -0,0 +1,44 @@
+import { clsx, type ClassValue } from 'clsx';
+import { Styles } from '@a2ui/lit/0.8';
+
+/**
+ * Utility function to merge class names.
+ * Combines clsx for conditional classes.
+ *
+ * @param inputs - Class values to merge
+ * @returns Merged class name string
+ *
+ * @example
+ * cn('base-class', condition && 'conditional-class', { 'object-class': true })
+ */
+export function cn(...inputs: ClassValue[]): string {
+ return clsx(inputs);
+}
+
+/**
+ * Converts a theme class map (Record) to a className string.
+ * Re-exported from theme/utils for convenience.
+ *
+ * @param classMap - An object where keys are class names and values are booleans
+ * @returns A space-separated string of class names where the value is true
+ */
+export { classMapToString, stylesToObject } from '../theme/utils';
+
+/**
+ * Merges multiple class maps into a single class map.
+ * Uses Lit's Styles.merge() function directly for consistency.
+ *
+ * Lit's merge handles prefix conflicts: if you have 'layout-p-2' and 'layout-p-4',
+ * only the latter is kept (same prefix 'layout-p-' means they conflict).
+ *
+ * @param maps - Class maps to merge
+ * @returns A merged class map
+ */
+export function mergeClassMaps(
+ ...maps: (Record | undefined)[]
+): Record {
+ // Filter out undefined maps and use Lit's merge function
+ const validMaps = maps.filter((m): m is Record => m !== undefined);
+ if (validMaps.length === 0) return {};
+ return Styles.merge(...validMaps);
+}
diff --git a/renderers/react/src/registry/ComponentRegistry.ts b/renderers/react/src/registry/ComponentRegistry.ts
new file mode 100644
index 000000000..10a1c103f
--- /dev/null
+++ b/renderers/react/src/registry/ComponentRegistry.ts
@@ -0,0 +1,125 @@
+import { lazy, type ComponentType } from 'react';
+import type { Types } from '@a2ui/lit/0.8';
+import type { A2UIComponentProps, ComponentLoader, ComponentRegistration } from '../types';
+
+/**
+ * Registry for A2UI components. Allows registration of custom components
+ * and supports lazy loading for code splitting.
+ *
+ * @example
+ * ```tsx
+ * const registry = new ComponentRegistry();
+ *
+ * // Register a component directly
+ * registry.register('Text', { component: Text });
+ *
+ * // Register with lazy loading
+ * registry.register('Modal', {
+ * component: () => import('./components/Modal'),
+ * lazy: true
+ * });
+ *
+ * // Use with A2UIRenderer
+ *
+ * ```
+ */
+export class ComponentRegistry {
+ private static _instance: ComponentRegistry | null = null;
+ private registry = new Map();
+ private lazyCache = new Map>();
+
+ /**
+ * Get the singleton instance of the registry.
+ * Use this for the default global registry.
+ */
+ static getInstance(): ComponentRegistry {
+ if (!ComponentRegistry._instance) {
+ ComponentRegistry._instance = new ComponentRegistry();
+ }
+ return ComponentRegistry._instance;
+ }
+
+ /**
+ * Reset the singleton instance.
+ * Useful for testing.
+ */
+ static resetInstance(): void {
+ ComponentRegistry._instance = null;
+ }
+
+ /**
+ * Register a component type.
+ *
+ * @param type - The A2UI component type name (e.g., 'Text', 'Button')
+ * @param registration - The component registration
+ */
+ register(
+ type: string,
+ registration: ComponentRegistration
+ ): void {
+ this.registry.set(type, registration as unknown as ComponentRegistration);
+ }
+
+ /**
+ * Unregister a component type.
+ *
+ * @param type - The component type to unregister
+ */
+ unregister(type: string): void {
+ this.registry.delete(type);
+ this.lazyCache.delete(type);
+ }
+
+ /**
+ * Check if a component type is registered.
+ *
+ * @param type - The component type to check
+ * @returns True if the component is registered
+ */
+ has(type: string): boolean {
+ return this.registry.has(type);
+ }
+
+ /**
+ * Get a component by type. If the component is registered with lazy loading,
+ * returns a React.lazy wrapped component.
+ *
+ * @param type - The component type to get
+ * @returns The React component, or null if not found
+ */
+ get(type: string): ComponentType | null {
+ const registration = this.registry.get(type);
+ if (!registration) return null;
+
+ // If lazy loading is enabled and the component is a loader function
+ if (registration.lazy && typeof registration.component === 'function') {
+ // Check cache first
+ const cached = this.lazyCache.get(type);
+ if (cached) return cached;
+
+ // Create lazy component and cache it
+ const lazyComponent = lazy(registration.component as ComponentLoader);
+ this.lazyCache.set(type, lazyComponent);
+ return lazyComponent;
+ }
+
+ return registration.component as ComponentType;
+ }
+
+ /**
+ * Get all registered component types.
+ *
+ * @returns Array of registered type names
+ */
+ getRegisteredTypes(): string[] {
+ return Array.from(this.registry.keys());
+ }
+
+ /**
+ * Clear all registrations.
+ */
+ clear(): void {
+ this.registry.clear();
+ this.lazyCache.clear();
+ }
+}
diff --git a/renderers/react/src/registry/defaultCatalog.ts b/renderers/react/src/registry/defaultCatalog.ts
new file mode 100644
index 000000000..d36d0e5a1
--- /dev/null
+++ b/renderers/react/src/registry/defaultCatalog.ts
@@ -0,0 +1,66 @@
+import { ComponentRegistry } from './ComponentRegistry';
+
+// Content components
+import { Text } from '../components/content/Text';
+import { Image } from '../components/content/Image';
+import { Icon } from '../components/content/Icon';
+import { Divider } from '../components/content/Divider';
+import { Video } from '../components/content/Video';
+import { AudioPlayer } from '../components/content/AudioPlayer';
+
+// Layout components
+import { Row } from '../components/layout/Row';
+import { Column } from '../components/layout/Column';
+import { List } from '../components/layout/List';
+import { Card } from '../components/layout/Card';
+import { Tabs } from '../components/layout/Tabs';
+import { Modal } from '../components/layout/Modal';
+
+// Interactive components
+import { Button } from '../components/interactive/Button';
+import { TextField } from '../components/interactive/TextField';
+import { CheckBox } from '../components/interactive/CheckBox';
+import { Slider } from '../components/interactive/Slider';
+import { DateTimeInput } from '../components/interactive/DateTimeInput';
+import { MultipleChoice } from '../components/interactive/MultipleChoice';
+
+/**
+ * Registers all standard A2UI components in the registry.
+ *
+ * @param registry - The component registry to populate
+ */
+export function registerDefaultCatalog(registry: ComponentRegistry): void {
+ // Content components (small, load immediately)
+ registry.register('Text', { component: Text });
+ registry.register('Image', { component: Image });
+ registry.register('Icon', { component: Icon });
+ registry.register('Divider', { component: Divider });
+ registry.register('Video', { component: Video });
+ registry.register('AudioPlayer', { component: AudioPlayer });
+
+ // Layout components
+ registry.register('Row', { component: Row });
+ registry.register('Column', { component: Column });
+ registry.register('List', { component: List });
+ registry.register('Card', { component: Card });
+
+ // Additional layout components
+ registry.register('Tabs', { component: Tabs });
+ registry.register('Modal', { component: Modal });
+
+ // Interactive components
+ registry.register('Button', { component: Button });
+ registry.register('TextField', { component: TextField });
+ registry.register('CheckBox', { component: CheckBox });
+ registry.register('Slider', { component: Slider });
+ registry.register('DateTimeInput', { component: DateTimeInput });
+ registry.register('MultipleChoice', { component: MultipleChoice });
+}
+
+/**
+ * Initialize the default catalog in the singleton registry.
+ * Call this once at app startup.
+ */
+export function initializeDefaultCatalog(): void {
+ registerDefaultCatalog(ComponentRegistry.getInstance());
+}
diff --git a/renderers/react/src/styles/README.md b/renderers/react/src/styles/README.md
new file mode 100644
index 000000000..1b6551f5e
--- /dev/null
+++ b/renderers/react/src/styles/README.md
@@ -0,0 +1,170 @@
+# A2UI React Renderer — Style Architecture
+
+The React renderer uses Light DOM (regular HTML elements), unlike the Lit
+renderer which uses Shadow DOM with built-in style encapsulation. This means
+all CSS lives in the global document scope and must be carefully organized to
+avoid conflicts with host-app stylesheets.
+
+All styles are injected into a single `
+
+
+
+
+
+