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 inexamples/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:
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: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.
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 onbody 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.
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.