---
title: "HTML components"
url: https://flexie.io/resources/customer-portals/html-components
description: "Your own markup on a portal page, rendered on the server against the signed-in customer, with scoped styles, private scripts and the handful of rules that keep one component out of another's way."
---

# HTML components

Last updated 14 August 2026

![An HTML component's markup with Flexie Scripting tokens rendering on the server into a finished panel showing the signed-in customer's name](https://flexie.io/image/resources/customer-portals-html-components.webp)

Drag an **HTML** component onto the page and write into its **Content** editor. That is the whole authoring surface: markup, styles and scripts in one place.

## It is rendered on the server, for one customer

Your content is not shipped to the browser as you typed it. It goes through **Flexie Scripting** first, with the signed-in customer in context, and only the result crosses:

```html
<div class="welcome">
  <h2>Hello {{ entity.first_name }}</h2>
  <p>Your account number is {{ entity.id }}.</p>
</div>
```

The customer's browser receives _Hello Melissa_. The template never reaches it, so nothing is exposed and there is nothing to wait for: by the time the page exists it is already about somebody.

**So you usually need no JavaScript at all.** The whole record is available: every field on the customer entity, by its alias. The editor's **Dynamics** menu inserts the tokens for you, and the full language reference is [Flexie Scripting](https://flexie.io/resources/flexie-scripting/overview).

### Run it before you save it

The editor has a **Run** button. It renders what you have written (or just the selection) and shows the result in a pane, which is how you find a mistake in a token before a customer does.

This matters more here than elsewhere: a template with a syntax error does not throw. Without checking, the mistake reaches the page as unrendered `{{ ... }}` with nothing anywhere saying so.

## Styles: yours, and only yours

Write a `<style>` block and it is **scoped to this component**. Your `.card` rule reaches your cards and nothing else on the page, so two components can both use `.card`, `.panel` and `.grid` without knowing the other exists.

```html
<style>
  :root {
    --accent: #0b84cf;
  }
  .card {
    border: 1px solid #d3dae5;
    border-radius: 8px;
    padding: 16px;
  }
</style>
```

Two rules that follow from the scoping:

* **`:root` means this component's own root.** It is where custom properties go, and it is what you style to give the component itself a background, a font or a padding.
* **A `body { }` rule matches nothing.** `body` is outside the component, so the reset most people paste at the top of a stylesheet is simply inert here. Put what you meant on `:root` instead.

A component drawn inside another one inherits the outer one's styling for anything it does not set itself. That is usually what you want; if a component must be immune, set the properties it cares about rather than relying on the boundary.

Scoped styles need a current browser (Chrome and Edge 118, Safari 17.4, Firefox 128 and later). On anything older the styles are ignored and the component draws unstyled, which is the right way round to fail, because the alternative is one component's reset rearranging the whole portal.

## Scripts: private by default

Write a `<script>` block and it runs when the component is drawn. Everything the portal can do is on **one object**, `FlexiePortal`, which is already there:

```html
<script>
  FlexiePortal.onComponentLoaded('my-component', function (el, params) {
    // el     the element this component was drawn into
    // params why it was opened, when it is a dialog. Empty otherwise

    el.querySelector('.card').addEventListener('click', function () {
      FlexiePortal.openModal('detail')
    })
  })
</script>
```

`'my-component'` is this component's own id, from the settings panel.

**Do your work inside that handler rather than at the top of the script.** Three reasons, and each of them is a bug people hit:

* it is what hands you your element, so you are not searching the whole page for markup you already own;
* it runs again when your component is redrawn, and a dialog is redrawn on every opening, so your code stays correct instead of running once;
* it is what makes your component work on the **builder canvas**, where you are looking at it while you write it.

Everything else the portal exposes, and every other event, is [The JavaScript API](https://flexie.io/resources/customer-portals/javascript-api).

> **There is no `el`, `api` or `params` variable at the top of your script.** Everything the portal offers comes from `FlexiePortal`, and your element reaches you as an argument to the handler above.

Nothing you declare escapes to the page, so `var rows` in one component cannot collide with `var rows` in another. Several `<script>` blocks in one component run as one piece, in order, and can see each other's variables.

**A script that fails is logged and the markup still draws.** A blank box tells the reader less and you nothing, so open the browser console when something quietly does not happen.

If your component starts something that outlives its markup, such as a timer or a listener on `window`, stop it in `FlexiePortal.onComponentUnloaded`. Anything attached inside your own element needs no cleanup at all.

## What cannot come across

Four tags are dropped when you save, and the editor says which:

| Dropped                 | Why                                                          |
| ----------------------- | ------------------------------------------------------------ |
| <title>, <meta>         | they name a document this is not                             |
| <base>                  | it would retarget every link on the portal                   |
| <link rel="stylesheet"> | an outside stylesheet is the one thing that cannot be scoped |
| <script src="...">      | there is nothing to tear down, and nothing to scope          |

Everything else survives, including whole pages pasted in: the parts that matter are taken out of them.

## Two ways the save quietly changes your markup

Your content is parsed as HTML when it is saved, so the styles and scripts can be separated from it. That parse has opinions, and neither of these warns you.

### 1\. Bare text at the top level becomes a paragraph

Statements sitting outside any element are text, and stray text gets wrapped in a paragraph, whose margin then pushes your component down the page.

```html
<!-- pushes everything down -->
{% set name = entity.first_name %}
<div class="panel"> ... </div>

<!-- correct -->
<div class="panel">
  {% set name = entity.first_name %}
  ...
</div>
```

### 2\. A tag written inside a comment becomes a real tag

A Flexie Scripting comment (`{# ... #}`) is not an HTML comment. The parser does not know the text is commented out, so a tag written in one is opened for real and stays open.

```
{# WRONG: this opens a real paragraph #}
{# each row is drawn as a <p> #}

{# RIGHT #}
{# each row is drawn as a paragraph #}
```

Both are invisible until you look at what is actually stored, which is worth doing once after writing a component.

## The frame, and the height

Two settings decide how the component sits on the page.

**Wrap the component** (on by default) draws it in the usual panel, with its name as the title. Turn it off and there is no panel at all: what the customer sees is exactly your markup, edge to edge. With no panel there is no title bar, so the Name field is kept but shown read-only. Turning the wrapper back on does not mean typing it again.

**Full width, as tall as its content** is for a document whose length nobody can know: a statement that is one page for one customer and three for another. It takes the component out of the grid, gives it the full width where you drew it, and lets it grow to whatever it needs. Everything below moves down by exactly that much.

Without it, a component that outgrows its rectangle is **clipped**. If your markup varies in length, turn it on.

> If what your component draws changes size _while the page is open_ (a board that grows as the customer opens sections), leave its drawn rectangle modest. The height you drew is kept as a floor, and a generous one leaves a permanent gap under the shortest state.

## Dialogs

Do not draw your own dialog. Add a **Modal** component, write its markup the same way, and open it:

```js
FlexiePortal.openModal('detail', { params: { orderId: 5002 } })
```

You get a dialog centred in the window with the product's header, close button, Escape key and backdrop, none of which your markup has to own or keep working.

Inside the dialog, the same load event tells it which record it is about:

```js
FlexiePortal.onComponentLoaded('detail', function (el, params) {
  // params.orderId is 5002
})
```

A dialog is rebuilt every time it opens, so that fires once per opening, always with the parameters of that opening.

## A worked example

A small panel that greets the customer and shows how complete their details are. Everything in it is resolved on the server; there is nothing to fetch.

```html
<style>
  :root {
    --ink: #1b2432;
    --muted: #55637a;
    --line: #d3dae5;
  }
  .panel {
    padding: 20px 22px;
    border: 1px solid var(--line);
    border-radius: 8px;
    background: linear-gradient(100deg, #fff 0%, #eaf3fb 100%);
    color: var(--ink);
  }
  .panel h2 { margin: 0; font-size: 20px; }
  .panel p  { margin: 4px 0 0; color: var(--muted); font-size: 13px; }
  .meter    { height: 6px; border-radius: 999px; background: #dde5ee; }
  .meter i  { display: block; height: 100%; background: #0b84cf; }
</style>

<div class="panel">
  {% set filled = 0 %}
  {% if entity.first_name %}{% set filled = filled + 1 %}{% endif %}
  {% if entity.company %}{% set filled = filled + 1 %}{% endif %}
  {% if entity.city %}{% set filled = filled + 1 %}{% endif %}

  <h2>
    Welcome back{% if entity.first_name %}, {{ entity.first_name }}{% endif %}
  </h2>
  <p>
    {% if entity.company %}{{ entity.company }}
    {% else %}Everything we hold for you is on this page{% endif %}
  </p>

  <div class="meter"><i style="width: {{ (filled * 33) }}%"></i></div>
</div>
```

Note where the setup sits: **inside** the root element, for the reason above.
