> For the complete documentation index, see [llms.txt](https://docs.ninox.com/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.ninox.com/builder-hub/visualize-and-organize-your-data/create-and-customize-pages/custom-component.md).

# Custom component

Build custom, data-driven page interfaces with Ninox Script and JSX-like markup.

The **Custom component** lets you build custom UI on a page by writing a single Ninox Script expression that returns a piece of markup.\
You write JSX‑like tags directly in Ninox Script. Mix in your live record data, wire up buttons and events, and drop in ready‑styled building blocks. All without a separate widget file, bundler, or upload step.

If you already know how to write a Function field, you know most of this. A Custom component expression is evaluated the same way, scoped to the current record. It just returns UI instead of a number or text.

## **Add a Custom component to a page**

{% stepper %}
{% step %}
**Open the Add tab**

In the **Settings** panel, open **Add**.
{% endstep %}

{% step %}
**Add Custom component**

Under **Basic components**, drag **Custom component** to the preferred location on the page.
{% endstep %}

{% step %}
**Configure Custom component**

In the component **Settings**, expand **General**.\
Under **Logic**, select <i class="fa-code-simple">:code-simple:</i> to open the script editor.\
Enter the expression that returns your Custom component.
{% endstep %}

{% step %}
**Review the result**

Check whether the component shows the expected result.\
Then drag the component to move it on the page.\
Use the resize handles to adjust its size.
{% endstep %}
{% endstepper %}

{% hint style="info" %}
Until you enter an expression, you'll see the placeholder *"Enter a Ninox script expression that returns HTML."* The editor gives you AI assistance that already knows the Custom component syntax and constraints described here.
{% endhint %}

## Write your first expression

Your expression must return the **`react`** type, a single root element built with tag syntax. Everything else about Ninox Script still works: `let`, `function`, `if/else`, `for`, `switch`, `select`, and the full function library.

```ninox
<div class="p-4">
  <h2 class="text-lg font-bold">Hello, {first_name}</h2>
  <p class="text-default-500">Welcome back.</p>
</div>
```

The result is rendered directly into the page, scoped to the current record. If the expression doesn't return the `react` type, you'll see: *"The expression must return the react type. Use HTML tag syntax to build your UI."*

## Tags: HTML and components

Element syntax is JSX‑like:

```ninox
<tag ...attributes>...children...</tag>     // with children
<tag ... />                                  // self-closing
```

The **first letter of the tag name** decides what it is:

| Tag style       | Meaning             | Examples                                                                          |
| --------------- | ------------------- | --------------------------------------------------------------------------------- |
| **lowercase**   | Native HTML element | `div`, `span`, `p`, `button`, `input`, `a`, `img`, `ul`, `li`, `h1`–`h6`, `table` |
| **Capitalized** | A component         | Your own `function`, or a built‑in like `<Button>`, `<Card>`, `<Field>`           |

Standard HTML attributes work on lowercase tags: `class`, `id`, `style`, `href`, `src`, `type`, `placeholder`, etc.

For Capitalized tags, name resolution is: your own `function` of that name wins, otherwise a built‑in component of that name is used, otherwise you get a *"Function not found"* warning. To avoid surprises, don't name your own functions after a built‑in tag.

## Attributes: static and dynamic

**Quoted string**: a literal value

```ninox
<div class="card">
```

**Curly braces**: a Ninox expression evaluated at render time

```ninox
<h2 title={first_name}>
<div class={if active then "bg-success" else "bg-default-200" end}>
```

**Bare attribute**: shorthand for `={true}`

```ninox
<Button isIconOnly>          // same as isIconOnly={true}
```

Plain text renders as‑is.

```ninox
<span>Hello</span>
```

`{ ... }` embeds an expression value as text.

```ninox
<span>{first_name}</span>
<div>Total: {format(amount, "#,##0.00")}</div>
```

Build **lists** with a loop that returns an array of elements.

```ninox
<ul>
  {for item in items do
    <li>{item.name}</li>
  end}
</ul>
```

## Using your record data

The expression runs scoped to the current record, exactly like a Function field:

* Reference a field by name, such as `first_name`, `amount`, or `status`.
* Reach related records, such as `Contacts.email` or `line_items`.
* Use the full Ninox Script function library, such as `select`, `sum`, `format`, `openRecord`, `alert`, and `icon`.

{% hint style="success" %}
Use `{ field_name }` when you only need the value as text. Use the `<Field>` tag when you want the real, editable widget.
{% endhint %}

## Event handlers

Any attribute whose name starts with `on`, for example, `onClick`, `onChange`, or `onPress`, is an event handler. It runs when the event fires, not at render time. It must be a function.

```ninox
// Correct — a function that runs on click
<button onClick={function () do alert("Hi") end}>Click me</button>

// Wrong — this runs immediately at render, not on click
<button onClick={alert("Hi")}>Click me</button>
```

Handlers may perform side effects, for example, show a message, open a record, write to a field, or update reactive state.

```ninox
<Button onPress={function () do status := "done" end}>Mark done</Button>
```

To read data about the event, take a parameter.

```ninox
<input onChange={function (e: any) do debug(e) end} />
```

The event you receive is a safe snapshot, a plain object with only the relevant primitive fields, for example:

* a mouse event exposes `clientX` / `button`
* a keyboard event exposes `key` / `code`, plus a nested `target` (`id`, `name`, `value`, `checked`, `tagName`, etc.)

Live DOM nodes never cross into your script.

{% hint style="success" %}
For HeroUI components like `<Button>`, prefer `onPress` over `onClick`. It behaves consistently across mouse, touch, and keyboard.
{% endhint %}

## Reactive state with `let`

A `let` inside a Custom component expression is a reactive component state. Think of it like a small piece of UI memory:

* Its initializer runs once to seed the value (like a default). Later renders reuse the stored value.
* Reassigning it (`:=`) from inside an event handler stores the new value and re‑renders the component.
* Only reassignments made inside a handler persis&#x74;**.** A `let` you reassign during rendering, for example, a loop total, resets every render and stays an ordinary computed value.
* State is per instance: a `let` inside a loop body or inside a Capitalized `function` component gets its own independent value each time.
* State resets when the record changes or when you edit the expression.

Counter example:

```ninox
let count := 0;
<Button onPress={function () do count := count + 1 end}>
  { "Clicked " }
	{ count }
	{ " times" }
</Button>
```

Per‑instance state inside a component used in a loop:

```ninox
function Counter(label: text) do
  let n := 0;
  <div class="flex items-center gap-2">
    <span>{label}</span>
    <Button size="sm" onPress={function () do n := n + 1 end}>{n}</Button>
  </div>
end;
<div>{for t in tasks do <Counter label={t.title} /> end}</div>
```

A render‑time computation (not state, recomputed every render):

```ninox
let total := 0;
for i in line_items do total := total + i.amount end;
<div>Total: {total}</div>
```

{% hint style="info" %}
**State vs. fields**\
Reassigning a field `status := "done"` writes to the database. Reassigning a **`let`** only updates in‑memory UI state.
{% endhint %}

## The `<Field>` tag

`<Field>` embeds an actual database field with its native Ninox editor. It can render fields such as a text input, date picker, choice dropdown, file upload, or reference picker. It is not just a formatted value. The tag is self‑closing.

```ninox
<Field field="first_name" />
```

### Attributes

<table><thead><tr><th width="113.92578125">Attribute</th><th width="106.45703125">Required</th><th>Description</th></tr></thead><tbody><tr><td><code>field</code></td><td>✅</td><td>The field's script name.</td></tr><tr><td><code>module</code></td><td></td><td>Module name. Defaults to the current scope.</td></tr><tr><td><code>table</code></td><td></td><td>Table name. Defaults to the current scope.</td></tr><tr><td><code>row</code></td><td></td><td>Row id (number). Defaults to the current scope's row.</td></tr><tr><td><code>readonly</code></td><td></td><td>Renders the field display‑only.</td></tr></tbody></table>

### How targeting works

* By default the field binds to the current module, table, and row.

  ```ninox
  <Field field="first_name" />
  ```
* Each of `module`, `table`, `row` independently falls back to the surrounding scope, so you only specify what differs.
* Once you override `module` or `table`, the current row no longer applies (it belongs to a different table), so you must give an explicit `row`:

  ```ninox
  <Field module="crm" table="contacts" row={1} field="first_name" />
  ```
* `row` accepts a number (`row={1}`), a numeric string (`row="1"`), or an expression (`row={this.id}`, `row={item.id}`).
* Make a field display‑only with `readonly`:

  ```ninox
  <Field field="status" readonly />
  ```

Edits made in a `<Field>` write back to the target record (unless `readonly`).

### Reference and reverse‑reference fields

`<Field>` fully supports reference and reverse‑reference fields. The picker renders just like it does elsewhere in Ninox. For these types the embedded picker shows the referenced table's first 5 columns.

{% hint style="success" %}
**Rule of thumb:** Use `<Field field="…" />` for the real interactive widget; use `{ field_name }` when you only need the value as text.
{% endhint %}

## Built‑in components (HeroUI)

Capitalized tags for these ready‑styled components are available out of the box. They always follow the active light/dark theme.

### **Available components**

| <ul><li><code>Button</code></li><li><code>Icon</code></li><li><code>Card</code></li><li><code>CardHeader</code></li><li><code>CardBody</code></li><li><code>CardFooter</code></li><li><code>Modal</code></li><li><code>ModalContent</code></li><li><code>ModalHeader</code></li></ul> | <ul><li><code>ModalBody</code></li><li><code>ModalFooter</code></li><li><code>Popover</code></li><li><code>PopoverTrigger</code></li><li><code>PopoverContent</code></li><li><code>Progress</code></li><li><code>Tabs</code></li><li><code>Tab</code></li></ul> | <ul><li><code>Table</code></li><li><code>TableHeader</code></li><li><code>TableColumn</code></li><li><code>TableBody</code></li><li><code>TableRow</code></li><li><code>TableCell</code></li><li><code>Tooltip</code></li><li><code>Field</code></li></ul> |
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |

### Button

Label goes in the children.

* Common props: `color`, `variant`, `size`, `radius`
* Boolean props: `isDisabled`, `isLoading`, `isIconOnly`, `fullWidth`
* Prefer `onPress`

```ninox
<Button color="danger" variant="flat" onPress={function () do alert("Deleted") end}>
  Delete
</Button>
```

### Card

```ninox
<Card shadow="sm" radius="lg">
  <CardHeader>Summary</CardHeader>
  <CardBody>{description}</CardBody>
  <CardFooter class="text-default-500">Updated today</CardFooter>
</Card>
```

`Card` supports `shadow`, `radius`, `fullWidth`, `isHoverable`, `isPressable`.

### Tabs

Each `<Tab>` needs a unique `key` and a `title`.

```ninox
<Tabs>
  <Tab key="details" title="Details"><div>...</div></Tab>
  <Tab key="history" title="History"><div>...</div></Tab>
</Tabs>
```

### Table

Cell count must match the columns; give each row a unique `key`.

```ninox
<Table isStriped>
  <TableHeader>
    <TableColumn>Name</TableColumn>
    <TableColumn>Amount</TableColumn>
  </TableHeader>
  <TableBody>
    {for i in line_items do
      <TableRow key={i.id}>
        <TableCell>{i.name}</TableCell>
        <TableCell>{format(i.amount, "#,##0.00")}</TableCell>
      </TableRow>
    end}
  </TableBody>
</Table>
```

`Table` supports `isStriped`, `isCompact`, `hideHeader`, `removeWrapper`, `selectionMode`.

### Progress

```ninox
<Progress value={percent} maxValue={100} color="success" showValueLabel />
```

### Tooltip

`content` plus a single trigger child; props `placement`, `color`, `delay`, `closeDelay`.

```ninox
<Tooltip content="Delete this record" placement="top">
  <Button isIconOnly><Icon name="trash" /></Button>
</Tooltip>
```

### Popover

```ninox
<Popover placement="bottom" showArrow>
  <PopoverTrigger><Button>Options</Button></PopoverTrigger>
  <PopoverContent><div class="p-2">...</div></PopoverContent>
</Popover>
```

### Modal

Drive visibility with a boolean `isOpen` bound to a reactive `let`. Sizes range `sm`…`5xl`/`full`. This example also embeds editable `<Field>`s:

```ninox
let open := false;
<div>
  <Button onPress={function () do open := true end}>Edit contact</Button>
  <Modal isOpen={open} onOpenChange={function () do open := false end}>
    <ModalContent>
      <ModalHeader>Edit contact</ModalHeader>
      <ModalBody class="flex flex-col gap-2">
        <Field field="first_name" />
        <Field field="email" />
      </ModalBody>
      <ModalFooter>
        <Button variant="light" onPress={function () do open := false end}>Close</Button>
      </ModalFooter>
    </ModalContent>
  </Modal>
</div>
```

`Modal` supports:

* `isOpen`
* `onOpenChange`
* `onClose`
* `size`
* `placement`
* `backdrop`
* `radius`
* `scrollBehavior`
* Booleans:
  * `isDismissable`
  * `isKeyboardDismissDisabled`
  * `hideCloseButton`
  * `shouldBlockScroll`.

## Custom components with `function`

Define reusable pieces with a Capitalized `function`:

```ninox
function Badge(label: text) do
  <span class="badge">{label}</span>
end;
<Badge label="New" />
```

* Attributes bind to parameters by name (not by position). Unmatched parameters receive `null`.
* A parameter named `children` (type `react[]`) receives the nested child nodes:

  ```ninox
  function Card2(children: react[]) do
    <div class="card">{children}</div>
  end;
  <Card2><p>Inside the card</p></Card2>
  ```

Compose your UI from small, reusable components.

## Styling with Tailwind classes

Style with Tailwind utility classes in the `class` attribute. There's one important rule:

{% hint style="success" %}
**Only a fixed, curated set of Tailwind utilities is available.** Because your class strings are computed at runtime, Tailwind can't scan them at build time.\
Any class **outside the supported set silently has no effect**, and **arbitrary‑value classes never work,** anything with square brackets like `w-[473px]`, `bg-[#1e90ff]`, or `[&>div]:…`.
{% endhint %}

For genuinely custom values, use the `style` attribute instead:

```ninox
<div style={"width: " + text(px) + "px"}>
```

### What's available

#### Layout

* `block inline-block inline flex inline-flex grid hidden`
* `flex-row/col/wrap`
* `items-*`
* `justify-*`
* `self-*`
* `grid-cols-1..6` (+`12`)
* `col-span-1..6/full`
* `flex-1 grow shrink`

#### Spacing

* `p/px/py/pt/pr/pb/pl`
* `m…` (incl. `m*-auto`)
* `gap/gap-x/gap-y`
* `space-x/space-y` on the scale `0, 0.5, 1, 1.5, 2, 2.5, 3, 3.5, 4, 5, 6, 7, 8, 10, 12, 14, 16, 20, 24`

#### Sizing

* `w-full/auto/fit/screen` and fractions `w-1/2`, `w-1/3`, `w-2/3`, `w-1/4`, etc.
* `max-w-xs..4xl/full`
* `min-w-0`
* fixed heights `h-4 … h-64`
* `h-full/auto/fit`
* `max-h-40/60/80/96/full`
* `size-4/6/8/10/12`

{% hint style="warning" %}
There are no root‑height utilities like `h-screen.` Don't try to set the component's overall height.
{% endhint %}

#### Typography

* `text-xs..4xl`
* `font-thin..extrabold`
* `text-left/center/right/justify`
* `italic`, `underline`, `line-through`, `uppercase`, `lowercase`, `capitalize`, `truncate`, `whitespace-nowrap`, `break-words`
* `leading-*`
* `tracking-*`

#### Colors

* `text-`/`bg-`/`border-` in every standard Tailwind family (`slate`…`rose`) at shades `50`–`900`, plus `white`, `black`, `transparent`, `current`.
* Prefer the semantic theme tokens:\
  `default`, `primary`, `secondary`, `success`, `warning`, `danger`, `foreground`, `content1-4`, `divider`, `overlay` (optionally `-50..900` or `-foreground`), for example, `bg-primary`, `text-foreground`, `bg-content1`, `text-default-500`, `border-divider`. These adapt to light/dark automatically.

#### Borders and radius

* `border border-0/2/4/8`
* side borders\
  `border-solid/dashed/dotted`\
  `rounded … rounded-full`

#### Effects

* `shadow shadow-sm..2xl shadow-inner`
* `ring ring-0/1/2/4`
* `opacity-0/25/50/75/90/100`.

#### Position and overflow

* `static`, `relative`, `absolute`, `fixed`, `sticky`
* `inset-0`
* `top/right/bottom/left-0`
* `z-0..50`
* `overflow-*`

#### Backgrounds / fit / aspect

* `bg-cover/contain/center`
* `object-cover/contain/fill`
* `aspect-square/video`

#### Interactivity and motion

* `cursor-*`
* `select-*`
* `pointer-events-*`
* `transition`, `transition-colors`, `transition-transform`
* `duration-100/150/200/300/500`
* `ease-*`
* `scale-95/100/105`
* `rotate-45/90`

### Variant prefixes

Only these prefixes are generated:

* `hover:`, `focus:`, `dark:` on color utilities
* `md:`, `lg:` on layout, spacing, sizing, and text utilities

**Not available:** `active:`, `disabled:`, `group-hover:`, `sm:`, `xl:`, `2xl:`. For states like active/disabled, drive them with an event handler plus a reactive `let`, or compute the class string yourself:

```ninox
<div class={if selected then "bg-primary text-white" else "bg-content1" end}>
```

{% hint style="success" %}
The built‑in HeroUI components are always fully styled regardless of the safelist, so reaching for `<Card>`, `<Button>`, etc. is the easiest way to get a polished look.
{% endhint %}

## The "Use shadow DOM" option

Each Custom component has a **Use shadow DOM** toggle (default: **off**).

When enabled, the component renders inside a shadow DOM, which isolates its styles. The component's CSS can't affect the rest of the page, and the page's CSS can't leak in. Enable it when a component's styling should be fully self‑contained.

Notes:

* Tailwind and HeroUI classes still resolve inside the shadow DOM. The theme stylesheet is loaded into the shadow root for you.
* Because that stylesheet loads asynchronously, content may flash briefly unstyled on first render.
* Interactivity (events, HeroUI popovers, focus handling) works across the shadow boundary. There are no functional differences.

Leave it off unless you specifically need style isolation.

## Security and limitations

**How it's kept safe**

* Your expression produces a structured element tree, not an HTML string. There's no raw HTML/JS injection surface.
* Certain tags are blocked (dropped): `script`, `iframe`, `object`, `embed`, `link`, `meta`, `base`, `body`, `head`, `html`, `title`. (`<style>` is allowed and is scoped when shadow DOM is on.)
* URLs on `href`, `src`, `action`, and similar attributes are screened. Only `http`, `https`, `mailto`, and `tel` schemes are allowed. `data:` is permitted only on `src` for inline images.
* Event handlers are Ninox Script only, there's no execution of JavaScript strings. Handlers run with your normal script permissions.

This is content isolation for a trusted workspace builder, meaning the author of the expression. (For running fully untrusted third‑party code, use Custom Widgets, which run in a sandboxed iframe.)

**Things to keep in mind**

* The expression must return the `react` type (a single root element) or nothing renders.
* Only the curated Tailwind vocabulary works; no arbitrary values; limited variant prefixes.
* Don't set the component's overall height on the root element, the page manages sizing.
* Embedded reference / reverse `<Field>` pickers show the first 5 columns of the referenced table.
* Reactive `let` state resets when the record changes or the expression is edited; only handler‑phase reassignments persist.
* If one of your components throws an error, it is contained to that component. It will not tear down the whole page.

## Troubleshooting

<table data-search="false"><thead><tr><th>Symptom</th><th>Likely cause / fix</th></tr></thead><tbody><tr><td><em>"The expression must return the react type."</em></td><td>Your expression doesn't end with a tag. Make the last value an element.</td></tr><tr><td>A CSS class does nothing</td><td>It's outside the supported vocabulary or uses an arbitrary value (<code>[...]</code>). Use a supported class or the <code>style</code> attribute.</td></tr><tr><td>Nothing happens on click</td><td>The handler must be a function: <code>onClick={function () do … end}</code>, not <code>onClick={doThing()}</code>.</td></tr><tr><td>A counter/toggle resets instantly</td><td>You reassigned the <code>let</code> during render instead of inside a handler; only handler reassignments persist.</td></tr><tr><td><em>"Function not found"</em> for a Capitalized tag</td><td>It's neither a defined <code>function</code> nor a built‑in component. Check spelling/casing.</td></tr><tr><td><code>&#x3C;Field></code> error: <em>"has no row in scope"</em></td><td>You set <code>module</code>/<code>table</code>, so you must also supply an explicit <code>row</code>.</td></tr><tr><td><code>&#x3C;Field></code> error: <em>"can only be used inside a Custom component"</em></td><td><code>&#x3C;Field></code> only works within a Custom component expression.</td></tr><tr><td>A tag is missing from the output</td><td>It may be a blocked tag (<code>script</code>, <code>iframe</code>, …) or a URL with a disallowed scheme.</td></tr></tbody></table>

{% hint style="info" %}
The in‑editor AI assistant knows all of the syntax and constraints above, so you can also describe what you want and let it draft the expression for you.
{% endhint %}


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://docs.ninox.com/builder-hub/visualize-and-organize-your-data/create-and-customize-pages/custom-component.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
