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 and after.create fires again. Events bound only to that room (U.chat('my-room').on) are cleared too, so bind to U.chat('*') and filter by room.id as 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 returns true the first time and false after that, so calling it more than once is safe.
  • Synchronous callbacks only: Callbacks such as condition, describe, resolve, and match must 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. onSelect ignores 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.skin after the room is destroyed throws an error.
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

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);
  • kind is one of alert, confirm, prompt, password, select. Any other value throws an error.
  • title and content are shown as text (not HTML).
  • With options.backdropDismiss: false, clicking outside does not close the dialog.
  • close(value) resolves result with that value and returns true. If the dialog is already closed, it returns false.
  • 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
  • content is required and is shown as text.
  • A higher weight places the item higher (default 0).
  • dismissLabel is 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 css text 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.

Business name UCHAT · CEO 김경욱 · Business registration no. 141-05-32832
Address 경기도 파주시 와석순환로 61 · Mail-order business registration no. 2013-경기파주-4485호
Phone 010-8677-8633 · Email support@uchat.io

ⓒ 2013–2026 UCHAT. All rights reserved.