## 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](#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:

$
 pnpm create arrow-js@latest arrow-app

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.

$
 npx @arrow-js/skill@latest

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:

```shell
npm install @arrow-js/core
```

#### From a CDN:

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

### Editor support

Install the official
 [ArrowJS Syntax](https://marketplace.visualstudio.com/items?itemName=StandardAgents.arrowjs-syntax)
 extension for VSCode to get syntax highlighting and
 autocomplete inside `html` template literals.
 Arrow also ships TypeScript definitions for full editor support.

## Community

Join the
 [Arrow Discord](https://discord.gg/fBy7csvmAt)
 to ask questions, share what you're building, and connect with
 other developers using Arrow.

Follow the author
 [Justin Schroeder](https://x.com/intent/follow?screen_name=jpschroeder)
 on X for updates, releases, and behind-the-scenes development.

Browse the source, report issues, and contribute on
 [GitHub](https://github.com/standardagents/arrow-js).

## 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.

```ts
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.

```ts
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.

```ts
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.

```ts
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.

```ts
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.

```ts
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.

```ts
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.

```ts
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.

```ts
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:

```ts
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:

```ts
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:

```ts
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.

```ts
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)
```

### Prompt for agents

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

Agent Prompt
 Copy

`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()`.

create_arrow_sandbox
 Copy

`{
 "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"]
 }
}`
 

 
 
 
 
 [](https://standardagents.ai)Early Access
 
 
 From the team behind
 [FormKit](https://formkit.com),
 [Tempo](https://tempo.formkit.com),
 [AutoAnimate](https://auto-animate.formkit.com),
 and
 [Drag and Drop](https://drag-and-drop.formkit.com)
 — 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.

Request Early Access

You're on the list! We'll be in touch.

## 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](https://developer.mozilla.org/en-US/docs/Web/API/Navigation)
 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.

```ts
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>`,
  }
}
```

