ArrowJS — The first UI framework for the agentic era

11 min read Original article ↗

Why Arrow

Arrow is a reactive UI framework built around JavaScript primitives: Modules, functions, and template literals. Arrow is just TypeScript, so your coding agent already knows how to use it really well.

You only need 3 functions:

  • reactive
  • html
  • component

Unlike other major frameworks, there is no "idomatic" way to use Arrow since it's just TypeScript functions and template literals. The entire documentation fits in less than 5% of a 200k context window.

Arrow requires no build step, no JSX compilation, no React compiler, no Vite plugin (there is one if you need SSR), no Vue template complier, and yet it runs incredibly fast at less than 5kb over the wire. When coupled with the Arrow sandbox, it's perfect for interfaces produced by chat agents too.

Quickstart

Scaffold a complete Vite 8 Arrow app with SSR, hydration, route-based metadata, and the full framework stack in one command:

Coding agent skill

Install the Arrow coding agent skill wrapper if you want the same project-specific guidance in tools like Codex and Claude Code.

Other ways to install

Arrow still works fine without a build tool. If you only need the core runtime, a simple module import is enough.

From npm:

npm install @arrow-js/core

From a CDN:

<script type="module">
  import { reactive, html } from 'https://esm.sh/@arrow-js/core'
</script>

Editor support

Install the official ArrowJS Syntax extension for VSCode to get syntax highlighting and autocomplete inside html template literals. Arrow also ships TypeScript definitions for full editor support.

Reactive Data

reactive() turns plain objects, arrays, or expressions into live state that Arrow (or anyone else) can track and update from.

reactive(value) or reactive(() => value)

  • Wrap objects or arrays to create observable state.
  • Pass an expression to create a computed value.
  • Use it for local component state, shared stores, and mutable props.
  • Read properties normally. Arrow tracks those reads inside watchers and template expressions.
  • Use $on and $off when you want manual subscriptions.
import { reactive } from '@arrow-js/core'

const data = reactive({
  price: 25,
  quantity: 10
})

console.log(data.price) // 25

Computed values

reactive(() => value) reruns when its tracked reads change.

import { reactive } from '@arrow-js/core'

const props = reactive({ count: 2, multiplier: 10 })

const data = reactive({
  total: reactive(() => props.count * props.multiplier)
})

console.log(data.total) // 20
props.count = 3
console.log(data.total) // 30

Tip

data.total reads like a normal value even though it is backed by a tracked expression.

Templates

To render DOM elements with Arrow you use the html tagged template literal.

html`...` — create a mountable template

  • Templates can be mounted directly, passed around, or returned from components.
  • Expression slots are static by default, but if callable functions are provided they will update when their respective reactive data is changed. In other words ${data.foo} is static but ${() => data.foo} is reactive.
  • Templates can render text, attributes, properties, lists, nested templates, and events.

Plain values render once. If you pass a function like () => data.count, Arrow tracks the reactive reads inside that function and updates only that part of the template when they change.

Attributes

Use a function expression to keep an attribute in sync.

import { html, reactive } from '@arrow-js/core'

const data = reactive({ disabled: false })

html`<button disabled="${() => data.disabled}">
  Save
</button>`

Tip

Returning false from an attribute expression will remove the attribute. This makes it easy to toggle attributes.

import { html, reactive } from '@arrow-js/core'

const data = reactive({ disabled: false })

html`<button disabled="${() => data.disabled ? true : false}">
  Save
</button>`

Lists

Return an array of templates to render a list. Add .key(...) when identity must survive reorders.

import { html, reactive } from '@arrow-js/core'

const data = reactive({
  todos: [
    { id: 1, text: 'Write docs' },
    { id: 2, text: 'Ship app' },
  ],
})

html`<ul>
  ${() => data.todos.map((todo) =>
    html`<li>${todo.text}</li>`.key(todo.id)
  )}
</ul>`

Tip

Keys are only necessary if you want to preserve the DOM nodes and their state. Avoid using the index as a key.

import { html, reactive } from '@arrow-js/core'

const data = reactive({ tags: ['alpha', 'beta', 'gamma'] })

html`<ul>
  ${() => data.tags.map((tag) => html`<li>${tag}</li>`)}
</ul>`

Events

@eventName attaches an event listener.

import { html } from '@arrow-js/core'

html`<button @click="${(e) => console.log(e)}">Click</button>`

Components

Arrow components are plain functions wrapped with component(). A component mounts once per render slot and keeps local state while that slot survives parent rerenders.

  • Pass a reactive object as props.
  • Read props lazily inside expressions like () => props.count.
  • Keep local component state with reactive() inside the component.
  • Use .key(...) when rendering components in keyed lists.
import { component, html, onCleanup, reactive } from '@arrow-js/core'
import type { Props } from '@arrow-js/core'

const parentState = reactive({ count: 1 })

const Counter = component((props: Props<{ count: number }>) => {
  const local = reactive({ clicks: 0 })
  const onResize = () => console.log(window.innerWidth)

  window.addEventListener('resize', onResize)
  onCleanup(() => window.removeEventListener('resize', onResize))

  return html`<button @click="${() => local.clicks++}">
    Root count ${() => props.count} | Local clicks ${() => local.clicks}
  </button>`
})

html`<section>
  <h3>Dashboard</h3>
  ${Counter(parentState)}
</section>`

Key concept

The component function itself is not rerun on every parent update. Arrow keeps the instance for that slot and retargets its props when needed. That makes local state stable across higher-order rerenders.

In the common case, just pass a reactive object directly as the component props.

import { component, html, reactive } from '@arrow-js/core'

const state = reactive({ count: 1, theme: 'dark' })
const Counter = component((props) =>
  html`<strong>${() => props.count}</strong>`
)

html`<p>
  Current count:
  ${Counter(state)}
</p>`

Tip

Props stay live when you read them lazily. Avoid destructuring them once at component creation time if you expect updates.

Use onCleanup() inside a component when you set up manual listeners, timers, or sockets that need teardown when the component slot unmounts.

Async components

The same core component() also accepts async factories when the Arrow async runtime is present:

import { component, html } from '@arrow-js/core'
import type { Props } from '@arrow-js/core'

type User = { id: string; name: string }

const UserName = component(
  async ({ id }: Props<{ id: string }>) => {
    const user = await fetch(`/api/users/${id}`)
      .then((r) => r.json() as Promise<User>)
    return user.name
  },
  { fallback: html`<span>Loading user…</span>` }
)

const UserCard = component((props: Props<{ id: string }>) =>
  html`<article>${UserName(props)}</article>`
)

The async body resolves data, and the surrounding template stays reactive in the usual Arrow way. SSR waits for async components to settle, and hydration resumes JSON-safe results from serialized payload data automatically.

Tip

Most async components need no extra options. Arrow assigns ids, snapshots JSON-safe results, and renders resolved values directly by default. Reach for fallback, render, serialize, deserialize, or idPrefix only when the default behavior is not enough.

Watching Data

watch(effect) or watch(getter, afterEffect)

  • Use it for derived side effects outside templates.
  • Dependencies are discovered automatically from reactive reads.
  • Arrow also drops dependencies that are no longer touched on later runs.
  • Watchers created inside a component are stopped automatically when that component unmounts.

Single-effect form:

import { reactive, watch } from '@arrow-js/core'

const data = reactive({ price: 25, quantity: 10, logTotal: true })

watch(() => {
  if (data.logTotal) {
    console.log(`Total: ${data.price * data.quantity}`)
  }
})

Getter plus effect form:

import { reactive, watch } from '@arrow-js/core'

const data = reactive({ price: 25, quantity: 10, logTotal: true })

watch(
  () => data.logTotal ? data.price * data.quantity : null,
  (total) => total !== null && console.log(`Total: ${total}`)
)

Sandbox

@arrow-js/sandbox lets you run JS/TS/Arrow inside a WASM virtual machine while the host page keeps ownership of the real DOM rendered by html(). These two environments only communicate through serialized messages, which allows safe execution of AI-generated code and makes the sandbox a good fit for inline UI produced by chat agents.

  • source must include exactly one main.ts or main.js entry file.
  • main.css is optional and is injected into the sandbox host root.
  • The sandbox renders through a stable <arrow-sandbox> custom element.
  • Call output(payload) inside sandboxed code to send data back through the optional events.output handler.
import { html } from '@arrow-js/core'
import { sandbox } from '@arrow-js/sandbox'

const root = document.getElementById('app')
if (!root) throw new Error('Missing #app root')

const source = {
  'main.ts': [
    "import { html, reactive } from '@arrow-js/core'",
    '',
    'const state = reactive({ count: 0 })',
    '',
    'export default html`<button @click="${() => state.count++}">',
    '  Count ${() => state.count}',
    '</button>`',
  ].join('\n'),
  'main.css': [
    'button {',
    '  font: inherit;',
    '  padding: 0.75rem 1rem;',
    '}',
  ].join('\n'),
}

html`<section>${sandbox({ source })}</section>`(root)

Live Demo See sandbox isolation in action A full interactive example showing reactivity inside the VM, blocked browser globals, and the restricted fetch bridge. Open in Playground

Prompt for agents

If you want an agent to generate a sandbox payload directly, this prompt keeps the output narrow and aligned with Arrow.

Build this UI as an Arrow sandbox payload. Return an object for sandbox({ source, ... }) with exactly one entry file named main.ts or main.js, plus main.css only if styles are needed. Use @arrow-js/core primitives directly: reactive(...) for state, html`...` for DOM, and component(...) only when reusable local state or composition is actually needed. Arrow expression slots are static by default, so any live value must be wrapped in a callable function like ${() => state.count}. Use event bindings like @click="${() => state.count++}", do not use JSX, React hooks, Vue directives, direct DOM mutation, or framework-specific render APIs.

Export a default Arrow template or component result from main.ts. Keep the example self-contained, prefer a single clear root view, and communicate back to the host with output(payload) when needed. Put CSS in main.css, keep payloads JSON-serializable, and only return the files that are necessary for the requested interface. If you create multiple files, make sure imports match the virtual filenames you place in source.

JSON schema tool

If your agent supports tool calling, this schema produces the exact argument object expected by sandbox().

{
  "name": "create_arrow_sandbox",
  "description": "Produce arguments for @arrow-js/sandbox.",
  "inputSchema": {
    "type": "object",
    "additionalProperties": false,
    "properties": {
      "source": {
        "type": "object",
        "description": "Virtual files passed to sandbox({ source }). Must include main.ts or main.js. main.css is optional.",
        "additionalProperties": false,
        "properties": {
          "main.ts": {
            "type": "string",
            "description": "Main Arrow TypeScript entry file."
          },
          "main.js": {
            "type": "string",
            "description": "Main Arrow JavaScript entry file."
          },
          "main.css": {
            "type": "string",
            "description": "Optional stylesheet for the sandbox root."
          }
        },
        "anyOf": [
          { "required": ["main.ts"] },
          { "required": ["main.js"] }
        ]
      },
      "shadowDOM": {
        "type": "boolean",
        "description": "Whether the sandbox should render inside shadow DOM."
      },
      "debug": {
        "type": "boolean",
        "description": "Whether sandbox debug logging should be enabled."
      }
    },
    "required": ["source"]
  }
}

From the team behind FormKit, Tempo, AutoAnimate, and Drag and Drop — Standard Agents is an open standard for creating domain-specific agents you can distribute and compose together to form safe, efficient, and effective agents. Join the early access list.

Routing

The Vite scaffold uses a simple routeToPage(url) entry so the server and browser both resolve the same route tree.

  • Choose a route from the incoming URL.
  • Return the page status, metadata, and Arrow view together.
  • Reuse the same routing function for SSR and hydration so both sides render the same page shape.

For browser-only routing, Arrow recommends the native Navigation API via window.navigation when your support matrix allows it. It gives you a single navigation event stream and more reliable history traversal than wiring everything around the older history.pushState() flow. Keep a History API fallback if you still support older browsers.

import { html } from '@arrow-js/core'

export function routeToPage(url: string) {
  if (url === '/') {
    return {
      status: 200,
      title: 'Home',
      view: html`<main>Home</main>`,
    }
  }

  return {
    status: 404,
    title: 'Not Found',
    view: html`<main>Not found</main>`,
  }
}

Examples

Each example runs in the playground with full source you can edit live.

Todo List

A task tracker with reactive arrays, keyed lists, and computed filtering.

Open in Playground →

🍅Pomodoro Timer

A focus timer with SVG progress ring, intervals, and computed formatting.

Open in Playground →

🎨Color Palette

A Coolors-style harmony palette generator with reactive style binding and computed colors.

Open in Playground →

🔐Password Generator

A configurable password tool with reactive toggles and a strength meter.

Open in Playground →

🪗Accordion

Expandable FAQ sections where each component instance keeps its own state.

Open in Playground →

📡Live Feed

An auto-updating event feed with reactive array mutations and timed entries.

Open in Playground →

📊Data Table

A sortable data table with reactive column sorting, keyed rows, and computed ordering.

Open in Playground →

📁Tabs

A tabbed interface with ARIA roles, animated panel transitions, and per-tab content.

Open in Playground →

🖼️Photo Gallery

A responsive image grid with a lightbox carousel, keyboard navigation, and lazy loading.

Open in Playground →

🎮Flappy Arrow

Navigate ()=> through ASCII pipes in this flappy-bird tribute with reactive state and a RAF game loop.

Open in Playground →

🛡️Sandbox

Run untrusted Arrow code in a WASM VM with isolated DOM, restricted fetch, and one-way output.

Open in Playground →

Playground Build something with Arrow Open a live editor with a starter template, hot reloading, and every Arrow package ready to import. Open Playground