The Promise: Agent-Streamed UI Without Manual DOM Wrangling

When evaluating a2ui-project/a2ui for a conversational dashboard rebuild, the pitch was clean: backend agents stream JSON layout descriptors, and the React adapter hydrates them into live components. No manual createElement chains, no brittle template strings. The repository ships adapters for React, Angular, Lit, Flutter, SwiftUI, and Jetpack Compose—universal generative UI.

The reality hit during our first modal implementation. a2ui's layout schema handles flat component trees elegantly, but once you introduce nested modals with focus trapping and portal rendering, the abstraction starts bleeding through the seams.

The Failure Surface: Portal Boundaries and TailwindCSS Context Loss

Test Case: Agent-Driven Confirmation Dialog

Our agent streams this layout descriptor:

{ "type": "modal", "id": "confirm-delete", "backdrop": true, "children": [ { "type": "box", "className": "bg-white rounded-lg shadow-xl p-6 max-w-md", "children": [ { "type": "text", "content": "Delete 47 items?" }, { "type": "button", "variant": "danger", "label": "Confirm" } ] } ] } 

The React adapter wraps this in a Radix Dialog.Root. Problem: TailwindCSS utility classes don't cascade into React portals unless you explicitly configure tailwind.config.js to scan portal roots. The modal renders, but bg-white and shadow-xl vanish—the dialog appears as unstyled gray blocks.

Hardening Configuration

Force Tailwind to scan portal containers:

// tailwind.config.js module.exports = { content: [ './src/**/*.{js,jsx,ts,tsx}', './node_modules/@a2ui/react-adapter/**/*.js', // Critical: scan React portal mount points './public/index.html' ], theme: { extend: {} } }; 

But now you've coupled your build pipeline to a2ui's internal DOM structure. When the adapter changes its portal strategy (observed between v0.8.3 and v0.9.1), styles break silently in production.

The Focus Trap Deadlock

Scenario: Nested Confirmation Flows

Agent streams a second modal ("Are you sure?") while the first is still open. a2ui's React adapter chains Dialog.Root components, but focus management collapses:

  1. User presses Tab inside the second modal.
  2. Focus escapes to the first modal's backdrop.
  3. Screen reader announces the wrong dialog title.
  4. Keyboard navigation enters a deadlock—Escape key dismisses both modals simultaneously.

Root cause: a2ui's layout schema has no explicit focus scope boundaries. The JSON descriptor treats modals as generic containers, delegating focus control entirely to the component library (Radix, in this case). When you nest portals, Radix's FocusScope gets confused by React's reconciliation order.

Manual Override

We patched the adapter to inject data-focus-scope attributes:

// Custom React adapter override import { Dialog } from '@radix-ui/react-dialog'; import { useEffect, useRef } from 'react'; function A2UIModal({ descriptor, children }) { const scopeRef = useRef(null); useEffect(() => { if (scopeRef.current) { scopeRef.current.focus(); } }, []); return ( <Dialog.Root open={true}> <Dialog.Portal> <Dialog.Overlay className="fixed inset-0 bg-black/50" /> <Dialog.Content ref={scopeRef} data-focus-scope={descriptor.id} className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2" > {children} </Dialog.Content> </Dialog.Portal> </Dialog.Root> ); } 

This works, but now we're maintaining a fork of the adapter. Every upstream change requires manual merge conflict resolution.

Operational Trade-Off: Schema Rigidity vs. Escape Hatches

The core tension: a2ui's JSON schema optimizes for agent-side simplicity (LLMs generate clean, validated layouts), but sacrifices client-side flexibility. When you hit edge cases—stacked z-index conflicts, viewport-aware positioning, touch gesture cancellation—you're forced to either:

  1. Extend the schema with custom properties (breaks cross-platform compatibility).
  2. Eject to raw component libraries (defeats the purpose of a2ui).

Example: We tried adding a trapFocus: "strict" flag to the modal descriptor. The React adapter honored it, but the Flutter adapter ignored it (no equivalent in Flutter's showDialog). Cross-platform consistency collapsed.

When This Matters

If your generative UI flows are linear and non-overlapping (form wizards, chatbot threads), a2ui's abstraction holds. But production apps demand:

  • Toast notifications overlaying modals
  • Popovers inside dropdown menus inside sidebars
  • Drag-and-drop interactions across portal boundaries

These patterns expose a2ui's unresolved question: should the layout schema stay minimal (forcing platform-specific hacks), or should it expand into a full declarative accessibility DSL (risking bloat and divergence from native primitives)?

No clean answer yet. The repository's issue tracker shows 14 open threads on focus management alone.