npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@benqoder/beam

v1.3.0

Published

A lightweight, declarative UI framework for building interactive web applications with WebSocket RPC. Beam provides server-driven UI updates with minimal JavaScript configuration—just add attributes to your HTML.

Readme

Beam

A lightweight, declarative UI framework for building interactive web applications with WebSocket RPC. Beam provides server-driven UI updates with minimal JavaScript configuration—just add attributes to your HTML.

Features

  • WebSocket RPC - Real-time communication without HTTP overhead
  • Per-Call Hono Middleware - Re-enter Hono for every RPC action without switching the browser to HTTP
  • Declarative - No JavaScript needed, just HTML attributes
  • Auto-discovery - Handlers are automatically found via Vite plugin
  • Modals & Drawers - Built-in overlay components
  • Smart Loading - Per-action loading indicators with parameter matching
  • DOM Updates - Server-driven UI updates
  • Real-time Validation - Validate forms as users type
  • Input Watchers - Trigger actions on input/change events with debounce/throttle
  • Conditional Triggers - Only trigger when conditions are met (beam-watch-if)
  • Dirty Form Tracking - Track unsaved changes with indicators and warnings
  • Conditional Fields - Enable/disable/show/hide fields based on other values
  • Deferred Loading - Load content when scrolled into view
  • Polling - Auto-refresh content at intervals
  • Hungry Elements - Auto-update elements across actions
  • Confirmation Dialogs - Confirm before destructive actions
  • Instant Click - Trigger on mousedown for snappier UX
  • Offline Detection - Show/hide content based on connection
  • Navigation Feedback - Auto-highlight current page links
  • Conditional Show/Hide - Toggle visibility based on form values
  • Auto-submit Forms - Submit on field change
  • Beam Visits - Upgrade normal SSR links into Beam-powered visits
  • History Management - Push/replace browser history for actions and visits
  • Placeholders - Show loading content in target
  • Keep Elements - Preserve elements during updates
  • Toggle - Client-side show/hide with transitions (no server)
  • Dropdowns - Click-outside closing, Escape key support (no server)
  • Collapse - Expand/collapse with text swap (no server)
  • Class Toggle - Toggle CSS classes on elements (no server)
  • Reactive State - Fine-grained reactivity for UI components (tabs, accordions, carousels)
  • Server State Updates - Update named client state from server actions without swapping DOM
  • Cloak - Hide elements until reactivity initializes (no flash of unprocessed content)
  • Lazy Connections - Beam connects only when needed by default
  • Multi-Render - Update multiple targets in a single action response
  • Async Components - Full support for HonoX async components in ctx.render()
  • Streaming Actions - Async generator handlers push incremental updates over WebSocket (skeleton → content, live progress, AI-style text)
  • React Islands - Mount pure React components into server-rendered pages; the server pushes props/state/events into them at any time and they call actions at any time, all over the same WebSocket
  • Dynamic Islands - Load island components from URLs at runtime (per-tenant compiled artifacts, plugin systems) with an explicit source allowlist and a single shared React instance via import map
  • Island Lifecycle & Resilience - Lazy mounting (beam-island-load="visible" | "idle"), crash fallback to the server-rendered placeholder with beam:island-error, and server-spawned/removed islands (ctx.island() upsert / ctx.removeIsland())
  • requestContext Access - Read the live Hono request context from Beam actions when per-call middleware is enabled

Installation

npm install @benqoder/beam

Create or Initialize an App

Beam ships its scaffolding in the same package as the framework.

Create a new HonoX + Beam + Wrangler app:

npx @benqoder/beam create my-app
cd my-app
npm install
npm run dev

Add Beam to an existing HonoX project:

npx @benqoder/beam init
npm install
npm run dev

init writes wrangler.json, updates package.json, adds missing starter files, and skips existing source files unless --force is provided.

Wrangler-First Development

Beam actions run over WebSocket RPC, so Cloudflare Workers apps should be developed through Wrangler rather than a standalone Vite dev server.

beam dev

One command runs the whole loop:

beam dev --port 8791    # extra args pass through to wrangler dev

It builds with dev refresh, then starts wrangler dev with cache-safe assets: dev builds emit a dist/_headers file serving everything with Cache-Control: no-store, so rebuilds are never masked by the browser's HTTP cache or miniflare's emulated edge cache — the class of "I rebuilt but the browser runs old code" problems is gone by construction. Production builds remove _headers automatically.

Dev refresh needs no wiring: in dev builds the Beam client auto-loads the refresh poller (statically eliminated from production bundles — zero bytes shipped). Edit a file → the wrangler build hook rebuilds → the page morphs in place, preserving focused inputs, beam-keep nodes, and mounted React islands. The manual <script src="/static/dev-refresh.js"> tag still works and is safe to keep — the poller is a singleton.

beam dev warns if your wrangler config lacks the rebuild hook. The Beam CLI also provides the underlying build command:

beam build

Error Visibility & Debugging

Dev error overlay. In dev builds, an action that throws on the server delivers its real details over the WebSocket — action name, message, and server stack — and Beam paints a full-screen overlay (dismiss with Esc). React island crashes (beam:island-error) appear in the same overlay. In production none of this ships: the overlay module is dead-code eliminated and the server sends an opaque failure with no stack.

Both failure kinds also dispatch a beam:action-error window event (detail: { action, message, stack? }) in every build, so you can wire your own reporting:

window.addEventListener('beam:action-error', (e) => {
  logToSentry(e.detail)
})

Call tracing. Toggle from the console (persisted in localStorage):

beam.debug(true)
// [beam] → addToCart { id: 42 }
// [beam] ← addToCart #1: state(cart) + json
// [beam] ✓ addToCart done in 38ms (1 chunk)
beam.debug(false)

Every action call logs its params, each response chunk (summarized by what it carries — html, state, islands, json, …), and the round-trip duration.

Add the production-safe build to Wrangler's hook so deploys cannot accidentally ship dev overlays. The beam dev command sets BEAM_BUILD_DEV=1 for Wrangler's watched local rebuilds.

{
  "main": "./dist/index.js",
  "assets": {
    "directory": "./dist"
  },
  "build": {
    "command": "npx --no-install beam build",
    "watch_dir": "app"
  }
}

With that configuration, beam dev runs a dev build before Miniflare starts and supplies the dev-build environment to watched rebuilds. wrangler deploy uses a production build. This keeps the Worker, assets, and WebSocket endpoint on the same origin while preventing dev error details from reaching production.

For development-only DOM refresh, add the dev refresh script when VITE_BEAM_DEV_REFRESH=1 is present:

{import.meta.env.VITE_BEAM_DEV_REFRESH === '1' && (
  <script type="module" src="/static/dev-refresh.js"></script>
)}

beam build --dev writes /__beam_dev.json and builds /static/dev-refresh.js. The browser polls that manifest; server-only changes fetch the current page and morph the matching root ([beam-dev-refresh-root], [beam-boost], or body) while preserving focused inputs and [beam-keep] nodes. Asset changes fall back to a full reload.

Quick Start

1. Add the Vite Plugin

// vite.config.ts
import { beamPlugin } from "@benqoder/beam/vite";

export default defineConfig({
  plugins: [
    beamPlugin({
      actions: "/app/actions/*.tsx",
    }),
  ],
});

2. Create the Beam Instance

// app/beam.ts
import { collectActions, createBeam } from "@benqoder/beam";
import type { Env } from "./types";

const actions = collectActions<Env>(
  import.meta.glob("/app/actions/*.tsx", { eager: true }),
);

export const beam = createBeam<Env>({ actions });

Existing applications that import virtual:beam or virtual:beam/islands continue to work unchanged. Those imports remain supported as compatibility shims; no migration command is required.

3. Initialize Beam Server

// app/server.ts
import { createApp } from "honox/server";
import { beam } from "./beam";

const app = createApp({
  init: beam.init,
});

export default app;

4. Add the Client Script

// app/client.ts
import "@benqoder/beam/client";

5. Create an Action

// app/actions/counter.tsx
export function increment(c) {
  const count = parseInt(c.req.query("count") || "0");
  return <div>Count: {count + 1}</div>;
}

6. Use in HTML

<div id="counter">Count: 0</div>
<button beam-action="increment" beam-target="#counter">Increment</button>

Core Concepts

Actions

Actions are server functions that return HTML. They're the primary way to handle user interactions.

// app/actions/demo.tsx
export function greet(c) {
  const name = c.req.query("name") || "World";
  return <div>Hello, {name}!</div>;
}
<button beam-action="greet" beam-data-name="Alice" beam-target="#greeting">
  Say Hello
</button>
<div id="greeting"></div>

Including Input Values

Use beam-include to collect values from input elements and include them in action params. Elements are found by beam-id, id, or name (in that priority order):

<!-- Define inputs with beam-id, id, or name -->
<input beam-id="name" type="text" value="Ben" />
<input id="email" type="email" value="[email protected]" />
<input name="age" type="number" value="30" />
<input beam-id="subscribe" type="checkbox" checked />

<!-- Button includes specific inputs -->
<button
  beam-action="saveUser"
  beam-include="name,email,age,subscribe"
  beam-data-source="form"
  beam-target="#result"
>
  Save
</button>

<div id="result"></div>

The action receives merged params with proper type conversion:

{
  "source": "form",
  "name": "Ben",
  "email": "[email protected]",
  "age": 30,
  "subscribe": true
}

Type conversion:

  • checkboxboolean (checked state)
  • number/rangenumber
  • All others → string

Modals

Two ways to open modals:

1. beam-modal attribute - Explicitly opens the action result in a modal, with optional placeholder:

<!-- Shows placeholder while loading, then replaces with action result -->
<button
  beam-modal="confirmDelete"
  beam-data-id="123"
  beam-size="small"
  beam-placeholder="<div>Loading...</div>"
>
  Delete Item
</button>

2. beam-action with ctx.modal() - Action decides to return a modal:

// app/actions/confirm.tsx
export function confirmDelete(
  ctx: BeamContext<Env>,
  { id }: Record<string, unknown>,
) {
  return ctx.modal(
    <div>
      <h2>Confirm Delete</h2>
      <p>Are you sure you want to delete item {id}?</p>
      <button beam-action="deleteItem" beam-data-id={id} beam-close>
        Delete
      </button>
      <button beam-close>Cancel</button>
    </div>,
    { size: "small" },
  );
}

ctx.modal() accepts JSX directly - no wrapper function needed. Options: size ('small' | 'medium' | 'large'), spacing (padding in pixels).

<button beam-action="confirmDelete" beam-data-id="123">Delete Item</button>

Drawers

Two ways to open drawers:

1. beam-drawer attribute - Explicitly opens in a drawer:

<button
  beam-drawer="openCart"
  beam-position="right"
  beam-size="medium"
  beam-placeholder="<div>Loading cart...</div>"
>
  Open Cart
</button>

2. beam-action with ctx.drawer() - Action returns a drawer:

// app/actions/cart.tsx
export function openCart(ctx: BeamContext<Env>) {
  return ctx.drawer(
    <div>
      <h2>Shopping Cart</h2>
      <div class="cart-items">{/* Cart contents */}</div>
      <button beam-close>Close</button>
    </div>,
    { position: "right", size: "medium" },
  );
}

ctx.drawer() accepts JSX directly. Options: position ('left' | 'right'), size ('small' | 'medium' | 'large'), spacing (padding in pixels).

<button beam-action="openCart">Open Cart</button>

Multi-Render Array API

Update multiple targets in a single action response using ctx.render() with arrays:

1. Explicit targets (comma-separated)

export function refreshDashboard(ctx: BeamContext<Env>) {
  return ctx.render(
    [
      <div class="stat-card">Visits: {visits}</div>,
      <div class="stat-card">Users: {users}</div>,
      <div class="stat-card">Revenue: ${revenue}</div>,
    ],
    { target: "#stats, #users, #revenue" },
  );
}

2. Auto-detect by beam-id / beam-item-id (no targets needed)

export function refreshDashboard(ctx: BeamContext<Env>) {
  // Client automatically finds elements by stable identity
  return ctx.render([
    <div beam-id="stats">Visits: {visits}</div>,
    <div beam-id="users">Users: {users}</div>,
    <div beam-id="revenue">Revenue: ${revenue}</div>,
  ]);
}

3. Mixed approach

export function updateDashboard(ctx: BeamContext<Env>) {
  return ctx.render(
    [
      <div>Header content</div>, // Uses explicit target
      <div beam-id="content">Main content</div>, // Auto-detected by beam-id
    ],
    { target: "#header" }, // Only first item gets explicit target
  );
}

Target Resolution Order:

  1. Explicit target from comma-separated list (by index)
  2. Identity from the HTML fragment's root element (beam-id or beam-item-id)
  3. Frontend fallback (beam-target on the triggering element)
  4. Skip if no target found

Notes:

  • beam-target accepts any valid CSS selector (e.g. #id, .class, [attr=value]). Using #id targets is still fully supported.
  • Auto-targeting (step 2) uses stable element identity from the returned HTML root: beam-id, beam-item-id, or id.
  • Auto-targeting intentionally does not use input name values, because they are often not unique enough for safe DOM replacement.
  • Prefer beam-id for named UI regions and beam-item-id for repeated/list items. Use plain id when that already matches your markup structure.
  • When an explicit target is used and the server returns a single root element that has the same beam-id/beam-item-id as the target, Beam unwraps it and swaps only the target’s inner content. This prevents accidentally nesting the component inside itself.

Exclusion: Use !selector to explicitly skip an item:

ctx.render(
  [<Box1 />, <Box2 />, <Box3 />],
  { target: "#a, !#skip, #c" }, // Box2 is skipped
);

Async Components

ctx.render() fully supports HonoX async components:

// Async component that fetches data
async function UserCard({ userId }: { userId: string }) {
  const user = await db.getUser(userId); // Async data fetch
  return (
    <div class="user-card">
      <h3>{user.name}</h3>
      <p>{user.email}</p>
    </div>
  );
}

// Use directly in ctx.render() - no wrapper needed
export function loadUser(
  ctx: BeamContext<Env>,
  { id }: Record<string, unknown>,
) {
  return ctx.render(<UserCard userId={id as string} />, { target: "#user" });
}

// Works with arrays too
export function loadUsers(ctx: BeamContext<Env>) {
  return ctx.render(
    [<UserCard userId="1" />, <UserCard userId="2" />, <UserCard userId="3" />],
    { target: "#user1, #user2, #user3" },
  );
}

// Mixed sync and async
export function loadDashboard(ctx: BeamContext<Env>) {
  return ctx.render([
    <div>Static header</div>, // Sync
    <UserCard userId="current" />, // Async
    <StatsWidget />, // Async
  ]);
}

Async components are awaited automatically - no manual Promise.resolve() or helper functions needed.


Streaming Actions

Turn any action into a streaming action by making it an async generator (async function*). Each yield pushes an update to the browser immediately — no waiting for the full response.

export async function* loadProfile(ctx: BeamContext<Env>, { id }: Record<string, unknown>) {
  // First yield: show skeleton immediately
  yield ctx.render(<div id="profile">Loading…</div>)

  // Simulate slow API call
  await delay(1800)

  const user = await db.getUser(id as string)

  // Second yield: replace with real content
  yield ctx.render(
    <div id="profile">
      <h3>{user.name}</h3>
      <p>{user.role}</p>
    </div>
  )
}

Use it in HTML exactly like a regular action — no special attribute needed:

<button beam-action="loadProfile" beam-include="id">Load Profile</button>
<div id="profile"></div>

Patterns

Skeleton → Content — yield a skeleton immediately so the user sees feedback, then yield the real content once the data is ready:

export async function* loadProfile(ctx: BeamContext<Env>, { id }: Record<string, unknown>) {
  yield ctx.render(<SkeletonCard id="result" />)
  const user = await fetchUser(id)
  yield ctx.render(<UserCard id="result" user={user} />)
}

Multi-step Progress — yield after each step completes to show a live progress list:

export async function* runPipeline(ctx: BeamContext<Env>, _: Record<string, unknown>) {
  const steps = ['Validating', 'Fetching', 'Processing', 'Saving']
  const done: string[] = []

  for (const step of steps) {
    yield ctx.render(<Pipeline done={done} current={step} pending={steps.slice(done.length + 1)} id="pipeline" />)
    await runStep(step)
    done.push(step)
  }

  yield ctx.render(<Pipeline done={done} complete id="pipeline" />)
}

AI-style Text Streaming — accumulate text and re-render on each chunk for a typewriter effect:

export async function* streamText(ctx: BeamContext<Env>, _: Record<string, unknown>) {
  let text = ''
  for (const word of words) {
    text += (text ? ' ' : '') + word
    await delay(120)
    yield ctx.render(<p id="output">{text}<span class="cursor">▌</span></p>)
  }
  yield ctx.render(<p id="output">{text}</p>) // remove cursor
}

Streaming into Modals and Drawers — yield ctx.modal() or ctx.drawer() calls. The first yield opens the overlay; subsequent yields update its content:

export async function* openProfileModal(ctx: BeamContext<Env>, { id }: Record<string, unknown>) {
  yield ctx.modal(<SkeletonProfile />, { size: 'md' }) // opens modal with skeleton

  const user = await fetchUser(id)

  yield ctx.modal(<UserProfile user={user} />, { size: 'md' }) // swaps in real content
}
<button beam-modal="openProfileModal" beam-include="id">View Profile</button>

How It Works

  • A regular action handler returns a single value → one DOM update.
  • An async generator handler yields multiple values → one DOM update per yield, streamed over the WebSocket as each chunk is ready.
  • The client processes chunks in order; each chunk is a full ActionResponse (the same object a regular action returns).
  • No special HTML attributes are needed — the server detects async generators automatically.

React Islands

Mount pure React components into server-rendered pages — no SSR, no hydration, no "use client". Islands are first-class citizens of Beam's duplex WebSocket: the server can push data into them at any time, and they can call the server at any time.

@benqoder/beam/client includes the complete island mount lifecycle, but React is still loaded lazily: pages without islands download zero React bytes. Island components are code-split per file.

Setup

npm install react react-dom

The Vite plugin compiles matching island files with the React JSX runtime (the rest of your app stays on hono/jsx), and the client glob registers them lazily:

// vite.config.ts
beamPlugin({
  actions: '/app/actions/*.tsx',
  islands: '/app/islands/*.tsx', // default; set false to disable
})

Register local component files in your client entry. You do not need a second side-effect import to activate mounting—the main Beam client already does that:

// app/client.ts
import '@benqoder/beam/client'
import { registerIslands } from '@benqoder/beam/islands'

registerIslands(import.meta.glob('/app/islands/*.tsx'))

For editor type-checking, give the islands directory its own tsconfig:

// app/islands/tsconfig.json
{
  "extends": "../../tsconfig.json",
  "compilerOptions": { "jsxImportSource": "react" },
  "include": ["./**/*"]
}

Writing an Island

// app/islands/Counter.tsx — plain React, hooks from '@benqoder/beam/react'
import { useState } from 'react'
import { useBeamAction } from '@benqoder/beam/react'

export default function Counter({ initial = 0, serverMessage }: { initial?: number; serverMessage?: string }) {
  const [count, setCount] = useState(initial)
  const { call: sync, loading } = useBeamAction('syncCounter')

  return (
    <div>
      <button onClick={() => setCount(count + 1)}>{count}</button>
      <button disabled={loading} onClick={() => sync({ value: count })}>Sync</button>
      {serverMessage && <p>{serverMessage}</p>}
    </div>
  )
}

Rendering an Island

Use the <Island> helper in any route or action (server-side, hono/jsx). Children are the placeholder until React mounts:

import { Island } from '@benqoder/beam'

<Island name="Counter" id="counter" props={{ initial: 5 }}>
  <div class="skeleton" />
</Island>

This renders <div beam-island="Counter" beam-id="counter" beam-props='{"initial":5}'> — you can also write the attributes by hand.

Server → Client (any time)

Update a mounted island's props — it re-renders without remounting, keeping its local React state:

export function syncCounter(ctx: BeamContext<Env>, { value }: Record<string, unknown>) {
  return ctx.island('counter', { initial: Number(value), serverMessage: 'Saved!' })
}

Stream props and events from a generator — one push per yield over the WebSocket:

export async function* streamTicker(ctx: BeamContext<Env>) {
  for (let tick = 1; tick <= 15; tick++) {
    yield ctx.island('stockTicker', { price: await nextPrice(), tick })
    await delay(350)
  }
  yield ctx.event('ticker:done', { message: 'Stream complete' })
}
  • ctx.island(id, props) / ctx.island({ id1: props1, id2: props2 }) — update island props by beam-id
  • ctx.island(id, props, options)upsert: creates the island when it isn't on the page yet — options: { component, src?, target, swap?: 'append' | 'prepend' | 'replace', load? }
  • ctx.removeIsland(...ids) — unmount and delete islands from the page
  • ctx.event(name, data) — push a named event (received by useBeamEvent and beam:server-event listeners)
  • ctx.state(id, value) — update shared reactive state (React subscribes via useBeamState)
  • await ctx.notify?.(name, data) — fire-and-forget push outside the response stream (live WebSocket sessions)
// Spawn an island anywhere, no HTML sent; remove it later
export function spawnRecs(ctx: BeamContext<Env>) {
  return ctx.island('recs', { items }, { component: 'RecRail', target: '#below-cart' })
}
export function dismissRecs(ctx: BeamContext<Env>) {
  return ctx.removeIsland('recs')
}

Client → Server (any time)

const { call, loading, error, data } = useBeamAction('addToCart')
// call({ id: 42 }) — streaming chunks are applied as they arrive;
// resolves with the final ActionResponse

Or outside components: callBeamAction('addToCart', { id: 42 }).

Typed Actions

The Vite plugin generates a typed-action registry (app/beam-actions.d.ts by default, next to your actions directory) mapping your action modules into Beam's types. With it, action names, params, and ctx.json payloads are inferred at every call siteuseBeamAction, callBeamAction, and window.beam.*:

// app/actions/cart.tsx — type the data param, types flow everywhere
export function addToCart(ctx: BeamContext<Env>, { productId, qty }: { productId: number; qty?: number }) {
  ...
  return ctx.json({ ok: true, count: cart.items.length })
}
// in an island — all inferred from the registry:
const { call, json } = useBeamAction('addToCart')  // name validated, typo = compile error
call({ productId: 42 })                             // params typed
json?.count                                         // ctx.json payload typed

Zero runtime cost — it's a generated .d.ts that augments BeamRegisteredActionModules on @benqoder/beam. It regenerates on build and when action files are added/removed in dev. Configure with the plugin's actionTypes option (a root-relative path, or false to disable). Untyped handlers (data: Record<string, unknown>) stay permissive; without the generated file everything falls back to the classic string-based API. If your app/islands/ has its own tsconfig, add "../beam-actions.d.ts" to its include for typed actions inside islands. Commit the generated file (or gitignore it — it rebuilds deterministically).

Plain request/response with ctx.json() — when an island fetches data for itself (search, pagination, chart data), return JSON that is private to the caller: no DOM update, no state, no events.

// server
export async function searchProducts(ctx: BeamContext<Env>, { q }: Record<string, unknown>) {
  return ctx.json(await db.search(q as string))
}

// island
const { call: search, json: results } = useBeamAction<Product[]>('searchProducts')
// declarative: `results` updates after each call — or imperative:
const products = (await search({ q })).json as Product[]

One response can carry data and side effects — { ...ctx.island('cart', cart), ...ctx.json(cart) } updates the page and answers the caller. In streaming actions, yield ctx.json last: the caller resolves with the final chunk.

Hooks

| Hook | Purpose | | ------------------------------- | ------------------------------------------------------------------------------------------------ | | useBeamAction(name, options?) | Call any action over the WebSocket; { call, loading, error, data, json } | | useBeamState(id, initial?) | Bind a named reactive state into React — two-way: mutate the proxy and beam-text bindings update | | useBeamEvent(name, handler) | Subscribe to server-pushed events (ctx.event / ctx.notify) | | useBeamConnection() | { connected, online } WebSocket status |

useBeamState shares state with the attribute world — a React island and a beam-state div with the same beam-id read and write the same reactive object:

// In the island
const shared = useBeamState('cart', { clicks: 0 })
<button onClick={() => { shared.clicks++ }}>Add</button>
<!-- Anywhere on the page, no React -->
<div beam-state="clicks: 0" beam-id="cart">
  Clicked <strong beam-text="clicks"></strong> times
</div>

State semantics: object states shallow-merge on server updates (ctx.state('cart', { count: 3 }) keeps cart.total), but nested object values replace wholesale — keep states flat-ish. Declare every key you need in initial: the initial shape is the subscription contract, and whichever side (island or beam-state element) initializes an id first creates the state, so give both the same shape.

Picking the Right Channel

| You're sending… | Use | Because | | -------------------------------------------------- | ---------------------- | ---------------------------------------------- | | Shared app data (cart, session, inventory) | ctx.state(id, value) | Broadcast to every subscriber, merges, survives swaps | | One component's inputs | ctx.island(id, props)| Scoped props semantics, remount-safe | | A private answer to whoever called | ctx.json(value) | Zero side effects anywhere else on the page | | A moment-in-time notification | ctx.event(name, data)| Ephemeral, no storage, any listener can react | | A rendered region | ctx.render(<Jsx />) | HTML over the wire, the classic Beam path |

Mount Strategies & Crash Resilience

Control when an island mounts with beam-island-load (or load on <Island>):

| Strategy | Behavior | | --------- | ----------------------------------------------------------------------------------------- | | eager | Mount as soon as the marker is seen (default) | | visible | Mount when scrolled near the viewport (100px margin) — a grid of 30 product-card islands creates zero React roots until the shopper reaches them | | idle | Mount when the main thread is free (requestIdleCallback) |

<Island name="ProductCard" id={`card-${p.id}`} load="visible" props={{ product: p }}>
  {/* the server-rendered card shows instantly; React attaches on approach */}
</Island>

Props pushed to a pending island land in beam-props and are used when it eventually mounts — nothing is lost while deferred.

Crashes fall back to the server HTML. The placeholder is captured before mount; if the module fails to load or the component throws during render/commit, Beam unmounts the root, restores the server-rendered placeholder, and dispatches a beam:island-error window event (detail: { name, src, phase: 'load' | 'render', error, element }). A broken tenant artifact degrades to a static-but-fine card instead of a blank hole; the failed element is not retried until a swap replaces it.

window.addEventListener('beam:island-error', (e) => {
  logToPlatform('island-crash', { name: e.detail.name, src: e.detail.src, phase: e.detail.phase })
})

Islands and DOM Swaps

Islands survive server swaps by default (like beam-keep): when an action re-renders a region containing a mounted island, the live element is preserved, fresh beam-props from the server are adopted, and the root re-renders — local React state (inputs, toggles, timers) is kept. Requirements and options:

  • Give islands a stable beam-id — it's the identity used for preservation and for ctx.island(id, ...) targeting
  • Add beam-island-remount (or remount on <Island>) to tear down and remount on swaps instead
  • Renaming the island (different beam-island value at the same identity) always remounts

Islands mounted inside modals, drawers, streamed fragments, and Beam visits all work — one MutationObserver covers every update path, and roots are unmounted automatically when their element leaves the DOM.

Dynamic Islands (runtime component sources)

Islands normally come from the build-time registry (app/islands/). Dynamic islands load their component module from a URL at runtime instead — for platforms that compile components from other sources (per-tenant artifacts in R2, a plugin system, a component marketplace) after the app is deployed.

1. Allow source prefixes at runtime (off by default — a source URL is executable code, so every prefix is a trust decision):

// vite.config.ts
beamPlugin({
  islandSources: ['/islands/'], // legacy virtual-module allowlist
})
// app/client.ts
import { allowIslandSources } from '@benqoder/beam/islands'

allowIslandSources(['/islands/'])

Instead of allowIslandSources([...]), the runtime allowlist can come from a <meta name="beam-island-sources" content="/islands/"> tag.

2. Add the import map to your document head (before any module scripts):

// in your renderer/layout <head>
import { raw } from 'hono/html'
import { beamIslandImportMap } from '@benqoder/beam'

{raw(beamIslandImportMap())}

Every island-capable client build emits shared files with stable names (static/beam-shared/react.js, react-jsx-runtime.js, react-dom-client.js, beam-react.js). The import map resolves bare react / @benqoder/beam/react imports in remote modules to these files. Beam detects that map and loads the remote island's renderer through it too, so the component and renderer always use the same React instance—even when a normal app bundler has also bundled React for local islands.

extra entries turn the import map into a shared-component registry: island modules import UI by bare name, and the map pins versions per deploy/tenant — repointing one entry updates every island that uses it, no recompiles:

{raw(beamIslandImportMap({ extra: { '@ui/button': '/islands/ui/[email protected]' } }))}
// inside any island module
import Button from '@ui/button'

3. Serve the component as an ES module — default export, compiled with react, react/jsx-runtime, react-dom/client, and @benqoder/beam/react left as bare imports (externals):

// served at /islands/ProductCard.js — e.g. compiled from TSX by your platform
import { useState } from 'react'
import { jsx, jsxs } from 'react/jsx-runtime'
import { useBeamAction } from '@benqoder/beam/react'

export default function ProductCard({ product }) { /* ... */ }

4. Point the island at it:

<Island name="ProductCard" id={`card-${p.id}`} src={`/islands/product-card@${hash}.js`} props={{ product: p }}>
  {/* server-rendered card = the placeholder until the module mounts */}
</Island>

Notes:

  • beam-island-src wins over a registered component of the same name, so runtime artifacts can override built-in defaults
  • Modules are cached per URL — use content-hashed file names so updates bust caches and unchanged components load instantly
  • Disallowed sources are refused loudly (console error, no import); a failed load leaves the placeholder in place
  • A missing shared-React import map produces a direct Beam warning, and an invalid-hook/dual-React failure becomes a named BeamIslandReactConfigurationError with remediation instead of a raw null.useState exception
  • Everything else works identically to registered islands: ctx.island() props pushes, swap preservation, hooks, unmounting

Attribute Reference

Actions

| Attribute | Description | Example | | --------------------- | ------------------------------------------------------------- | ------------------------------------------- | | beam-action | Action name to call | beam-action="increment" | | beam-target | CSS selector for where to render response | beam-target="#counter" | | beam-data-* | Pass data to the action | beam-data-id="123" | | beam-include | Include values from inputs by beam-id, id, or name | beam-include="name,email,age" | | beam-swap | How to swap content: replace, append, prepend, delete | beam-swap="replace" | | beam-confirm | Show confirmation dialog before action | beam-confirm="Delete this item?" | | beam-confirm-prompt | Require typing text to confirm | beam-confirm-prompt="Type DELETE\|DELETE" | | beam-instant | Trigger on mousedown instead of click | beam-instant | | beam-disable | Disable element(s) during request | beam-disable or beam-disable="#btn" | | beam-placeholder | Show placeholder in target while loading | beam-placeholder="<p>Loading...</p>" | | beam-push | Push URL to browser history after action | beam-push="/new-url" | | beam-replace | Replace current URL in history | beam-replace="?page=2" |

Swap notes:

  • replace replaces target.innerHTML (no DOM diff), then tries to preserve UX:
    • Keeps focused input caret/selection when possible.
    • Reinserts elements marked with beam-keep (matched by beam-id, beam-item-id, id, or input name).
    • If Alpine.js is present on the page, initializes any newly inserted DOM (Alpine.initTree).

Swap transitions (optional):

Add beam-swap-transition on the target element to animate after swaps:

<div id="results" beam-swap-transition="fade"></div>

Presets: fade, scale, zoom, pop, blur, slide, slide-up, slide-down, slide-left, slide-right, flip-x, flip-y. Tune per element with CSS variables: style="--beam-swap-duration: 300ms; --beam-swap-ease: ease-in-out". See the Animations section for view transitions, enter/leave classes, and staggering.

Modals & Drawers

| Attribute | Description | Example | | ------------------ | ------------------------------------------------- | -------------------------------------- | | beam-modal | Action to call and display result in modal | beam-modal="editUser" | | beam-drawer | Action to call and display result in drawer | beam-drawer="openCart" | | beam-size | Size for modal/drawer: small, medium, large | beam-size="large" | | beam-position | Drawer position: left, right | beam-position="left" | | beam-placeholder | HTML to show while loading | beam-placeholder="<p>Loading...</p>" | | beam-close | Close the current modal/drawer when clicked | beam-close |

Modals and drawers can also be returned from beam-action using context helpers:

// Modal with options
return ctx.modal(render(<MyModal />), { size: "large", spacing: 20 });

// Drawer with options
return ctx.drawer(render(<MyDrawer />), { position: "left", size: "medium" });

Forms

| Attribute | Description | Example | | ------------- | -------------------------------------- | ------------------------------- | | beam-action | Action to call on submit (on <form>) | <form beam-action="saveUser"> | | beam-reset | Reset form after successful submit | beam-reset |

Loading States

| Attribute | Description | Example | | ---------------------- | ------------------------------------ | --------------------------------- | | beam-loading-for | Show element while action is loading | beam-loading-for="saveUser" | | beam-loading-for="*" | Show for any loading action | beam-loading-for="*" | | beam-loading-data-* | Match specific parameters | beam-loading-data-id="123" | | beam-loading-class | Add class while loading | beam-loading-class="opacity-50" | | beam-loading-remove | Hide element while NOT loading | beam-loading-remove |

Validation

| Attribute | Description | Example | | --------------- | ---------------------------------------------- | ------------------------------ | | beam-validate | Target selector to update with validation | beam-validate="#email-error" | | beam-watch | Event to trigger validation: input, change | beam-watch="input" | | beam-debounce | Debounce delay in milliseconds | beam-debounce="300" |

Input Watchers

| Attribute | Description | Example | | -------------------- | ----------------------------------------------------------- | ----------------------------------- | | beam-watch | Event to trigger action: input, change | beam-watch="input" | | beam-debounce | Debounce delay in milliseconds | beam-debounce="300" | | beam-throttle | Throttle interval in milliseconds (alternative to debounce) | beam-throttle="100" | | beam-watch-if | Condition that must be true to trigger | beam-watch-if="value.length >= 3" | | beam-cast | Cast input value: number, integer, boolean, trim | beam-cast="number" | | beam-loading-class | Add class to input while request is in progress | beam-loading-class="loading" | | beam-keep | Prevent element from being replaced during updates | beam-keep |

Dirty Form Tracking

| Attribute | Description | Example | | ---------------------- | --------------------------------------------- | --------------------------------- | | beam-dirty-track | Enable dirty tracking on a form | <form beam-dirty-track> | | beam-dirty-indicator | Show element when form is dirty | beam-dirty-indicator="#my-form" | | beam-dirty-class | Toggle class instead of visibility | beam-dirty-class="has-changes" | | beam-warn-unsaved | Warn before leaving page with unsaved changes | <form beam-warn-unsaved> | | beam-revert | Button to revert form to original values | beam-revert="#my-form" | | beam-show-if-dirty | Show element when form is dirty | beam-show-if-dirty="#my-form" | | beam-hide-if-dirty | Hide element when form is dirty | beam-hide-if-dirty="#my-form" |

Conditional Form Fields

| Attribute | Description | Example | | ------------------ | ------------------------------------------ | ------------------------------------------ | | beam-enable-if | Enable field when condition is true | beam-enable-if="#subscribe:checked" | | beam-disable-if | Disable field when condition is true | beam-disable-if="#country[value='']" | | beam-visible-if | Show field when condition is true | beam-visible-if="#source[value='other']" | | beam-hidden-if | Hide field when condition is true | beam-hidden-if="#premium:checked" | | beam-required-if | Make field required when condition is true | beam-required-if="#business:checked" |

Deferred Loading

| Attribute | Description | Example | | ------------- | ----------------------------------------- | ---------------------------- | | beam-defer | Load content when element enters viewport | beam-defer | | beam-action | Action to call (used with beam-defer) | beam-action="loadComments" |

Polling

| Attribute | Description | Example | | --------------- | ------------------------------ | ------------------------- | | beam-poll | Enable polling on this element | beam-poll | | beam-interval | Poll interval in milliseconds | beam-interval="5000" | | beam-action | Action to call on each poll | beam-action="getStatus" |

Hungry Elements

| Attribute | Description | Example | | ------------- | -------------------------------------------------- | ------------- | | beam-hungry | Auto-update when any response contains matching ID | beam-hungry |

Out-of-Band Updates

| Attribute | Description | Example | | ------------ | ----------------------------------------------- | ------------------------------- | | beam-touch | Update additional elements (on server response) | beam-touch="#sidebar,#footer" |

Optimistic UI

| Attribute | Description | Example | | ----------------- | ------------------------------------------------- | ---------------------------------------- | | beam-optimistic | Immediately update with this HTML before response | beam-optimistic="<div>Saving...</div>" |

Preloading & Caching

| Attribute | Description | Example | | -------------- | ---------------------------------- | ---------------------- | | beam-preload | Preload on hover: hover, mount | beam-preload="hover" | | beam-cache | Cache duration in seconds | beam-cache="60" |

Infinite Scroll

| Attribute | Description | Example | | ---------------- | ---------------------------------------------------------- | ------------------------ | | beam-infinite | Load more when scrolled near bottom (auto-trigger) | beam-infinite | | beam-load-more | Load more on click (manual trigger) | beam-load-more | | beam-action | Action to call for next page | beam-action="loadMore" | | beam-item-id | Unique ID for list items (deduplication + fresh data sync) | beam-item-id={item.id} |

Navigation Feedback

| Attribute | Description | Example | | ---------------- | ----------------------------------------------------------- | ---------------- | | beam-nav | Mark container as navigation (children get .beam-current) | <nav beam-nav> | | beam-nav-exact | Only match exact URL paths | beam-nav-exact |

Offline Detection

| Attribute | Description | Example | | ---------------------- | ------------------------------------------- | -------------------------------------- | | beam-offline | Show element when offline, hide when online | beam-offline | | beam-offline-class | Toggle class instead of visibility | beam-offline-class="offline-warning" | | beam-offline-disable | Disable element when offline | beam-offline-disable |

Auto-Reconnect

| Element / Attribute | Description | | ---------------------------------------------------------------- | ------------------------------------------------- | | <meta name="beam-reconnect-interval" content="5000"> | Fixed retry interval (ms) after initial backoff | | beam-disconnected | Show element while disconnected, hide on reconnect | | <meta name="beam-auto-connect" content="true"> | Explicitly opt into eager Beam connection |

Conditional Show/Hide

| Attribute | Description | Example | | ------------------ | -------------------------------------------- | -------------------------- | | beam-switch | Watch this field and control target elements | beam-switch=".options" | | beam-show-for | Show when switch value matches | beam-show-for="premium" | | beam-hide-for | Hide when switch value matches | beam-hide-for="free" | | beam-enable-for | Enable when switch value matches | beam-enable-for="admin" | | beam-disable-for | Disable when switch value matches | beam-disable-for="guest" |

Auto-submit Forms

| Attribute | Description | Example | | ----------------- | ---------------------------------- | --------------------- | | beam-autosubmit | Submit form when any field changes | beam-autosubmit | | beam-debounce | Debounce delay in milliseconds | beam-debounce="300" |

Boost Links

| Attribute | Description | Example | | ---------------- | --------------------------------------------------------------- | ------------------------ | | beam-boost | Upgrade descendant same-origin links into Beam SSR visits | <main beam-boost> | | beam-boost-off | Exclude specific links from Beam visits | beam-boost-off | | beam-visit | Explicitly mark a link as a Beam visit | <a beam-visit href> | | beam-patch | Visit with patch-style scroll semantics | <a beam-patch href> | | beam-navigate | Visit with navigation-style scroll semantics | <a beam-navigate href> |

Keep Elements

| Attribute | Description | Example | | ----------- | ----------------------------------- | ------------------- | | beam-keep | Preserve element during DOM updates | <video beam-keep> |

Animations

| Attribute | Description | Example | | ---------------------- | -------------------------------------------------------- | ---------------------------------- | | beam-transition | Run this target's swaps in a native view transition | beam-transition="view" | | beam-transition-name | Shared-element morph name across updates/visits | beam-transition-name="product-1" | | beam-swap-transition | Preset animation on swap (12 presets) | beam-swap-transition="pop" | | beam-enter[-start/-end] | Class triplet applied when content is inserted | beam-enter-start="opacity-0" | | beam-leave[-start/-end] | Class triplet run before removal (delete/removeIsland) | beam-leave-end="opacity-0" | | beam-enter-stagger | Per-child delay in ms for entering children | beam-enter-stagger="80" |

React Islands

| Attribute | Description | Example | | --------------------- | ------------------------------------------------------ | --------------------------- | | beam-island | React component to mount (registered name) | beam-island="Chart" | | beam-props | JSON props for the component | beam-props='{"series":[]}'| | beam-id | Island identity for ctx.island() and preservation | beam-id="salesChart" | | beam-island-src | Load the component module from a URL at runtime | beam-island-src="/islands/[email protected]" | | beam-island-load | Mount strategy: eager (default), visible, idle | beam-island-load="visible" | | beam-island-remount | Remount on swaps instead of preserving React state | beam-island-remount |

Client-Side Reactive State (No Server Round-Trip)

Beam supports a single client-side UI model: reactive state + declarative bindings. Use beam-state to define state, beam-show / beam-class / beam-text / beam-attr-* to bind UI, and beam-click / beam-model / beam-state-toggle to mutate state.

Reactive State

Fine-grained reactivity for UI components (carousels, tabs, accordions) without server round-trips.

| Attribute | Description | Example | | ------------------- | --------------------------------------------------------- | ---------------------------------- | | beam-state | Declare reactive state (JSON, key-value, or simple value) | beam-state="tab: 0; total: 5" | | beam-id | Name the state for cross-component access | beam-id="cart" | | beam-state-ref | Reference a named state from elsewhere | beam-state-ref="cart" | | beam-init | Run JS expression once after state is initialized | beam-init="setInterval(() => { index = (index+1) % total }, 3000)" | | beam-cloak | Hide element until its reactive scope is ready | <div beam-state="open: false" beam-cloak> | | beam-text | Bind text content to expression | beam-text="count" | | beam-attr-* | Bind any attribute to expression | beam-attr-disabled="count === 0" | | beam-show | Show/hide element based on expression | beam-show="open" | | beam-class | Toggle classes (simplified or JSON syntax) | beam-class="active: tab === 0" | | beam-click | Click handler that mutates state | beam-click="open = !open" | | beam-state-toggle | Toggle (or set) a state property (sugar) | beam-state-toggle="open" | | beam-model | Two-way binding for inputs | beam-model="firstName" |


Swap Modes

Control how content is inserted into the target element:

| Mode | Description | | --------- | ------------------------------------------------------------------------------------------- | | replace | Replace target HTML (default) while preserving focus and beam-keep elements when possible | | append | Add to the end of target | | prepend | Add to the beginning of target | | delete | Remove the target element |

<!-- Append new items to a list -->
<button beam-action="addItem" beam-swap="append" beam-target="#items">
  Add Item
</button>

<!-- Replace update -->
<button beam-action="refresh" beam-swap="replace" beam-target="#content">
  Refresh
</button>

Animations

Beam's animation system is layered and dependency-free, and every layer respects prefers-reduced-motion automatically.

View Transitions (native)

Put beam-transition="view" on any swap target and its server-driven updates run inside document.startViewTransition — a native cross-fade, no library:

<div id="results" beam-transition="view">…</div>

<!-- put it on your boost shell and Beam visits animate page navigation -->
<main beam-boost beam-transition="view">…</main>

Shared-element morphs — give the same element a beam-transition-name on both sides of an update and the browser animates it between positions (the product-card-flies-into-detail-page effect):

<!-- grid page -->
<img beam-transition-name="product-42" src="…" />
<!-- detail page: same name → the image morphs across the visit -->
<img beam-transition-name="product-42" src="…" />

Unsupported browsers fall back to an instant swap.

Swap Presets

beam-swap-transition on the target animates each swap. Presets: fade, scale, zoom, pop (springy overshoot), blur, slide, slide-up, slide-down, slide-left, slide-right, flip-x, flip-y.

<div id="cart" beam-swap-transition="pop" style="--beam-swap-duration: 250ms">…</div>

Enter/Leave Classes (Tailwind-friendly)

Alpine/Vue-style class triplets on the content itself — full designer control, pure markup (works inside server-rendered fragments, streamed chunks, modals, drawers, and spawned islands):

<div beam-enter="transition duration-300 ease-out"
     beam-enter-start="opacity-0 translate-y-2"
     beam-enter-end="opacity-100 translate-y-0">
  I animate in whenever the server inserts me
</div>

beam-leave / beam-leave-start / beam-leave-end run before removal — on beam-swap="delete" targets and ctx.removeIsland() — and the element is removed when the transition settles.

Staggeringbeam-enter-stagger="80" on a parent delays each entering child by 80ms more than the last. Perfect for product grids and lists:

<div id="grid" beam-enter-stagger="60">
  <!-- server renders items with beam-enter classes; they cascade in -->
</div>

Beyond these layers (springs, scroll-driven, gestures), islands can use any React animation library, and Motion / auto-animate pair well with the attribute world — Beam deliberately doesn't bundle one.


Loading States

Global Loading

Show a loader for any action:

<div beam-loading-for="*" class="spinner">Loading...</div>

Per-Action Loading

Show a loader only for specific actions:

<button beam-action="save">
  Save
  <span beam-loading-for="save" class="spinner"></span>
</button>

Parameter Matching

Show loading state only when parameters match:

<div class="item">
  <span>Item 1</span>
  <span beam-loading-for="deleteItem" beam-loading-data-id="1">
    Deleting...
  </span>
  <button beam-action="deleteItem" beam-data-id="1">Delete</button>
</div>

Loading Classes

Toggle a class instead of showing/hiding:

<div beam-loading-for="save" beam-loading-class="opacity-50">
  Content that fades while saving
</div>

Real-time Validation

Validate form fields as the user types:

<form beam-action="submitForm" beam-target="#result">
  <input
    name="email"
    beam-validate="#email-error"
    beam-watch="input"
    beam-debounce="300"
  />
  <div id="email-error"></div>

  <button type="submit">Submit</button>
</form>

The action receives _validate parameter indicating which field triggered validation:

export function submitForm(c) {
  const data = await c.req.parseBody();
  const validateField = data._validate;

  if (validateField === "email") {
    // Return just the validation feedback
    if (data.email === "[email protected]") {
      return <div class="error">Email already taken</div>;
    }
    return <div class="success">Email available</div>;
  }

  // Full form submission
  return <div>Form submitted!</div>;
}

Input Watchers

Trigger actions on input events without forms. Great for live search, auto-save, and real-time updates.

Basic Usage

<!-- Live search with debounce -->
<input
  name="q"
  placeholder="Search..."
  beam-action="search"
  beam-target="#results"
  beam-watch="input"
  beam-debounce="300"
/>
<div id="results"></div>

Throttle vs Debounce

Use beam-throttle for real-time updates (like range sliders) where you want periodic updates:

<!-- Range slider with throttle - updates every 100ms while dragging -->
<input
  type="range"
  name="price"
  beam-action="updatePrice"
  beam-target="#price-display"
  beam-watch="input"
  beam-throttle="100"
/>

<!-- Search with debounce - waits 300ms after user stops typing -->
<input
  name="q"
  beam-action="search"
  beam-target="#results"
  beam-watch="input"
  beam-debounce="300"
/>

Conditional Triggers

Only trigger action when a condition is met:

<!-- Only search when 3+ characters are typed -->
<input
  name="q"
  placeholder="Type 3+ chars to search..."
  beam-action="search"
  beam-target="#results"
  beam-watch="input"
  beam-watch-if="value.length >= 3"
  beam-debounce="300"
/>

The condition has access to value (current input value) and this (the element).

Type Casting

Cast input values before sending to the server:

<!-- Send as number instead of string -->
<input
  type="range"
  name="quantity"
  beam-action="updateQuantity"
  beam-cast="number"
  beam-watch="input"
/>

Cast types:

  • number - Parse as float
  • integer - Parse as integer
  • boolean - Convert "true"/"1"/"yes" to true
  • trim - Trim whitespace

Loading Feedback

Add a class to the input while the request is in progress:

<input
  name="q"
  placeholder="Search..."
  beam-action="search"
  beam-target="#results"
  beam-watch="input"
  beam-loading-class="input-loading"
/>

<style>
  .input-loading {
    border-color: blue;
    animation: pulse 1s infinite;
  }
</style>

Preventing Element Replacement

Use beam-keep to prevent an element from being replaced during updates. This keeps the element exactly as-is, preserving its state (focus, value, etc.):

<input
  name="bio"
  beam-action="validateBio"
  beam-target="#bio-feedback"
  beam-watch="input"
  beam-keep
/>

Since the input isn't replaced, focus and cursor position are naturally preserved.

Beam matches kept elements by stable identity in this order:

  • beam-id
  • beam-item-id
  • id
  • unique form control name as a best-effort fallback

For the most reliable behavior, give kept elements a beam-id, beam-item-id, or id. If multiple inputs share the same name, Beam will not preserve by name alone.

Use cases:

  • beam-id: shared UI regions like badges, panels, counters, and named state scopes
  • beam-item-id: repeated records in feeds, tables, carts, and paginated lists
  • id: one-off DOM anchors when the element already has a stable page-level ID

Auto-Save on Blur

Trigger action when the user leaves the field:

<input
  name="username"
  beam-action="saveField"
  beam-data-field="username"
  beam-target="#save-status"
  beam-watch="change"
  beam-keep
/>
<div id="save-status">Not saved yet</div>

Dirty Form Tracking

Track form changes and warn users before losing unsaved work.

Basic Usage

<form id="profile-form" beam-dirty-track>
  <input name="username" value="johndoe" />
  <input name="email" value="[email protected]" />
  <button type="submit">Save</button>
</form>

The form gets a beam-dirty attribute when modified.

Dirty Indicator

Show an indicator when the form has unsaved changes:

<h2>
  Profile Settings
  <span beam-dirty-indicator="#profile-form" class="unsaved-badge">*</span>
</h2>

<form id="profile-form" beam-dirty-track>
  <!-- form fields -->
</form>

<style>
  [beam-dirty-indicator] {
    display: none;
    color: orange;
  }
</style>

Revert Changes

Add a button to restore original values:

<form id="profile-form" beam-dirty-track>
  <input name="username" value="johndoe" />
  <input name="email" value="[email protected]" />

  <button
    type="button"
    beam-revert="#profile-form"
    beam-show-if-dirty="#profile-form"
  >
    Revert Changes
  </button>
  <button type="submit">Save</button>
</form>

The revert button only shows when the form is dirty.

Unsaved Changes Warning

Warn users before navigating away with unsaved changes:

<form beam-dirty-track beam-warn-unsaved>
  <input name="important-data" />
  <button type="submit">Save</button>
</form>

The browser will show a confirmation dialog if the user tries to close the tab or navigate away.

Conditional Visibility

Show/hide elements based on dirty state:

<form id="settings" beam-dirty-track>
  <!-- Show when dirty -->
  <div beam-show-if-dirty="#settings" class="warning">
    You have unsaved changes
  </div>

  <!-- Hide when dirty -->
  <div beam-hide-if-dirty="#settings">All changes saved</div>
</form>

Conditional Form Fields

Enable, disable, show, or hide fields based on other field values—all client-side, no server round-trip.

Enable/Disable Fields

<label>
  <input type="checkbox" id="subscribe" name="subscribe" />
  Subscribe to newsletter
</label>

<!-- Enabled only when checkbox is checked -->
<input
  type="email"
  name="email"
  placeholder="Enter your email..."
  beam-enable-if="#subscribe:checked"
  disabled
/>

Show/Hide Fields

<select name="source" id="source">
  <option value="">-- Select --</option>
  <option value="google">Google</option>
  <option value="friend">Friend</option>
  <option value="other">Other</option>
</select>

<!-- Only visible when "other" is selected -->
<div beam-visible-if="#source[value='other']">
  <label>Please specify</label>
  <input type="text" name="source-other" />
</div>

Required Fields

<label>
  <input type="checkbox" id="business" name="is-business" />
  This is a business account
</label>

<!-- Required only when checkbox is checked -->
<input
  type="text"
  name="company"
  placeholder="Company name"
  beam-re