Author a Beyond widget
Build a React 19 widget declared by its module.json, give it a Tailwind stylesheet and a styled public dependency of another package, see it mount in its own shadow root, change its code and its CSS without reloading, and do the same with a Vue or a Svelte view.
- Availability: Experimental
- Evidence: Recorded run
- Tutorial
What you build
A custom element, <hello-widget>, whose view is a React 19 component. It has its own Tailwind stylesheet, adopts the shared stylesheet of its package, and shows a badge that a public module of another package supplies with its own stylesheet, all inside the shadow root of the element. Then you change its code and its CSS while the page is open, and load the same widget with a Vue or a Svelte view.
Declare the widget
A widget is a public module whose manifest declares the element under widget: its tag name and the attributes it observes. The ts bundler compiles it, running on the development runtime, and generates the registration of the element: importing the module is enough to make <hello-widget> known to the page. The manifest also declares the sources Tailwind scans.
{
"platforms": ["web", "node"],
"entry": "index.ts",
"widget": {
"element": { "name": "hello-widget", "attrs": ["subject"] }
},
"tailwind": { "sources": ["view.tsx"] }
}The package declares its bundler, its runtime, its dependencies and the shared stylesheet it publishes as ./global:
{
"name": "@testbed/widget-app",
"version": "0.1.0",
"private": true,
"description": "A root React 19 widget with its own Tailwind stylesheet, a shared global stylesheet and a styled public dependency of another package",
"exports": {
"./global": "./global.css"
},
"beyond": {
"modules": ".",
"bundler": "ts"
},
"bundlers": {
"ts": {
"specifier": "@beyond-js/packages/bundlers/ts",
"runtime": "@beyond-js/local-2026/bundle"
}
},
"dependencies": {
"@testbed/badge": "0.1.0",
"@beyond-js/widgets": "^1.1.0",
"@beyond-js/react-19-widgets": "^1.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
}
}The Widgets package, the React 19 adapter and React itself are supplied by the toolchain and served by the same development service: the workspace installs nothing.
Write the controller and the view
The controller extends the React 19 adapter and names the view. The same controller serves the browser, where the adapter mounts the view, and Node, where it renders the view to HTML.
/**
* The controller of the widget `hello-widget`: a React 19 widget whose view is `View`. The same controller
* serves the browser, where the adapter mounts the view, and Node, where the adapter renders it to HTML.
*/
import { ReactWidgetController } from '@beyond-js/react-19-widgets/base';
import { View } from './view';
export class Controller extends ReactWidgetController {
get Widget() {
return View;
}
}The view is an ordinary React component. It receives the widget, its attributes and its store as props, keeps its own state, and imports the badge from another public module by its bare specifier:
import * as React from 'react';
import { badge } from '@testbed/badge/badge';
/**
* The view of the widget: a heading styled by the module's own stylesheet, a badge supplied by another
* public module with its own stylesheet, and a counter that survives updates of the code and the styles
*/
export function View({ attributes }: { attributes: Map<string, string> }) {
const [count, setCount] = React.useState(0);
const subject = attributes.get('subject') ?? 'Beyond';
return (
<div className="card p-4">
<h1 className="greeting text-brand">Hello {subject}!</h1>
<span className="badge-slot" dangerouslySetInnerHTML={{ __html: badge('React 19') }} />
<button className="counter" onClick={() => setCount(count + 1)}>
Clicked {count}
</button>
</div>
);
}/* The stylesheet of the widget: Tailwind utilities for the classes in view.tsx, the theme, and its own rules */
@import "tailwindcss";
@theme {
--color-brand: rgb(30, 64, 175);
}
.greeting {
font-size: 28px;
margin: 0 0 8px;
}
.counter {
background-color: rgb(30, 64, 175);
color: rgb(255, 255, 255);
border: 0;
padding: 8px 12px;
}The dependency with its own styles
@testbed/badge/badge is a public module of another package of the workspace, not a widget. The badge package selects the same bundler and runtime and declares nothing else; its stylesheet travels with the module: a widget that imports the module adopts the sheet in its own root.
{
"name": "@testbed/badge",
"version": "0.1.0",
"private": true,
"description": "A public module with its own stylesheet, imported by the widget of another package by its bare specifier",
"beyond": {
"modules": ".",
"bundler": "ts"
},
"bundlers": {
"ts": {
"specifier": "@beyond-js/packages/bundlers/ts",
"runtime": "@beyond-js/local-2026/bundle"
}
}
}/**
* A badge: markup styled by this module's stylesheet, which the widget that imports the module adopts in
* its root together with its own sheets
*/
export const badge = (text: string): string => `<span class="badge">${text}</span>`;.badge {
display: inline-block;
background-color: rgb(22, 101, 52);
color: rgb(255, 255, 255);
border-radius: 4px;
padding: 2px 8px;
}The compiled widget keeps from '@testbed/badge/badge' as a bare reference: the implementation of the badge is never copied into the widget.
Put it on a page
The entry module of the page imports the widget module, which registers the element, and appends one instance:
/**
* The entry point of the application: importing the widget module registers its element, and the page
* gets one instance. Nothing else is needed to show a widget.
*/
import '@testbed/widget-app/hello';
const widget = document.createElement('hello-widget');
widget.setAttribute('subject', 'Beyond');
document.body.append(widget);Start the server with BEYOND_SERVICE_EXTENSIONS=@beyond-js/packages/development beyond run and open <endpoint>/preview/?entry=@testbed/widget-app/main. The widget shows a card with Hello Beyond! in blue (rgb(30, 64, 175), the text-brand utility of its theme), the green badge (rgb(22, 101, 52), the stylesheet of the badge module), and a Clicked 0 button. Its shadow root holds three stylesheet links, in this order: the global sheet of the package, the sheet of the widget and the sheet of the badge. The document links none of them.
Change it while it runs
| Edit | What the open page does |
|---|---|
The badge text in badge/index.ts |
The next render of the widget shows the new badge, through its original import. The counter and the element are kept; the unrelated @testbed/badge/note module of the badge package is not rebuilt (same ETag) |
The color in badge.css |
The badge sheet is replaced in the root of the widget; the counter is kept |
A Tailwind class added to view.tsx |
The utility is emitted and the widget renders the new component; a component whose module changed is created again, so its useState starts over while the element and its controller stay. Removing the class removes the utility |
A syntax error in badge.css or in badge/index.ts |
The module is invalid and answers 422 BUILD_FAILED; the page keeps its last good state and correcting the file recovers |
None of this navigates the page.
The same widget with a Vue or a Svelte view
The controller of a widget selects its adapter; the view is a single-file component compiled by Packages. The manifest declares the element the same way, with no mention of the framework:
{
"platforms": ["web", "node"],
"entry": "index.ts",
"widget": {
"element": { "name": "hello-vue", "attrs": ["label"] }
}
}import { VueWidgetController } from '@beyond-js/vue-widgets/base';
import View from './view.vue';
/**
* A Vue widget with a label attribute and a counter of its own. The same controller renders on Node.
*/
export class Controller extends VueWidgetController {
get Widget() {
return View;
}
}<script setup lang="ts">
import { ref } from 'vue';
import { label } from '@testbed/boundaries/shared';
const props = defineProps<{ attributes: Map<string, string> }>();
const count = ref(0);
</script>
<template>
<div class="vue">
<h2 class="title">{{ props.attributes.get('label') }}</h2>
<p class="shared-label">{{ label('Vue count', count) }}</p>
<button class="counter" @click="count++">Add</button>
</div>
</template>
<style scoped>
.title {
color: rgb(202, 138, 4);
margin: 0;
}
</style>import { SvelteWidgetController } from '@beyond-js/svelte-widgets/base';
import View from './view.svelte';
/**
* A Svelte 5 widget with a label attribute and a counter of its own. The same controller renders on Node.
*/
export class Controller extends SvelteWidgetController {
get Widget() {
return View;
}
}<script lang="ts">
import { label } from '@testbed/boundaries/shared';
let { attributes }: { attributes: Map<string, string> } = $props();
let count = $state(0);
</script>
<div class="svelte">
<h2 class="title">{attributes.get('label')}</h2>
<p class="shared-label">{label('Svelte count', count)}</p>
<button class="counter" onclick={() => count++}>Add</button>
</div>
<style>
.title {
color: rgb(190, 24, 93);
margin: 0;
}
</style>A Vue component becomes its script, its render function and a facade, and each <style> block goes into the stylesheet of the module, scoped when the block is; a Svelte 5 component is compiled with runes and its CSS is collected into the stylesheet of the module. A React, a Vue and a Svelte widget mount side by side on one page, each in its own root with its own styles; a widget nested inside another owns its root, and the rule of the parent does not reach it. Two instances of one tag keep separate attributes and state.
Render it on a server
Every widget module of these examples also builds for Node. A Node host loads the modules from the service, renders each widget with its server controller (deterministic HTML, with the stylesheet links of its dependencies), and a browser hydrates that markup instead of replacing it: the heading the server wrote is the same node after hydration, and the counters work. Read Create a modular execution environment for the host.
Next
Write a widget without a view framework, or integrate another one: Integrate a view framework. Put the widget in a page that was not built with Beyond: Embed a widget in an existing page. Deliver it from a CDN release: Widgets and view frameworks.