# Why a2ui's JSON-Driven Layout Schema Breaks Down in Production Modal Stacks

> Source: <https://dev.to/chloe_chen/why-a2uis-json-driven-layout-schema-breaks-down-in-production-modal-stacks-2fee>
> Published: 2026-11-14 00:09:14+00:00

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.

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.

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.

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**:

`Tab` inside the second modal.`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.

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

``` js
// 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.

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:

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.

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

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.
