We wanted a compact overview of a system’s active connection methods: VPN, Internet, LAN, RDP and other routes. Each related access-route record already had a native Ninox choice icon. Showing one icon in a formula column worked. Concatenating several icons, returning an icon array or using an HTML formula did not give us the same native multi-icon display in a table view.
The working approach was a dynamic multiple-choice field: generate its options from the active related routes, copy each original icon object, and select the matching numeric option IDs. A small, carefully scoped JavaScript/CSS addition then removes the excess horizontal spacing.
Who can use this solution?
The complete compact display requires permission to load your own custom.js and custom.css in the Ninox web client. It is aimed at administrators of On-Premises installations and Private Cloud environments whose operator enables these custom resources. A normal lower-tier Public Cloud subscription should not be assumed to provide this access. Check the actual deployment permissions with your operator; a Private Cloud label or a higher subscription alone is not a verified guarantee.
Tested with Ninox 3.18.13 in our On-Premises web client on 10 November 2026. This is a report of that tested setup, not a minimum-version or later-version compatibility claim. The spacing helper is a custom interface adjustment, not an official Ninox multi-icon formula feature. Native apps and other versions were not tested.
Why use a multiple-choice field?
Ninox’s table renderer already knows how to draw several selected multiple-choice options, including their icons. That gave us a suitable native rendering path. In our tests, HTML worked in a form formula field but the table column rendered its text representation. Converting our multi-value field through icon(...) did not solve that.
Instead of rebuilding the icons with emoji or importing images, we reused the icon objects that were already attached to the original single-choice options. Their icon name, color, weight and fill stay available to Ninox’s own renderer. Ninox documents the newer configurable icon library in its icon picker documentation.
1. Generate the dynamic options
Our example uses a parent system record with related Zugangswege records. Each route has the boolean field Aktiv and the single-choice field Verbindungsweg, whose options already have the desired icons. On the parent, create a dynamic multiple-choice field called ZugangswegeDynamisch. Adapt these German example field names to your database.
Use this as its dynamic-options formula:
let wege := Zugangswege[Aktiv = true and Verbindungsweg != null];
let ids := unique(
for weg in wege do
number(weg.Verbindungsweg)
end
);
for wegId in ids do
let weg := first(wege[number(Verbindungsweg) = wegId]);
{
id: wegId,
caption: " ",
icon: parseJSON(formatJSON(icon(weg.Verbindungsweg)))
}
end
The formula returns a list of objects directly. Do not wrap the entire result in formatJSON(...). The numeric id is the original choice ID; unique(...) means two active VPN records produce one VPN option. This list contains the connection methods currently used by active related records, rather than every unused option in the source field’s schema.
The crucial detail is caption: " ": one space. An empty string did not work in our dynamic choice test. The round trip through formatJSON(icon(...)) and parseJSON(...) gives the option a JSON icon object while retaining its visual settings. JSON-defined dynamic choices are described in the Ninox release notes, section 3.14.
2. Set the selected values separately
Available options and selected values are separate. Put the following code in a button on the parent system’s form, then run it to select all currently active connection types:
ZugangswegeDynamisch := unique(
for weg in Zugangswege[Aktiv = true and Verbindungsweg != null] do
number(weg.Verbindungsweg)
end
)
Use the multiple-choice field directly as the view-column formula:
ZugangswegeDynamisch
This button is the tested refresh mechanism. For automatic updates, your application must also handle deactivation, deletion, connection-type changes and reassignment of a route to another system. Reassignment needs to refresh both the previous and the new system. That trigger workflow is application-specific and is not implemented by the example button. Users must also be able to read the relevant related records for the calculation to see them.
3. Remove the extra spacing in table cells
The native multiple-choice items still reserve room for their labels. Removing only the text therefore leaves surprisingly large gaps. In our installation, two table renderers were relevant: t-cell / multicombo-item and nx-table__body__cell / cell-value--with-icon. Targeting just the latter did not affect our main view.
The helper below marks only native table items whose caption is empty after trimming whitespace. This is necessary because CSS :empty does not match the single-space caption. It does not change the stored data or caption text. A MutationObserver handles inserted and recycled rows, with work batched per frame. The CSS reduces horizontal padding, margin and gap for those icon-only items. Labelled options, form fields and selection popups keep their original styling; vertical spacing, icon size and color are preserved.
Download the commented CSS, JavaScript, Ninox formulas and installation notes (ZIP). The package contains only these helpers, not our full installation-specific custom files.
Show the CSS block
/* Native Ninox icon-only multiple-choice cells in table views.
* Tested with Ninox 3.18.13 web client, 10 November 2026.
* Keep existing custom.css; append this block. Requires modern :is/:has CSS.
* Empty captions match directly; whitespace captions need the JS marker.
* Only horizontal spacing changes. Review DOM selectors after Ninox updates.
* Remove this block and its JS helper to roll back, then reload the browser.
*/
.nx-table__body__cell:is(
.nx-table__body__cell--multi,
.nx-table__body__cell--dmulti
):not(
.nx-table__body__cell--multi--editing,
.nx-table__body__cell--dmulti--editing
) > .cell-value--with-icon:has(> span:empty),
.nx-table__body__cell:is(
.nx-table__body__cell--multi,
.nx-table__body__cell--dmulti
):not(
.nx-table__body__cell--multi--editing,
.nx-table__body__cell--dmulti--editing
) > .cell-value--with-icon[data-zag1-icon-only="true"] {
margin-left: 0;
margin-right: 4px;
padding-left: 0;
padding-right: 0;
gap: 0;
}
.t-cell:is(
.t-cell-multi,
.t-cell-dmulti
) > .multicombo-item:has(> [role="img"]):has(> span:not([role="img"]):empty),
.t-cell:is(
.t-cell-multi,
.t-cell-dmulti
) > .multicombo-item[data-zag1-icon-only="true"] {
margin-left: 0;
margin-right: 4px;
padding-left: 0;
padding-right: 0;
gap: 0;
}
Show the JavaScript helper
/* Mark icon-only native multiple-choice entries in Ninox table cells.
* Tested with Ninox 3.18.13 web client, 10 November 2026.
* Dynamic JSON choices need a nonempty caption: use one space, not "".
* CSS :empty cannot match that space, so this helper checks trimmed text.
* It changes a DOM attribute only; it never rewrites data or caption text.
* A MutationObserver handles inserted/recycled rows, batched per frame.
* Load once through the installation's existing custom-JS mechanism.
* Keep all existing custom.js code; review selectors after Ninox upgrades.
*/
;(function () {
'use strict';
if (window.__zag1CompactIconSpacingV3) return;
window.__zag1CompactIconSpacingV3 = true;
var marker = 'data-zag1-icon-only';
var candidates = [
'.t-cell.t-cell-multi > .multicombo-item',
'.t-cell.t-cell-dmulti > .multicombo-item',
'.nx-table__body__cell.nx-table__body__cell--multi > .cell-value--with-icon',
'.nx-table__body__cell.nx-table__body__cell--dmulti > .cell-value--with-icon'
].join(',');
var discover = candidates + ',[' + marker + ']';
var pending = new Set();
var scheduled = false;
function update(item) {
var cell = item.parentElement;
var eligible = item.matches(candidates) && cell &&
!cell.classList.contains('nx-table__body__cell--multi--editing') &&
!cell.classList.contains('nx-table__body__cell--dmulti--editing');
var children = Array.prototype.slice.call(item.children);
var icons = children.filter(function (child) {
return child.getAttribute('role') === 'img';
});
var captions = children.filter(function (child) {
return child.tagName === 'SPAN' && child.getAttribute('role') !== 'img';
});
var caption = captions[0];
var onlyIcons = eligible && children.length === 2 && icons.length === 1 &&
captions.length === 1 && caption.childElementCount === 0 &&
caption.textContent.trim() === '' &&
Array.prototype.every.call(item.childNodes, function (node) {
return node.nodeType === 1 || (node.nodeType === 3 && node.textContent.trim() === '');
});
if (onlyIcons) {
if (item.getAttribute(marker) !== 'true') item.setAttribute(marker, 'true');
} else if (item.hasAttribute(marker)) {
item.removeAttribute(marker);
}
}
function scan(root) {
if (!root || !root.isConnected) return;
var element = root.nodeType === 1 ? root : root.parentElement;
if (element) {
var item = element.closest(discover);
if (item) update(item);
}
if (root.querySelectorAll) root.querySelectorAll(discover).forEach(update);
}
function flush() {
scheduled = false;
var roots = Array.from(pending);
pending.clear();
roots.forEach(scan);
}
function queue(root) {
if (!root) return;
pending.add(root);
if (!scheduled) {
scheduled = true;
(window.requestAnimationFrame || window.setTimeout).call(window, flush);
}
}
function start() {
scan(document);
var observer = new MutationObserver(function (records) {
records.forEach(function (record) {
queue(record.target);
if (record.addedNodes) record.addedNodes.forEach(queue);
});
});
observer.observe(document.body, {
childList: true,
characterData: true,
subtree: true,
attributes: true,
attributeFilter: ['class', 'role']
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', start, { once: true });
} else {
start();
}
}());
Install and check the result
Back up your existing custom files, then add the helper through your installation’s configured custom-resource loading mechanism. Preserve the other contents of custom.js and custom.css. The actual resource directory and loading setup depend on your deployment; placing files in an arbitrary directory does not load them into Ninox.
In our installation, updating the already loaded custom files required no Ninox server restart. Refresh the web client with Ctrl+F5 and check that the updated files are actually loaded. Already open tabs do not receive the change automatically.
Test several icons, repeated connection types, no active routes, scrolling, labelled multiple-choice entries, forms and popups. Our selectors and marker behavior passed 63 automated checks across the two renderer structures, and the resulting view was accepted in our live setup. Those checks do not establish compatibility with every Ninox release. The CSS requires a modern browser with :is() and :has() support.
Keep descriptive connection names available elsewhere or provide a legend, because an icon-only column loses its visible labels. After Ninox updates, inspect the table structure and retest. To roll back the spacing adjustment, remove these helper blocks and reload the browser; the selected field values remain in the database.
What we gained
Our overview now shows the active connection types as compact native Ninox icons, with their original colors, inside one table-view column. We did not find a suitable ready-made solution during our research; using the multiple-choice renderer was the practical route that worked in our installation.

