> For the complete documentation index, see [llms.txt](https://basedapp.gitbook.io/docs/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://basedapp.gitbook.io/docs/integrations/mini-apps-platform/07-ui-nodes-and-renderer.md).

# UI nodes & renderer

Mini-apps describe their UI as a tree of `UiNode`s. The host's `UiNodeRenderer` walks the tree and instantiates Flutter widgets from `based_ui`. Mini-app code never touches Flutter directly.

This is the design choice that gives us:

* **Visual consistency** — every mini-app looks like a Based mini-app, not a webview.
* **Tenant theming for free** — the renderer pulls colors from the active tenant theme.
* **Small surface for security review** — partners can only render from a known catalog.

## The `UiNode` type

```dart
@freezed
class UiNode with _$UiNode {
  const factory UiNode({
    required String type,                  // catalog kind
    String? key,                            // optional stable key for diffing
    Map<String, dynamic>? props,            // type-specific props
    List<UiNode>? children,                 // nested children, if applicable
  }) = _UiNode;

  factory UiNode.fromJson(Map<String, dynamic> json) => _$UiNodeFromJson(json);
}
```

Mini-apps build these in JS using helper builders:

```js
import { ui } from 'based';

const node = ui.Screen({
  header: { title: 'Stake USDC', showBack: true },
  body: [
    ui.Card([
      ui.Text('Earn yield', { style: 'h2' }),
      ui.Button({ label: 'Stake', onTap: () => stake() }),
    ]),
  ],
});
```

`ui.Screen`, `ui.Card`, etc. are pure JS factory functions that return `UiNode` JSON. No DOM, no React, no JSX runtime needed (though we will support a JSX preset in the partner CLI).

## Catalog (v1)

The widget catalog maps directly to existing `based_ui` widgets. Mini-apps cannot create types outside this list.

### Layout

| Type       | Maps to                     | Notable props                                                   |
| ---------- | --------------------------- | --------------------------------------------------------------- |
| `Screen`   | `Scaffold` + `ScreenHeader` | `header`, `body`, `safeArea`, `backgroundColor`                 |
| `Column`   | `Column`                    | `children`, `spacing`, `mainAxis`, `crossAxis`, `padding`       |
| `Row`      | `Row`                       | same                                                            |
| `Stack`    | `Stack`                     | `children`, `alignment`                                         |
| `Padding`  | `Padding`                   | `child`, `padding`                                              |
| `Sized`    | `SizedBox`                  | `child`, `width`, `height`                                      |
| `Spacer`   | `Spacer` / `SizedBox`       | `flex` or `size`                                                |
| `Center`   | `Center`                    | `child`                                                         |
| `Expanded` | `Expanded`                  | `child`, `flex`                                                 |
| `Scroll`   | `SingleChildScrollView`     | `child`, `direction`                                            |
| `List`     | `ListView.builder`          | `items`, `itemBuilder` (mini-app-side), `divider`, `onLoadMore` |
| `Pager`    | `PageView`                  | `pages`, `index`, `onIndexChanged`                              |
| `Tabs`     | `TabBar` + `TabBarView`     | `tabs`, `index`, `onIndexChanged`                               |

### Display

| Type             | Maps to                      | Notable props                                                        |
| ---------------- | ---------------------------- | -------------------------------------------------------------------- |
| `Text`           | `Text`                       | `value`, `style` (`h1`/`h2`/`h3`/`body`/`caption`), `align`, `color` |
| `Image`          | host `NetworkImage`          | `url`, `width`, `height`, `fit`, `placeholder`                       |
| `Icon`           | `Icon` (catalog only)        | `name` (allowlisted), `size`, `color`                                |
| `Badge`          | `PnlBadge` / generic         | `text`, `variant`                                                    |
| `Divider`        | `Divider`                    | `thickness`, `color`                                                 |
| `SkeletonLoader` | `SkeletonLoader`             | `width`, `height`, `lines`                                           |
| `EmptyState`     | host empty state             | `icon`, `title`, `body`, `cta`                                       |
| `Markdown`       | restricted markdown renderer | `value`. Subset: bold/italic/links/lists. **No raw HTML.**           |

### Form / interactive

| Type               | Maps to             | Notable props                                                                                   |
| ------------------ | ------------------- | ----------------------------------------------------------------------------------------------- |
| `Button`           | `AppButton`         | `label`, `variant` (`primary`/`secondary`/`ghost`/`destructive`), `enabled`, `loading`, `onTap` |
| `TextField`        | `AppTextField`      | `value`, `placeholder`, `onChanged`, `keyboard`, `obscure`, `maxLength`, `error`                |
| `AmountInput`      | `AmountInput`       | `value`, `token`, `usdEquivalent`, `onChanged`                                                  |
| `Toggle`           | `AppToggle`         | `value`, `onChanged`, `label`                                                                   |
| `Slider`           | `Slider`            | `value`, `min`, `max`, `onChanged`                                                              |
| `SegmentedControl` | host segmented      | `options`, `value`, `onChanged`                                                                 |
| `Picker`           | bottom-sheet picker | `options`, `value`, `onChanged`                                                                 |

### Crypto-specific

| Type           | Maps to           | Notable props                                       |
| -------------- | ----------------- | --------------------------------------------------- |
| `TokenImage`   | `TokenImage`      | `symbol`, `chain`, `size`                           |
| `AddressShort` | host address chip | `address`, `chain`, `copyable`                      |
| `Chart`        | host chart        | `series`, `kind` (`line`/`candle`/`area`), `xRange` |
| `OrderBook`    | host order book   | `bids`, `asks`, `decimals`                          |
| `Receipt`      | host receipt      | `lines`, `totals`                                   |

### Surfaces

| Type     | Maps to     | Notable props                                                 |
| -------- | ----------- | ------------------------------------------------------------- |
| `Card`   | `AppCard`   | `children`, `padding`, `elevated`                             |
| `Sheet`  | host sheet  | imperative — pushed via `ui.openSheet(node)`, not declarative |
| `Dialog` | host dialog | imperative — pushed via `ui.openDialog(node)`                 |

### Animation, effects & sensor-driven widgets (post-v1 additions)

These primitives shipped after the initial v1 catalog and are part of the live SDK surface (`targetSdkVersion: '1.0'`). They map to bespoke widgets inside `based_ui_nodes/lib/src/widgets/`.

| Type                 | Maps to               | Notable props                                                                                                       |
| -------------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------- |
| `Motion`             | `MotionWidget`        | `initial`, `animate`, `exit`, `transition`, `whileHover`, `whileTap`, `key`                                         |
| `AnimatePresence`    | host equivalent       | `children` (each requires a stable `key`)                                                                           |
| `Gradient`           | `GradientBox`         | `kind` (`linear`/`radial`), `stops`, `begin`/`end`, `center`, `radius`                                              |
| `Blur`               | `BlurBox`             | `sigmaX`, `sigmaY`                                                                                                  |
| `DropShadow`         | `DropShadowBox`       | `color`, `blur`, `spread`, `offsetX`, `offsetY`                                                                     |
| `AspectRatio`        | `AspectRatio`         | `ratio`                                                                                                             |
| `PositionedAbsolute` | `Positioned`          | `left`, `top`, `right`, `bottom`                                                                                    |
| `SafeArea`           | `SafeArea`            | `top`, `bottom`, `left`, `right`                                                                                    |
| `Marquee`            | host scroller         | `pixelsPerSecond`, `gap`                                                                                            |
| `IgnorePointer`      | `IgnorePointer`       | `ignoring`                                                                                                          |
| `Video`              | `VideoNode`           | `src`, `autoplay`, `loop`, `muted`                                                                                  |
| `WebView`            | `WebViewNode`         | `url`, `onUrlChange`                                                                                                |
| `BottomNavBar`       | `BottomNavigationBar` | `items`, `activeId`, `iconSize`, `showLabels`, `onTap`                                                              |
| `CardTilt`           | `CardTilt`            | `front` (UiNode), `back?` (UiNode), `width`, `height`, `tiltMaxRadians`, `glareOpacity`, `borderRadius`, `showBack` |

`<CardTilt />` deserves a separate note: it reads the device accelerometer directly inside the renderer via `package:sensors_plus` and applies a perspective `Matrix4.rotateX/rotateY` plus a radial-gradient glare overlay that tracks the tilt. This is what the gacha mini-app's card-detail sheet uses for its "3D" view mode — the literal three.js / WebGL viewer the web app uses isn't portable to QuickJS, so we faked the effect with native primitives. Auto-orbits when no sensor is available; clamps to zero when the OS reduce-motion setting is on. **No permission required** — sensor access happens out-of-band of the bridge. (Mini-apps that want raw sensor access for their own logic use `based.gyro.read()` instead, which is gated by `gyro.read`.)

### Hex colour convention

All `color` / `backgroundColor` / `borderColor` props accept the same CSS-style hex format: `#RRGGBB`, `#RRGGBBAA` (alpha last), or the 3-char shorthand `#RGB`. Effects widgets (`Gradient`, `DropShadow`, `Blur`, `CardTilt`) use the same parser as the layout/text widgets — earlier internal versions of the renderer treated 8-char hex as ARGB inside `Gradient`/`DropShadow`, which silently mis-coloured every alpha-suffix stop. Fixed in the host. If you see a green hue where you expected pink, you're on a stale host build.

### Text overflow

`Text` accepts two extra props for narrow-screen ellipsis behaviour:

| prop       | values                                           | default            | notes                                                                    |
| ---------- | ------------------------------------------------ | ------------------ | ------------------------------------------------------------------------ |
| `maxLines` | `number`                                         | `null` (unlimited) | hard cap on line count                                                   |
| `overflow` | `'clip'` / `'ellipsis'` / `'fade'` / `'visible'` | `'clip'`           | tail handling when content exceeds `maxLines` or the row's bounded width |

Use `maxLines: 1` + `overflow: 'ellipsis'` on label rows that share a `Row` with other intrinsic-width children — wrap the Text in `ui.Expanded(...)` so it gets the leftover width.

Imperative surfaces are pushed by calling `based.ui.openSheet(node)` etc., which returns a Promise resolving to the sheet's result. The body of the sheet is itself a `UiNode` tree.

## Events

Event handlers are not closures across the bridge — JS owns the closure, Dart only knows the event ID.

In JS:

```js
ui.Button({
  label: 'Stake',
  onTap: () => onStake(),    // JS function, scoped to mini-app code
})
```

The builder rewrites this to:

```json
{
  "type": "Button",
  "props": { "label": "Stake", "onTap": { "$ref": "evt:7" } }
}
```

`"evt:7"` is a handle managed by `based-runtime/dist/events.js`. When the user taps the button:

1. Renderer fires `onEvent("evt:7")`.
2. Bridge dispatches as a special `event-fire` message into the mini-app isolate.
3. JS runtime invokes the registered closure.

```mermaid
sequenceDiagram
    participant U as User
    participant R as UiNodeRenderer
    participant Br as Bridge
    participant JS as JS Runtime
    U->>R: tap (Button)
    R->>Br: onEvent("evt:7")
    Br->>JS: event-fire message
    JS->>JS: invoke registered closure
    JS-->>R: new UiNode tree
    R-->>U: re-render
```

Closures are GC'd when their parent `UiNode` is unmounted. The runtime tracks references to prevent leaks.

## State and reactivity

Two reactivity models are supported; mini-apps pick one per screen.

### `state` API (built-in, simple)

```js
export default function App({ state }) {
  const count = state.use('count', 0);

  return ui.Column([
    ui.Text(`Count: ${count}`),
    ui.Button({ label: '+1', onTap: () => state.set('count', count + 1) }),
  ]);
}
```

`state` is a tiny key-value store with subscription. Setting a key triggers re-invocation of `App()`. Suitable for screens with a handful of pieces of state.

### Reactive primitives (advanced)

For more complex apps, the SDK exposes a minimal reactive primitive (`signal`, `computed`, `effect`) inspired by Solid:

```js
import { signal, computed } from 'based';

export default function App() {
  const count = signal(0);
  const doubled = computed(() => count() * 2);

  return () => ui.Column([
    ui.Text(`${count()} → ${doubled()}`),
    ui.Button({ label: '+1', onTap: () => count.set(count() + 1) }),
  ]);
}
```

When `App` returns a function, the renderer treats it as reactive — re-invoking only the parts of the tree that depend on changed signals.

We deliberately avoid bundling React or Vue — adds binary weight, depends on DOM-shaped APIs, and pulls partners into a framework we'd then have to keep in lockstep with the host. A small bespoke reactive primitive (\~200 LOC) is enough for our scope.

## Diffing

Re-renders produce a new `UiNode` tree. The renderer:

1. Walks old and new trees in parallel, keyed by `key` if present, otherwise positional.
2. For each pair: if `type` differs, replace; if same, diff `props`, recursively diff `children`.
3. Patches translate to widget rebuilds via Flutter's normal mechanism — `Element.update`.

For `List`, virtualization is mandatory: only nodes for visible items are materialized. The mini-app provides an `itemBuilder(index)` callback (which is itself an event ref); the host invokes it lazily.

## Theming

Mini-apps inherit the active tenant theme automatically. The renderer wraps the mini-app's container in a `Theme` widget configured from `BasedTheme.colors` and any `manifest.theme.overrides`.

Mini-app code may *read* the active theme via `based.theme.colors` and use those values in props (e.g. `ui.Text('hi', { color: based.theme.colors.accent })`). It may not change the theme.

When the host's tenant theme changes (rare — only on tenant switch, which restarts the host), or dark/light flips, an `event` of topic `theme.changed` is pushed. Reactive mini-apps handle this automatically; non-reactive ones receive it via the `state` API's `host.onThemeChanged` hook.

## Animations

V1 supports declarative animations on a small set of properties:

* Implicit transitions on `opacity`, `transform`, `color`, `width`, `height`.
* Pre-baked entrances/exits: `fadeIn`, `slideIn`, `scaleIn`.

Mini-apps don't write animation curves. They request named animations:

```js
ui.AnimatedSwitcher({
  child: showLoading ? ui.SkeletonLoader() : ui.Text('Done'),
  duration: 300, curve: 'easeOut',
})
```

Custom per-frame animation is not supported in v1. Use cases that need it (e.g. scroll-driven parallax) get a host-implemented native widget with declarative inputs (`<ParallaxScroll>` in v2).

## Accessibility

Every interactive node has accessibility props:

```js
ui.Button({
  label: 'Stake',
  a11y: { semanticsLabel: 'Stake 100 USDC into Cool Yield', hint: 'Opens confirmation' },
  onTap: ...,
})
```

The renderer applies these via Flutter's `Semantics` widget. The CLI lints for missing labels on interactive nodes.

## Localization

Mini-apps own their strings. The catalog includes a `ui.t(key, params)` helper that the partner CLI bundles from a `locales/<lang>.json` file. The host provides the active locale via `based.host.locale`.

Mini-apps cannot read host strings (no shared string table), but they can inherit number/date formatting via `based.intl.formatNumber(...)` etc. (TBD; see `12-open-questions.md`).

## What's deliberately out of scope

* **Custom widget types.** Partners cannot extend the catalog. If a use case is unmet, we add to `based_ui` and surface it.
* **Per-pixel canvas drawing.** No `<Canvas>` in v1. Charts go through the catalog `Chart` widget.
* **Custom fonts.** Mini-apps use the host's font stack. Custom fonts would balloon bundle size and undermine visual consistency.
* **Modal stacking >2 deep.** Sheets-on-sheets-on-sheets is a UX trap; the host caps at 2.

Read [`08-security-and-sandbox.md`](/docs/integrations/mini-apps-platform/08-security-and-sandbox.md) next.
