Custom components
A plugin can add its own component to objects. Its interface appears in the inspector, next to Transform and Material.
Use this when your tool belongs to one object: notes on an object, a custom control, or a generator that stays with the thing it creates. A plugin like this does not need a panel.
Declare the component
List it under components in the manifest. panels can be empty:
{
"id": "entity-notes",
"entry": "main.tsx",
"panels": [],
"components": [{ "id": "notes", "title": "Notes", "entry": "main.tsx" }]
}
Write the component
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(); // re-render when scene data read here changes
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>
);
}
These hooks do the work:
| Hook | Gives you |
|---|---|
useEntity() | the object your component is attached to |
useWorld() | the scene that object is in |
useProps() | the values from the form you declared with defineProps |
useSignals() | a re-render when scene data you read during render changes |
useSyncedState() | state stored on the object and synced like any other component |
useSelected() | whether the object is selected in the editor |
useViewportPointer() | mouse events in the viewport, with the object and point under the pointer |
defineProps uses the same field types as script properties: number, string, boolean, colour,
select, entity, scene, space, resource, record, array and group. So your component gets a proper
form in the inspector with no extra work.
Change the values from your component
props.$set(patch) changes your component's own fields, the way the inspector does: the change
syncs to everyone and can be undone. It is checked against defineProps: numbers stay within
their min and max, and a field you did not declare throws an error. Use it to drag a value in
the viewport:
<Handle axis={[0, 1, 0]} onDrag={(p) => props.$set({ height: p[1] })} />
Field names that start with $ are reserved for this, so defineProps refuses them.
Declare the state
defineState declares the component's state with defaults, the same way defineProps declares
the form:
const useScore = defineState({ points: { type: 'number', default: 0 } });
const [score, setScore] = useScore(); // inside the component
Scripts then read this state typed, with the same defaults.
Translate it
i18n() or useEditorLocale() from @was/extension give you t(key, vars) over your plugin's
locale files, in the editor's language.
How it runs
Your component is real React 19. Hooks, useEffect, context and Suspense all work as usual.
The editor draws your component with its own elements, so it looks like part of the editor.
Two things follow from this:
- You build only from the provided elements. Arbitrary HTML is not available. In the
inspector:
Column,Row,Text,Button,Divider,InputText,InputNumber,InputColor,Slider,CheckboxandSelect. In the viewport:Overlay,Gizmo,Group,Box,Sphere,Line,HandleandControl. Give aGizmoor anOverlaywhen="selected"to show it only on the selected object. - Write event handlers as usual.
onClickworks the normal way. The one thing you cannot do is pass a DOM event around.
How people add it
Your component can be added in two ways:
| Added from | Result |
|---|---|
| + new component, in the Extensions group | it is added to the object that is already selected |
| The toolbar, under +, in the Plugins group | a new object is created with your component and selected |
The Plugins heading in the toolbar shows people where an unfamiliar button came from.
A plugin folder connected from your computer also appears in that toolbar list if it has a panel, so you can open it without going through the Plugins panel each time. A folder with only an overlay does not appear, because there is nothing to open.
Use it in scripts and in the published project
The component's interface works in the editor only. Its values are saved on the object, so the project can use them when it plays:
- scripts read and change the component with
ctx.plugin('<plugin id>/<component id>')→ The ctx API; - your plugin's own code for the published project does the same → Runtime code.
Trust
A custom component starts whenever anyone opens a project that contains it. That is why it goes through the stricter check described in Trust and verification.
Next: Runtime code — make your plugin work in the published project.