Skip to content

UI Components

Stina provides a declarative component system for building UIs in extensions. Instead of writing HTML and CSS, you define your UI as JSON structures in the manifest or return them from actions. The component system is used in three places:

  • Tool settings (contributes.toolSettings with kind: "component") — settings pages shown in the extension’s configuration area.
  • Panels (contributes.panels) — side panel views visible during conversations.
  • Modals — dialog overlays triggered from actions.

Every component is a JSON object with a component field that identifies the type. Additional fields depend on the component type. All components accept an optional style object for inline CSS (restricted to safe properties).

{
"component": "ComponentName",
"style": { "padding": "1rem" }
}

Components that contain other components use children, content, body, or footer depending on the component type.

Use kind: "component" in a tool settings view definition. The data object defines data sources whose keys become scope variables. The content object is the root component to render.

{
"contributes": {
"toolSettings": [
{
"id": "my-settings",
"title": "My Settings",
"view": {
"kind": "component",
"data": {
"settings": {
"action": "getSettings",
"refreshOn": ["settings.changed"]
}
},
"content": {
"component": "VerticalStack",
"gap": 1,
"children": [
{
"component": "TextInput",
"label": "API Key",
"value": "$settings.apiKey",
"onChangeAction": {
"action": "updateSetting",
"params": { "key": "apiKey", "value": "$value" }
}
}
]
}
}
}
]
}
}

Panels appear in the right sidebar. They use the same kind: "component" structure.

{
"contributes": {
"panels": [
{
"id": "my-panel",
"title": "My Panel",
"icon": "check-list",
"view": {
"kind": "component",
"data": {
"items": {
"action": "getItems",
"refreshOn": ["item.changed"]
}
},
"content": {
"component": "VerticalStack",
"gap": 1,
"children": []
}
}
}
]
}
}

Panels require the panels.register permission. Data source actions require the actions.register permission.

Stacks children vertically. The gap property controls spacing between items (in rem units as a number).

{
"component": "VerticalStack",
"gap": 1,
"children": [
{ "component": "Label", "text": "First item" },
{ "component": "Label", "text": "Second item" }
]
}

Stacks children horizontally. Commonly used with align-items in the style to control vertical alignment.

{
"component": "HorizontalStack",
"gap": 0.5,
"style": { "align-items": "center", "justify-content": "space-between" },
"children": [
{ "component": "Label", "text": "Left side" },
{ "component": "Pill", "text": "Active", "variant": "success" }
]
}

Grid layout with a fixed number of columns.

{
"component": "Grid",
"columns": 2,
"gap": 1,
"children": [
{ "component": "Label", "text": "Cell 1" },
{ "component": "Label", "text": "Cell 2" },
{ "component": "Label", "text": "Cell 3" },
{ "component": "Label", "text": "Cell 4" }
]
}

Stacked card list where items share borders and the first/last items have rounded corners. Each child (or each data item when using iteration) becomes a separate list item.

Static children:

{
"component": "List",
"children": [
{ "component": "Label", "text": "Item one" },
{ "component": "Label", "text": "Item two" },
{ "component": "Label", "text": "Item three" }
]
}

With iteration:

{
"component": "List",
"children": {
"each": "$contacts",
"as": "contact",
"items": [
{
"component": "HorizontalStack",
"gap": 0.5,
"style": { "align-items": "center", "justify-content": "space-between" },
"children": [
{ "component": "Label", "text": "$contact.name" },
{ "component": "Pill", "text": "$contact.role", "variant": "primary" }
]
}
]
}
}

Short text display. The text property supports data binding with $ references.

{ "component": "Label", "text": "Account Name" }
{ "component": "Label", "text": "$account.email", "style": { "font-size": "0.85em", "opacity": "0.7" } }

Longer text blocks.

{ "component": "Paragraph", "text": "This extension monitors your inbox for new messages." }

Section header with a heading level (1—6). Supports an optional description and icon.

{
"component": "Header",
"level": 2,
"title": "Settings",
"description": "Configure your extension preferences.",
"icon": "settings-01"
}

The description can also be an array of strings, which renders as multiple lines.

Render markdown content. Note the property is called content, not text.

{ "component": "Markdown", "content": "**Bold** and *italic* text with [links](https://example.com)." }

Compact markdown preview that truncates after a set number of lines (default 5) and shows a toggle to expand/collapse. Useful for displaying longer text in a compact space, such as descriptions or notes in a list.

{ "component": "TextPreview", "content": "A longer markdown text that may span **multiple lines**.\n\nIt will be truncated after the default 5 lines with a chevron toggle to expand." }

Custom line limit:

{ "component": "TextPreview", "content": "$item.description", "maxLines": 3 }

Horizontal line separator.

{ "component": "Divider" }

Display an icon by name. Icons come from the Huge Icons set.

{ "component": "Icon", "name": "mail-01", "title": "Email" }

Note: The property is name, not icon.

Small badge or tag. Supports predefined color variants: default, primary, success, warning, danger, accent.

{ "component": "Pill", "text": "Connected", "variant": "success" }
{ "component": "Pill", "text": "$account.providerLabel", "variant": "$account.statusVariant", "icon": "mail-01" }

All input components require an onChangeAction that is called when the value changes. The new value is available as $value in action parameters.

Text field with a label.

{
"component": "TextInput",
"label": "Display Name",
"placeholder": "Work Email",
"value": "$editState.form.name",
"onChangeAction": {
"action": "updateFormField",
"params": { "field": "name", "value": "$value" }
}
}

Date/time picker.

{
"component": "DateTimeInput",
"label": "Due Date",
"value": "$todo.dueAt",
"onChangeAction": { "action": "setDueDate" }
}

Dropdown with static options. The currently selected value is set via selectedValue (not value).

{
"component": "Select",
"label": "Provider",
"options": [
{ "label": "iCloud", "value": "icloud" },
{ "label": "Gmail", "value": "gmail" },
{ "label": "Outlook", "value": "outlook" },
{ "label": "Generic IMAP", "value": "imap" }
],
"selectedValue": "$editState.form.provider",
"onChangeAction": {
"action": "updateFormField",
"params": { "field": "provider", "value": "$value" }
}
}

On/off toggle switch. Uses checked (not value) for the current state.

{
"component": "Toggle",
"label": "All day",
"description": "Mark this as an all-day event",
"checked": "$editModal.todo.allDay",
"disabled": false,
"onChangeAction": {
"action": "updateEditField",
"params": { "field": "allDay", "value": "$value" }
}
}

Checkbox with a label. Uses checked for the current state. When strikethrough is true (the default), the label gets a strikethrough style when checked.

{
"component": "Checkbox",
"label": "Buy groceries",
"checked": "$todo.completed",
"strikethrough": true,
"onChangeAction": {
"action": "toggleTodo",
"params": { "id": "$todo.id" }
}
}

Lets the user choose an icon from the available icon set.

{
"component": "IconPicker",
"label": "Icon",
"value": "$editModal.todo.icon",
"onChangeAction": {
"action": "updateEditField",
"params": { "field": "icon", "value": "$value" }
}
}

Clickable button. Uses onClickAction (not onChangeAction).

{
"component": "Button",
"text": "Add Account",
"onClickAction": "showAddForm"
}

Actions can be a simple string (just the action name) or a full action call object with parameters:

{
"component": "Button",
"text": "Save",
"onClickAction": {
"action": "saveTodo",
"params": { "todoId": "$editModal.todo.id" }
}
}

Icon-only button with a tooltip. Supports type for color variants: normal, primary, danger, accent.

{
"component": "IconButton",
"icon": "pencil-edit-01",
"tooltip": "Edit todo",
"onClickAction": {
"action": "openEditTodo",
"params": { "todoId": "$todo.id" }
}
}
{
"component": "IconButton",
"icon": "delete-02",
"tooltip": "Delete account",
"type": "danger",
"onClickAction": {
"action": "deleteAccount",
"params": { "id": "$account.id" }
}
}

A titled container with an optional icon, description, and action buttons in the header. Content is rendered via the content property (a single component, not children).

{
"component": "Panel",
"title": "$group.title",
"icon": "folder-01",
"description": "Project tasks",
"actions": [
{
"icon": "add-01",
"tooltip": "Add item",
"action": { "action": "addItem", "params": { "groupId": "$group.id" } }
}
],
"content": {
"component": "VerticalStack",
"gap": 0.5,
"children": []
}
}

Expandable/collapsible section with a title header. Content is rendered via the content property.

{
"component": "Collapsible",
"title": "Advanced Settings",
"description": "Additional configuration options",
"icon": "settings-02",
"defaultExpanded": false,
"content": {
"component": "VerticalStack",
"gap": 1,
"children": [
{ "component": "Toggle", "label": "Debug mode", "checked": "$settings.debug", "onChangeAction": "toggleDebug" }
]
}
}

A styled container with an optional collapsible header. Two visual variants: border (default) draws a border around the content, solid uses a solid background. The title can be a plain string or extension components. Content is rendered via children.

{
"component": "Frame",
"title": "Connection Details",
"variant": "border",
"children": [
{ "component": "Label", "text": "$connection.host" },
{ "component": "Label", "text": "$connection.port" }
]
}

Collapsible frame with a solid background:

{
"component": "Frame",
"title": "Advanced Options",
"variant": "solid",
"collapsible": true,
"defaultExpanded": false,
"children": [
{ "component": "Toggle", "label": "Verbose logging", "checked": "$settings.verbose", "onChangeAction": "toggleVerbose" }
]
}

Dialog overlay. Controlled by the open property (bound to a data variable). The body and footer properties each take a single component.

{
"component": "Modal",
"title": "Edit Todo",
"open": "$editModal.modalOpen",
"maxWidth": "500px",
"onCloseAction": "closeEditModal",
"body": {
"component": "VerticalStack",
"gap": 1,
"children": [
{
"component": "TextInput",
"label": "Title",
"value": "$editModal.todo.title",
"onChangeAction": { "action": "updateEditField", "params": { "field": "title", "value": "$value" } }
},
{
"component": "Select",
"label": "Status",
"options": [
{ "label": "Not started", "value": "not_started" },
{ "label": "In progress", "value": "in_progress" },
{ "label": "Completed", "value": "completed" }
],
"selectedValue": "$editModal.todo.status",
"onChangeAction": { "action": "updateEditField", "params": { "field": "status", "value": "$value" } }
}
]
},
"footer": {
"component": "HorizontalStack",
"gap": 0.5,
"style": { "justify-content": "flex-end" },
"children": [
{ "component": "Button", "text": "Cancel", "onClickAction": "closeEditModal" },
{
"component": "Button",
"text": "Save",
"onClickAction": { "action": "saveTodo", "params": { "todoId": "$editModal.todo.id" } }
}
]
}
}

Renders its children only when the condition expression evaluates to true. The condition supports:

  • Comparison operators: ==, !=
  • Logical operators: && (and), || (or)
  • Values: $references, 'strings', numbers, true, false, null
{
"component": "ConditionalGroup",
"condition": "$editState.form.provider == 'imap'",
"children": [
{
"component": "TextInput",
"label": "IMAP Server",
"placeholder": "imap.example.com",
"value": "$editState.form.imapHost",
"onChangeAction": {
"action": "updateFormField",
"params": { "field": "imapHost", "value": "$value" }
}
}
]
}

You can combine conditions with ||:

{
"component": "ConditionalGroup",
"condition": "$form.provider == 'gmail' || $form.provider == 'outlook'",
"children": [
{ "component": "Button", "text": "Connect with OAuth", "onClickAction": "startOAuth" }
]
}

ConditionalGroup does not have an else branch. To handle the opposite case, add a second ConditionalGroup with a negated condition using !=.

Use $variableName syntax to reference data from data sources. The variable names correspond to the keys in the data object of your view definition.

{
"data": {
"settings": { "action": "getSettings" },
"accounts": { "action": "getAccounts" }
}
}

With the above data sources, you can reference $settings.apiKey, $accounts, etc. Nested paths work too: $editModal.todo.title.

Inside input onChangeAction handlers, the special variable $value contains the new value entered by the user.

Use each/as on the children property to render a list of components from data. The each field points to an array in the data, as names the loop variable, and items contains the components to render for each element.

{
"component": "VerticalStack",
"gap": 0.5,
"children": {
"each": "$accounts",
"as": "account",
"items": [
{
"component": "HorizontalStack",
"gap": 0.5,
"style": { "align-items": "center" },
"children": [
{ "component": "Label", "text": "$account.name" },
{ "component": "Pill", "text": "$account.providerLabel", "variant": "$account.statusVariant" }
]
}
]
}
}

Iterators can be nested. For example, a list of groups where each group has a list of items:

{
"component": "VerticalStack",
"gap": 1,
"children": {
"each": "$groups",
"as": "group",
"items": [
{
"component": "Panel",
"title": "$group.title",
"content": {
"component": "VerticalStack",
"gap": 0.5,
"children": {
"each": "$group.items",
"as": "item",
"items": [
{ "component": "Label", "text": "$item.name" }
]
}
}
}
]
}
}

Components trigger extension actions via onClickAction (buttons) and onChangeAction (inputs). Actions can be specified in two ways:

Simple string — just the action name, no parameters:

{ "onClickAction": "showAddForm" }

Action call object — action name with parameters:

{
"onClickAction": {
"action": "deleteAccount",
"params": { "id": "$account.id" }
}
}

Parameters can mix static values and $-prefixed data references. The action name must match a registered action in your extension code:

context.actions?.register('deleteAccount', async (params) => {
const id = params.id as string
await context.storage?.collections.get('accounts').delete(id)
await context.events?.emit('account.changed')
})

Data sources define how a view fetches its data. Each data source calls a registered action and makes the result available as a scope variable.

{
"data": {
"settings": {
"action": "getSettings",
"params": { "includeDefaults": true },
"refreshOn": ["settings.changed"]
}
}
}

The refreshOn array lists event names that trigger an automatic reload of the data source. When any of those events are emitted, Stina re-calls the action and re-renders the UI with the new data.

Emit events from your extension code:

await context.events?.emit('settings.changed')

This requires the events.emit permission.

Only whitelisted CSS properties are permitted in the style object. This restriction exists for security — properties like position, z-index, and pointer-events are blocked to prevent UI spoofing. Values containing url(), expression(), or javascript: are also blocked.

Colors: color, background-color, background, border-color

Borders: border, border-width, border-style, border-radius, border-top, border-right, border-bottom, border-left, border-top-left-radius, border-top-right-radius, border-bottom-left-radius, border-bottom-right-radius

Spacing: padding, padding-top, padding-right, padding-bottom, padding-left, margin, margin-top, margin-right, margin-bottom, margin-left, gap, row-gap, column-gap

Typography: font-size, font-weight, font-style, text-align, text-decoration, line-height, letter-spacing, white-space, word-break, overflow-wrap

Layout: width, height, min-width, min-height, max-width, max-height, flex, flex-grow, flex-shrink, flex-basis, flex-wrap, align-self, justify-self, align-items, justify-content

Visual: opacity, visibility, overflow, overflow-x, overflow-y, box-shadow, outline, cursor, border-collapse, border-spacing

Style values can also use $-prefixed data references:

{ "style": { "background-color": "$item.color" } }

This example shows a tool settings view for managing notification accounts — a simplified version of the pattern used by the Mail Reader extension. It combines data sources, iteration, conditional rendering, modals, and multiple input types.

{
"permissions": [
"tools.register",
"actions.register",
"events.emit",
"storage.collections"
],
"contributes": {
"toolSettings": [
{
"id": "accounts",
"title": "Notification Accounts",
"description": "Configure accounts to monitor.",
"view": {
"kind": "component",
"data": {
"accounts": {
"action": "getAccounts",
"refreshOn": ["account.changed"]
},
"editState": {
"action": "getEditState",
"refreshOn": ["edit.changed"]
}
},
"content": {
"component": "VerticalStack",
"gap": 1,
"children": [
{
"component": "VerticalStack",
"gap": 0.5,
"children": {
"each": "$accounts",
"as": "account",
"items": [
{
"component": "HorizontalStack",
"gap": 0.5,
"style": { "align-items": "center", "justify-content": "space-between" },
"children": [
{
"component": "VerticalStack",
"gap": 0.25,
"children": [
{ "component": "Label", "text": "$account.name" },
{
"component": "Label",
"text": "$account.email",
"style": { "font-size": "0.85em", "opacity": "0.7" }
}
]
},
{
"component": "HorizontalStack",
"gap": 0.25,
"children": [
{
"component": "Pill",
"text": "$account.providerLabel",
"variant": "$account.statusVariant"
},
{
"component": "IconButton",
"icon": "edit-02",
"tooltip": "Edit account",
"onClickAction": {
"action": "editAccount",
"params": { "id": "$account.id" }
}
},
{
"component": "IconButton",
"icon": "delete-02",
"tooltip": "Delete account",
"type": "danger",
"onClickAction": {
"action": "deleteAccount",
"params": { "id": "$account.id" }
}
}
]
}
]
}
]
}
},
{
"component": "Button",
"text": "Add Account",
"onClickAction": "showAddForm"
},
{
"component": "Modal",
"title": "$editState.modalTitle",
"open": "$editState.showModal",
"onCloseAction": "closeModal",
"body": {
"component": "VerticalStack",
"gap": 1,
"children": [
{
"component": "TextInput",
"label": "Display Name",
"placeholder": "Work Account",
"value": "$editState.form.name",
"onChangeAction": {
"action": "updateFormField",
"params": { "field": "name", "value": "$value" }
}
},
{
"component": "TextInput",
"label": "Email Address",
"placeholder": "you@example.com",
"value": "$editState.form.email",
"onChangeAction": {
"action": "updateFormField",
"params": { "field": "email", "value": "$value" }
}
},
{
"component": "Select",
"label": "Provider",
"options": [
{ "label": "IMAP", "value": "imap" },
{ "label": "Gmail", "value": "gmail" },
{ "label": "Outlook", "value": "outlook" }
],
"selectedValue": "$editState.form.provider",
"onChangeAction": {
"action": "updateFormField",
"params": { "field": "provider", "value": "$value" }
}
},
{
"component": "ConditionalGroup",
"condition": "$editState.form.provider == 'imap'",
"children": [
{
"component": "TextInput",
"label": "IMAP Server",
"placeholder": "imap.example.com",
"value": "$editState.form.imapHost",
"onChangeAction": {
"action": "updateFormField",
"params": { "field": "imapHost", "value": "$value" }
}
},
{
"component": "TextInput",
"label": "Password",
"placeholder": "password",
"value": "$editState.form.password",
"onChangeAction": {
"action": "updateFormField",
"params": { "field": "password", "value": "$value" }
}
}
]
},
{
"component": "Toggle",
"label": "Enable notifications",
"description": "Receive alerts for new messages",
"checked": "$editState.form.notificationsEnabled",
"onChangeAction": {
"action": "updateFormField",
"params": { "field": "notificationsEnabled", "value": "$value" }
}
}
]
},
"footer": {
"component": "HorizontalStack",
"gap": 0.5,
"style": { "justify-content": "flex-end" },
"children": [
{
"component": "Button",
"text": "Cancel",
"onClickAction": "closeModal"
},
{
"component": "Button",
"text": "Save",
"onClickAction": "saveAccount"
}
]
}
}
]
}
}
}
]
}
}

The extension code registers the actions referenced in the manifest:

import type { ExtensionModule, ExtensionContext } from '@stina/extension-api'
const extension: ExtensionModule = {
activate(context: ExtensionContext) {
const accounts = context.storage?.collections.get('accounts')
// Data source actions
context.actions?.register('getAccounts', async () => {
const result = await accounts?.find({})
return (result ?? []).map((a: any) => ({
id: a.id,
name: a.name,
email: a.email,
providerLabel: a.provider.toUpperCase(),
statusVariant: a.enabled ? 'success' : 'default',
}))
})
// Edit state is kept in memory (not persisted)
let editState = { showModal: false, modalTitle: 'Add Account', form: {} as any }
context.actions?.register('getEditState', async () => editState)
context.actions?.register('showAddForm', async () => {
editState = {
showModal: true,
modalTitle: 'Add Account',
form: { name: '', email: '', provider: 'imap', notificationsEnabled: true },
}
await context.events?.emit('edit.changed')
})
context.actions?.register('closeModal', async () => {
editState = { ...editState, showModal: false }
await context.events?.emit('edit.changed')
})
context.actions?.register('updateFormField', async (params) => {
editState.form[params.field as string] = params.value
await context.events?.emit('edit.changed')
})
context.actions?.register('saveAccount', async () => {
await accounts?.upsert(editState.form)
editState.showModal = false
await context.events?.emit('edit.changed')
await context.events?.emit('account.changed')
})
context.actions?.register('editAccount', async (params) => {
const account = await accounts?.get(params.id as string)
if (account) {
editState = { showModal: true, modalTitle: 'Edit Account', form: { ...account } }
await context.events?.emit('edit.changed')
}
})
context.actions?.register('deleteAccount', async (params) => {
await accounts?.delete(params.id as string)
await context.events?.emit('account.changed')
})
},
}
export default extension

Quick reference table of all components and their key properties.

ComponentKey PropertiesEvent
VerticalStackgap, children
HorizontalStackgap, children
Gridcolumns, gap, children
Listchildren
Labeltext
Paragraphtext
Headerlevel, title, description, icon
Markdowncontent
TextPreviewcontent, maxLines
Divider
Iconname, title
Pilltext, variant, icon
TextInputlabel, placeholder, valueonChangeAction
DateTimeInputlabel, valueonChangeAction
Selectlabel, options, selectedValueonChangeAction
Togglelabel, description, checked, disabledonChangeAction
Checkboxlabel, checked, disabled, strikethroughonChangeAction
IconPickerlabel, valueonChangeAction
ButtontextonClickAction
IconButtonicon, tooltip, type, active, disabledonClickAction
Paneltitle, description, icon, actions, content
Collapsibletitle, description, icon, defaultExpanded, content
Frametitle, variant, collapsible, defaultExpanded, children
Modaltitle, open, maxWidth, body, footeronCloseAction
ConditionalGroupcondition, children