Skin UI API
The full list of the room.skin API, which adds menus, dialogs, areas, and styling to the default skin (basic-v2).
Last updated October 19, 2026
On this page
Use room.skin to add buttons, menus, dialogs, and areas to the default skin (basic-v2) and to change how nicknames and messages are displayed. This page is the canonical reference. If you are new to this, start with Extending the skin UI.
The legacy skin (basic) does not have this API. For the legacy skin, see Legacy skin API.
Common rules
U.chat('*').on('after.create#my-ui', function (room) {
if (room.id !== 'my-room') return;
var ui = room.skin;
var remove = ui.menu.add({
id: 'hello',
label: 'Greet',
onSelect: function (ctx) { ctx.room.action.send('Hello!'); },
});
// call remove() when you no longer need it
});
- When to register: Register inside
after.create. When the room is reinstalled (room.reload(), re-inserting the tag), all skin registrations are cleared andafter.createfires again. Events bound only to that room (U.chat('my-room').on) are cleared too, so bind toU.chat('*')and filter byroom.idas in the example above; then everything is registered again automatically on every reinstall. - IDs: Every registration needs an
id. A duplicate within the same kind throws an error. - Removal function:
add-style functions return a removal function. It returnstruethe first time andfalseafter that, so calling it more than once is safe. - Synchronous callbacks only: Callbacks such as
condition,describe,resolve, andmatchmust be synchronous. Returning a Promise is treated as an error and that item is dropped. An exception inside a callback is logged to the console and only that item is skipped.onSelectignores its return value, so it can be async, but you must handle exceptions inside it yourself. - Display only: Hiding or styling a menu does not change permissions. The server checks permissions.
- Calling
room.skinafter the room is destroyed throws an error.
Menus
| API | Location |
|---|---|
ui.menu |
Buttons in the input toolbar |
ui.userMenu |
The menu that opens when you click a nickname |
| Function | Returns | Description |
|---|---|---|
add(item) |
Removal function | Adds an item |
remove(id) |
true/false |
Removes an item. false if the ID did not exist |
resolver.add({ id, resolve }) |
Removal function | Adjusts the item list. See Menu resolver below |
resolver.remove(id) |
true/false |
Removes a resolver |
resolver.refresh() |
— | Recalculates the list when conditions change |
Item fields
| Key | Type | Description |
|---|---|---|
id |
String | Required. Unique ID |
label |
String | Display text. Falls back to title, then to id |
title |
String | Tooltip on hover |
order |
Number | Lower comes first. Default 0 |
group |
String | userMenu: groups items of the same group on one row |
column |
0/1 |
userMenu: splits items within the same group into left (0) and right (1) columns |
separator |
Boolean | userMenu: a divider. Cannot be selected |
disabled |
Boolean | Disabled (visible but cannot be clicked) |
pressed |
Boolean | menu: shown as pressed (toggle button) |
padding |
'none'/'normal' |
menu: inner padding of the button. Default 'normal' |
condition(ctx) |
Function | Hidden if it returns false |
describe(ctx) |
Function | Returns an object with fields that change over time (label, disabled, pressed, etc.). Hidden if it returns null |
onSelect(ctx) |
Function | When clicked |
Callback ctx
| API | ctx |
|---|---|
ui.menu |
{ room, skin }, plus event in onSelect |
ui.userMenu |
{ room, skin, target, messageUid, message, event } — target is the clicked person's user object; if the nickname was clicked in a message, messageUid and message are set |
ui.userMenu.add({
id: 'profile',
label: 'View profile',
condition: function (ctx) { return !!ctx.target.id; },
onSelect: function (ctx) {
window.open('/profile/' + encodeURIComponent(ctx.target.id));
},
});
Default items
| API | ID (order) |
|---|---|
ui.menu |
skin.bold(-60), skin.italic(-50), skin.underline(-40), skin.color(-30), skin.emoticon(-20), skin.upload(-10), skin.brand(100), skin.scroll(110) |
ui.userMenu |
Start with skin., such as skin.whisper, skin.mute, skin.ban. order starts at -1000 in steps of 10 |
Menu resolver
A resolver only works with items that are already registered. It can hide items, reorder them, change display values, and disable them; it cannot create new items or enable disabled ones.
ui.userMenu.resolver.add({
id: 'hide-ignore',
resolve: function (ctx) {
return ctx.current.filter(function (item) { return item.id !== 'skin.ignore'; });
},
});
| Item | Description |
|---|---|
resolve(ctx) |
ctx is the menu ctx plus base (the list before any resolver) and current (the list after the previous resolvers). Return an array to replace the list, or null to leave it unchanged |
| Returned array | Items without an ID, unregistered IDs, and duplicate IDs are dropped. The list is re-sorted by order |
Dialogs
| Function | Returns (Promise result) |
|---|---|
ui.dialog.alert(title, content) |
OK true, close null |
ui.dialog.confirm(title, content) |
OK true, cancel false |
ui.dialog.prompt(title, content, value) |
The entered string, cancel null |
ui.dialog.password(title, content, value) |
The entered string (masked input), cancel null |
ui.dialog.select(title, content, options, value) |
The chosen value (key), cancel null. options is an object { value: 'display text' } |
ui.dialog.open({ kind, title, content, options, value }) |
{ id, result, close } |
ui.dialog.confirm('Challenge', 'Play rock-paper-scissors?').then(function (ok) {
if (ok) room.action.send('Challenge!');
});
var handle = ui.dialog.open({
kind: 'confirm',
title: 'Challenge',
content: 'Answer within 10 seconds.',
options: { backdropDismiss: false },
});
handle.result.then(function (ok) { /* true, false, or the value passed to close */ });
setTimeout(function () { handle.close(null); }, 10000);
kindis one ofalert,confirm,prompt,password,select. Any other value throws an error.titleandcontentare shown as text (not HTML).- With
options.backdropDismiss: false, clicking outside does not close the dialog. close(value)resolvesresultwith that value and returnstrue. If the dialog is already closed, it returnsfalse.- Dialogs are shown one at a time, and the rest wait in order. If the skin's own dialog is open, it is shown first. When the room is destroyed, all waiting dialogs resolve with
null.
Mounting areas
Pass mount(host, ctx) to ui.mount.* and the skin creates an empty element (host) and hands it to you. Work only inside the host; do not modify the DOM outside it.
ui.mount.messageList({
id: 'banner',
position: 'before',
mount: function (host, ctx) {
host.textContent = 'Event today at 8 PM!';
return function () { host.replaceChildren(); };
},
});
| Function | position |
Placement |
|---|---|---|
header |
before (default), after |
Before/after the header area |
headerTitle |
— | Inside the room title spot |
middle |
before (default), after |
Before/after the chat + user list area |
messageList |
before (default), after |
Before/after the chat area |
userList |
before (default), after |
Before/after the user list |
composer |
above-input (default, before-input is the same), after-input |
Above the input, after the input |
footer |
before (default), after |
Before/after the input area |
wrap |
before (default), after, left, right |
Above, below, left, or right of the whole room |
toolbar |
— | Inside the input toolbar (after the buttons) |
overlay |
— | Overlaid on top of the screen. See Overlay areas below |
All of them return a removal function. You can also remove by ID with ui.mount.removeOverlay(id) and ui.mount.removeToolbar(id). An unsupported position throws an error.
mount ctx
| Key | Description |
|---|---|
room, skin |
The room object, room.skin |
signal |
AbortSignal. Aborted right before cleanup. Pass it to addEventListener(..., { signal }) for automatic cleanup |
bounds() |
The host's getBoundingClientRect() value |
close() |
Removes this whole registration (same as the removal function) |
requestLayout() |
Asks the skin to lay out again after you change the host's size |
slot |
(areas) The name of the slot it is attached to. Example: messageList.before |
layer, scope |
(overlay) Overlay layer and scope |
message, author |
(message.extend) That message and its sender |
If mount returns a function or a { destroy } object, it is called once when the host goes away or the registration is removed.
Overlay areas
ui.mount.overlay({ id, layer, scope, exclusive, mount }) overlays content on top without pushing the layout.
| Key | Value | Description |
|---|---|---|
layer |
background-effect |
Background effect. Clicks pass through |
content-overlay (default) |
Information shown over the content | |
modal |
Topmost layer that covers what is below | |
scope |
skin (default) |
Overlays the whole room |
message-list |
Overlays only the chat area | |
exclusive |
Boolean | Used with layer: 'modal', ui.dialog dialogs wait instead of opening while this area is shown. Only allowed when scope is skin; otherwise it throws an error |
ui.mount.overlay({
id: 'player',
layer: 'content-overlay',
scope: 'message-list',
mount: function (host, ctx) {
host.textContent = 'Now playing';
},
});
Pinned notice
| Function | Returns | Description |
|---|---|---|
ui.topLayer.add({ id, content, backgroundColor, color, dismissLabel, weight }) |
Removal function | Adds an item to the notice bar at the top of the chat area |
ui.topLayer.remove(id) |
true/false |
Removes an item |
contentis required and is shown as text.- A higher
weightplaces the item higher (default 0). dismissLabelis the text of the button the viewer uses to close the notice.
ui.topLayer.add({ id: 'event', content: 'Event in progress!', backgroundColor: '#fff4cc', color: '#7a4b00' });
Attaching to messages
ui.message.extend({ id, position, match, mount }) attaches an area to every normal message and whisper line. It applies to both past and new messages.
| Key | Description |
|---|---|
position |
before (start of the line, before the nickname), inline (right after the content), after (default, after inline) |
match(ctx) |
Chooses which messages to attach to. ctx is { message, author, room, skin }. If omitted, all messages |
mount(host, ctx) |
mount ctx plus message and author |
The main values of message are kind ('message' or 'whisper'), author (the sender), content, uid, created, and mine (true if you sent it). ctx.close() removes the whole registration, not just that one message.
ui.message.extend({
id: 'admin-badge',
position: 'inline',
match: function (ctx) { return ctx.author && ctx.author.auth >= 2; },
mount: function (host) { host.textContent = ' [Admin]'; },
});
Nickname styling
ui.nickname.resolver.add({ id, resolve }), remove(id), refresh().
| Item | Description |
|---|---|
ctx of resolve(ctx) |
{ user, location, message, backgroundColor, room, skin, base, current }. location is 'message' (chat) or 'user-list' (user list); message is set only in chat |
| Return value | Only the parts of { style, nickcon, iconsBefore, iconsAfter, addBefore, addAfter, tag, gradient, classes, vars } you want to change. null skips |
style |
See Allowed style below |
gradient |
An array of 2–8 colors painted left to right across the nickname text (e.g. ['#e65c00', '#d4145a']). Only color formats allowed in style are accepted; null clears it. Titles and icons are not painted |
classes |
An array of class names added to the whole nickname ([data-nickname-display]). Use it to apply rules registered with plugin CSS. Classes are only added (an earlier resolver's classes are never removed), up to 16. Only names made of letters, digits, _ and - are accepted |
vars |
CSS variables set on the whole nickname ({ '--my-color': '#f00' }), for passing per-person values to your CSS. Names start with --; values containing ;, { or } are dropped. null removes that variable |
nickcon |
{ src, alt } (an image instead of the nickname) or null (remove the nickcon) |
iconsBefore, iconsAfter |
[{ src, alt }]. Replaces the whole list of icons before/after the nickname. To add, use ctx.current.iconsAfter.concat(...) |
addBefore, addAfter |
Parts appended before or after the nickname: [{ text, image, color, background, border, title, classes }]. Up to 6 per side, text up to 16 characters. classes (up to 8) applies shapes from plugin CSS |
Image src must start with https://, http://, //, or /. URLs containing spaces or with an invalid format are dropped.
ui.nickname.resolver.add({
id: 'admin-nick',
resolve: function (ctx) {
if (!ctx.user || ctx.user.auth < 2) return null;
return {
style: { fontWeight: 'bold', color: '#c2410c' },
iconsAfter: ctx.current.iconsAfter.concat({ src: 'https://example.com/star.png', alt: 'Admin' }),
};
},
});
Message styling
ui.message.resolver.add({ id, resolve }), remove(id), refresh(). Applies only to normal messages and whispers.
| Item | Description |
|---|---|
ctx of resolve(ctx) |
{ message, author, backgroundColor, room, skin, base, current }. message is read-only |
| Return value | Only the parts of { style, row, classes, vars } you want to change. null skips |
style |
Content text and row background. See Allowed style below |
row.spacing |
'normal' or 'relaxed' (wider row spacing) |
row.hideNickname |
true hides the nickname. Applies only to normal messages; whispers are not hidden |
classes |
An array of class names added to the whole message row, for rules registered with plugin CSS. Only added, up to 16. The message text inside is .message-content |
vars |
CSS variables set on the message row ({ '--my-accent': '#f00' }). Same rules as the nickname vars |
ui.message.resolver.add({
id: 'notice-highlight',
resolve: function (ctx) {
if (ctx.message.content.indexOf('Notice') < 0) return null;
return { style: { backgroundColor: '#fff4cc' }, row: { spacing: 'relaxed' } };
},
});
Resolvers are applied in order (default 0), then in registration order, each overriding the previous result. If conditions change and you need to recalculate, call refresh().
Allowed style
The style of nickname and message resolvers accepts only the keys below. Other keys and values in the wrong format are silently dropped. For other CSS, register it with plugin CSS and attach it through the nickname's classes.
| Key | Allowed values |
|---|---|
color, backgroundColor |
#rgb, #rgba, #rrggbb, #rrggbbaa, rgb(), rgba(), hsl(), hsla() |
fontWeight |
normal, bold, 100~900 (in steps of 100) |
fontStyle |
normal, italic, oblique |
textDecoration |
none, underline, overline, line-through |
Plugin CSS
ui.style.add({ id, css }) returns a removal function, and ui.style.remove(id) returns true/false.
- The
csstext goes into the chat room page as a<style>element as is. Its content is not filtered. It is removed when the plugin is turned off or the skin is cleaned up. - The CSS applies to the whole chat room page. Prefix your class names with the plugin name so you don't affect anything else.
- Nickname text always sits inside
[data-nickname-label]. With a gradient, the same element also gets[data-nickname-gradient].
ui.style.add({
id: 'myPlugin.flow',
css: '.my-flow [data-nickname-gradient]{background-size:200% 100%;animation:my-flow 4s linear infinite}'
+ '@keyframes my-flow{to{background-position:-200% 0}}',
});
ui.nickname.resolver.add({
id: 'myPlugin.nick',
resolve: function (ctx) {
if (!ctx.user || ctx.user.auth < 2) return null;
return { gradient: ['#538eff', '#f61ecf', '#538eff'], classes: ['my-flow'] };
},
});
Other features
| Function | Returns | Description |
|---|---|---|
ui.composer.guard.add({ id, shouldBlock, onBlocked }) |
Removal function | Blocks use of the input. If shouldBlock(ctx) is truthy, it blocks and calls onBlocked(ctx) |
ui.composer.guard.remove(id) |
true/false |
Removes a guard |
ui.message.removeByUser(nick) |
Number removed | Removes that nickname's messages from your screen |
ui.scrollToBottom(force) |
true/false |
Scrolls to the bottom of the chat. Without force, it does not move and returns false if the user is hovering over a nickname or link in a message, or has turned off auto-scroll and is reading above |
The guard's ctx is { action, draft, event, room, skin }, and action is 'send' (sending) or 'activate' (clicking or focusing the input).
ui.composer.guard.add({
id: 'login-first',
shouldBlock: function (ctx) { return ctx.room.my.auth < 1; },
onBlocked: function (ctx) { ctx.skin.dialog.alert('Notice', 'Only logged-in members can chat.'); },
});
Still not sure how it works? Ask your question on the contact board.