Skip to main content

Custom Widgets

A widget is not a small app. It sits flat in a wall, it does not turn towards the player, it has no title bar and cannot be focused, and outside the edit mode it takes no clicks at all — it is something you glance at, not something you use. It is also saved in the player’s profile, so a widget somebody hangs today is still on that wall tomorrow. Write your own resource, register a kind, and it turns up in the widget bar next to the clock. A complete, running example is in examples/roadvr_examplewidget/.
Why a page and not a component. RoadVR’s interface is one compiled Vue bundle. Your resource cannot add a component to a build that shipped before your resource existed — so your widget is loaded as a page in a frame instead.That is the better deal anyway. Inside the frame you use whatever you like, plain HTML or a framework, and a crash in your widget cannot take the headset down with it.

Getting one on a wall

1

Create a resource

Any normal FiveM resource will do. Put your page and your icon in the files {} block so RoadVR can load them:
fxmanifest.lua
2

Register the kind

Once, at resource start. RoadVR may not be up yet when your resource starts, so retry until it takes:
client.lua
3

Write the page

Transparent background, no cursor, no scrolling. See Writing the page below.

The four exports

The definition

string
required
Unique across every resource. Prefix it with your resource name. Registering over an id that belongs to somebody else fails with an error rather than silently replacing their widget.
string
required
A file inside your resource, e.g. 'ui/widget.html'. The full address — https://cfx-nui-<your-resource>/<file> — is built for you, so it cannot point somewhere else by accident. Put the file in your files {} block.
string
default:"the id"
Shown under the tile in the widget bar.
string
default:""
A file inside your resource. Shown as the tile in the bar — a remote kind does not get a live preview, because that would mean loading your page a second time just to shrink it into a corner.
number
default:"0.34"
Metres of width. The height follows from aspect, so you never have to keep two numbers in agreement.
number
default:"0.2"
Smallest width the player’s mouse wheel may scale it to, in metres.
number
default:"0.8"
Largest width the wheel may scale it to, in metres. Hard-capped at 4 m.
number
default:"1.0"
Width divided by height. 1.0 is square, 1.78 is a 16:9 letterbox.
string
default:"recess"
'recess' sinks the widget into the wall — a routed edge, a bore wall, a floor a couple of centimetres back. 'raised' stands it off the wall on a plate that throws a shadow. Pick by what the thing is: a dial belongs in the wall, a print belongs in front of it.
string
default:"rounded"
'round' cuts the recess as a circle, anything else as a rounded square. Only meaningful for the recess mount.

Talking to your widget

sendToRoadVrWidget arrives in your page as a message event:
Everything RoadVR forwards carries source: 'roadvr', so a stray message from somewhere else cannot be mistaken for one of yours. The other direction needs nothing from RoadVR. FiveM routes https://<your-resource>/<callback> by resource name, no matter which page fires the request, so a plain fetch inside the frame reaches your own RegisterNUICallback:

More than one on the wall

A player may hang several widgets of the same kind. A message reaches all of them — messages are addressed to the kind, not to the piece. To tell themselves apart, each page gets its own widget id in its address:
Use getRoadVrWidgetInstances before you start sending. Nothing hanging means nothing to send to, and that check is the difference between a widget that costs nothing when unused and one that does not.
Four limits worth knowing before you build something around this:
  • A widget takes no clicks outside the edit mode. It is a display, not a control. Inside the edit mode the clicks belong to RoadVR’s own grab, edit and remove buttons.
  • Config.Widgets.max — 12 by default — counts every widget together, yours and RoadVR’s. Frames cost more than components; that number is what protects the frame rate.
  • Beyond Config.Widgets.renderDistance a widget is hidden, but its frame keeps running. If you have an expensive loop, stop it yourself when nothing arrives.
  • There is no per-instance payload in this version. If you need state per piece, key it by the instance id on your own side.

Lifecycle

Register once at resource start. There is no need to unregister on stop. Registration survives the headset being taken off and put back on. The list is kept on the Lua side and pushed to the interface again on every boot, so a resource that started long before anyone put a headset on still shows up in the bar. When your resource stops, the widgets stay on the wall. This is the one place custom widgets differ from custom apps: an app that goes away takes its window with it, but a widget lives in the player’s profile, and a resource restarting is not a reason to clear somebody’s wall. The kind disappears from the bar and the hanging pieces show a plate reading “Widget unavailable”. Start the resource again and they light back up, in the same spots and at the same sizes. The player can always take one off the wall in the edit mode, available or not — the grab and remove controls belong to the mount, not to your kind.

Writing the page

The page renders at a fixed pixel size and is scaled to the widget’s size in metres, so set type much larger than you would in a browser. RoadVR’s own widgets render at 512 px across. Keep the background transparent. RoadVR’s mount sits behind your page. A background colour on body shows up as a rectangle inside a round recess. Hide the cursor with cursor: none. The headset draws its own pointer. Nothing can be selected. Set user-select: none on html, body. RoadVR sets it on its own widgets, but that rule stops at the frame boundary — your page is its own document and starts from the default, which is selectable. A widget that takes no clicks is also one nobody should be able to smear blue.
Do not scroll. Set overflow: hidden on html, body and make the page fit. The wheel is taken while a widget is being placed — it resizes the widget — and a scrollbar inside a hole in a wall looks like a fault.

Talking to RoadVR itself

The same SDK the apps use, with one difference in what it reports:
ctx.surface is 'widget', ctx.window is null, and ctx.widget holds what a piece on the wall has:
size is metres of width — one number, because the height follows from aspect. The resize event carries { size } rather than the { width, height } an app’s window sends, for the same reason. RoadVr.close() does nothing for a widget. A widget hangs in a wall and belongs to the player; taking one off is theirs to do. Two more are apps only and do nothing here either: RoadVr.setActions(), because a widget has no ornament to hang buttons in, and watch('placement'), because a widget hangs still — there is no distance that changes. Everything else — theme, locale, world, visible / hidden, message, the counterpart returned by every on() — works exactly as it does for an app. The full table is in Custom Apps, and every field and function in the SDK reference.

Trying the example

Put the headset on, press the widget key, and “Speed” is in the bar. Hang it, get in a car, and the number follows you. Hang a second one and both show the same reading. Stop the resource and they both say “Widget unavailable” without leaving the wall; start it again and they come back.