Skip to main content

Your own components

The second kind of plugin does not open a panel at all. It attaches to an object as a component and draws its interface inside the editor's own inspector, alongside Transform and Material.

This is the right shape when your tool is about a particular object — notes on an object, a custom control, a generator that belongs to the thing it generates.

Declaring one

Declare it in components; panels can be empty:

{
"id": "entity-notes",
"entry": "main.tsx",
"panels": [],
"components": [{ "id": "notes", "title": "Notes", "entry": "main.tsx" }]
}

Writing one

import {
Button,
Column,
Text,
defineProps,
useEntity,
useSignals,
useSyncedState,
} from '@was/extension';
import { TransformComponent } from '@was/engine';

const useProps = defineProps({ step: { type: 'number', default: 0.5 } });

export default function Notes() {
useSignals(); // track scene data read during render
const props = useProps(); // values from the inspector form
const entity = useEntity(); // the object this is attached to
const [note, setNote] = useSyncedState('note', '');

return (
<Column gap={2}>
<Text muted>{note}</Text>
<Button
onClick={() =>
setNote(`y=${entity.getComponent(TransformComponent)?.get('position').y}`)
}
>
Remember
</Button>
</Column>
);
}

Four hooks do the work:

HookGives you
useEntity()the object your component is attached to
useProps()the values from the form you declared with defineProps
useSignals()re-renders when the scene data you read during render changes
useSyncedState()state stored on the object and synced like any other component

defineProps uses the same field types as script properties — number, string, boolean, colour, select, entity, resource — so your component gets a proper inspector form for free.

How it runs

This is real React, not a simplified imitation

Every component extension from every plugin runs in one shared worker, with React 19 inside it. Hooks, useEffect, context and Suspense all work exactly as you expect.

What the worker produces is not DOM but a description of it, which the editor draws with its own components. That is what makes an extension look native rather than like an embedded page.

Two consequences of that boundary:

  • The element set is a fixed list. You build from the provided components, not arbitrary HTML.
  • Handlers do not cross the boundary. The editor is told only that a handler exists; a click comes back as a message and calls your function in the worker. You write onClick normally — this only matters if you expected to pass a DOM event around.

How people add it

Your extension appears in + New component, in an Extensions group — it is not treated as anything more special than a built-in component.

It also appears in the toolbar under +, grouped under a Plugins heading so it is obvious where an unfamiliar button came from. The two entry points behave differently, deliberately:

Added fromResult
+ New componentattaches to the object you already selected
The toolbarcreates a new object with your component and selects it

A locally connected folder shows up in that list too if it has a panel — otherwise the only way to reach it would be to open the Plugins panel every time. A folder with nothing but an overlay does not appear: there would be nothing to press.

Trust

A component extension runs without being opened

It mounts whenever anyone opens a project containing it, with no click involved. That is what puts it behind the stronger consent described in trust and verification.


Next: Keyboard shortcuts