Toolbar and Settings Extensions
The chart toolbar and the settings dialog both accept content the SDK knows nothing about. You get a DOM element and render whatever belongs there: a button, an input, a switch, a segmented control, a panel trigger that opens your own screener somewhere else entirely in your application.
Extensions are plain DOM. No framework is required and none is assumed — the same extension works from React, Vue, Svelte, Angular, or hand-written DOM.
This is a different mechanism from a registered action. An action is a label, an icon, and a handler, and the SDK draws the button. An extension is yours from the container inwards.
The composable cluster is the right-hand run of controls, from the indicator picker to the fullscreen button. Your own items take their place in that order.
The extension contract
Every extension point takes the same object:
interface TradeScriptDomExtension<Context> {
mount(container: HTMLElement, context: Context): {
update(context: Context): void;
destroy(): void;
};
}
mount runs once with an empty element you own. Return a handle. update runs when
the context changes. destroy runs when the chart tears down, and must release
everything.
The SDK never reads or writes inside your container.
Plain DOM
const symbolBadge = {
mount(container, context) {
const badge = document.createElement('span');
badge.textContent = context.symbol?.ticker ?? '';
container.append(badge);
return {
update: (next) => {
badge.textContent = next.symbol?.ticker ?? '';
},
destroy: () => badge.remove(),
};
},
};
React
Mount a root inside the container and unmount it on destroy.
import { createRoot } from 'react-dom/client';
const symbolBadge = {
mount(container, context) {
const root = createRoot(container);
root.render(<SymbolBadge context={context} />);
return {
update: (next) => root.render(<SymbolBadge context={next} />),
destroy: () => root.unmount(),
};
},
};
Vue
import { createApp, reactive } from 'vue';
const symbolBadge = {
mount(container, context) {
const state = reactive({ context });
const app = createApp(SymbolBadge, { state });
app.mount(container);
return {
update: (next) => {
state.context = next;
},
destroy: () => app.unmount(),
};
},
};
Svelte, Angular, Solid and vanilla follow the same three steps: create in mount,
re-render in update, tear down in destroy.
A React host can skip the contract entirely and pass render instead, which returns
a React node. Where both are set, render wins.
Adding a control to the toolbar
Declare an item with an id, then place that id in the toolbar order.
const chart = sdk.chart.mount({
container: '#chart',
symbol: 'AAPL',
interval: '1D',
datafeed,
toolbar: {
items: [{ id: 'firm-screener', label: 'Screener', extension: screenerExtension }],
actionIds: ['firm-screener', 'toolbar.indicators', 'toolbar.chart-type'],
},
});
actionIds is the left-to-right order for the ids it names, ahead of everything it
does not. Ids you leave out keep their default order behind those. Omit actionIds
and your item renders after the built-in controls.
Change the composition later through the customization controller:
chart.customization().setToolbar({
items: [{ id: 'firm-screener', label: 'Screener', extension: screenerExtension }],
actionIds: ['firm-screener', 'toolbar.indicators'],
});
Built-in control ids
The composable cluster sits at the right end of the toolbar. These are its ids, in default order, each with the option that shows or hides it:
| Id | Control | Gate |
|---|---|---|
toolbar.execution-mode | Click-to-add execution | executionModeVisible |
toolbar.options | Options panel toggle | optionsVisible |
toolbar.paper-trade | Paper trade | paperTradeVisible |
toolbar.object-inspector | Object inspector | objectInspectorVisible |
toolbar.super-search | Chart search | superSearchVisible |
toolbar.indicators | Indicator picker | indicatorsVisible |
toolbar.chart-type | Chart type selector | chartTypeVisible |
toolbar.execution-markers | Execution marker toggle | executionMarkersVisible |
toolbar.time-range | Time range | timeRangeVisible |
toolbar.undo-redo | Undo and redo | undoRedoVisible |
toolbar.replay | Bar replay | replayVisible |
toolbar.timezone-menu | Timezone menu | timezoneMenuVisible |
toolbar.screenshot | Screenshot | screenshotButtonVisible |
toolbar.fullscreen | Fullscreen | fullscreenButtonVisible |
Read the same list at runtime rather than copying it:
import { listBuiltInControlsForSurface } from '@tradescript/pro/sdk';
listBuiltInControlsForSurface('period-bar').map((control) => control.id);
The symbol search, the interval selector, and the time-frame shortcuts sit in the toolbar's left and centre regions. Those are layout rather than a list of peers, so they carry ids and visibility gates but do not take part in ordering.
Remove any control by id:
toolbar: { hiddenActionIds: ['toolbar.paper-trade', 'toolbar.options'] }
Hiding wins over ordering: an id named in both lists is hidden.
Replacing a built-in control
controlOverrides swaps what a control renders while keeping its position, its order,
and its gate.
toolbar: {
controlOverrides: {
'toolbar.screenshot': { extension: firmExportButton },
},
}
An override extension also receives defaultContent, a handle to the SDK's own
control. Mount it to keep the built-in inside your own chrome; ignore it to drop the
built-in entirely.
const firmExportButton = {
mount(container, context) {
const wrapper = document.createElement('span');
wrapper.className = 'firm-export';
container.append(wrapper);
const builtIn = context.defaultContent.mount(wrapper);
return {
update: () => {},
destroy: () => {
builtIn.destroy();
wrapper.remove();
},
};
},
};
An override respects the built-in's visibility gate. Set respectVisibility: false to
render regardless.
What the SDK passes you
interface ChartToolbarItemContext {
id: string;
display: 'full' | 'icon' | 'overflow';
symbol?: SdkSymbolInfo;
interval?: ChartInterval;
chart?: ChartApi;
}
update runs when any of these change. chart is the same handle the rest of your
integration holds, so a control can read chart state and drive the chart directly.
Passing your own values in
An extension is an ordinary object, so close over whatever you like. Your data never travels through the SDK.
function createScreenerExtension({ apiClient, onSelect }) {
return {
mount(container, context) {
// apiClient and onSelect are yours, captured here.
},
};
}
toolbar: {
items: [{
id: 'firm-screener',
label: 'Screener',
extension: createScreenerExtension({
apiClient,
onSelect: (ticker) => chart.setSymbol(ticker),
}),
}],
}
To push a value in after mounting, keep your own reference and re-render from it. The
SDK's update carries chart state only.
function createBadge() {
let paint = (_text: string) => {};
return {
extension: {
mount(container: HTMLElement) {
const node = document.createElement('span');
container.append(node);
paint = (text) => {
node.textContent = text;
};
return { update: () => {}, destroy: () => node.remove() };
},
},
setText: (text: string) => paint(text),
};
}
const badge = createBadge();
badge.setText('12 alerts');
Callbacks and events
You own the DOM, so wire listeners directly and remove them in destroy.
mount(container, context) {
const button = document.createElement('button');
const onClick = () => context.chart?.setChartType('line');
button.addEventListener('click', onClick);
container.append(button);
return {
update: () => {},
destroy: () => {
button.removeEventListener('click', onClick);
button.remove();
},
};
}
To react to the chart rather than to the user, subscribe through the chart handle and
unsubscribe in destroy.
Async work
mount returns synchronously. Start async work inside it and guard the result against
teardown, because a chart can be destroyed before a request settles.
mount(container, context) {
const controller = new AbortController();
const node = document.createElement('div');
node.textContent = 'Loading…';
container.append(node);
loadScreenerResults(context.symbol?.ticker, { signal: controller.signal })
.then((results) => { node.textContent = `${results.length} matches`; })
.catch(() => { node.textContent = 'Unavailable'; });
return {
update: () => {},
destroy: () => {
controller.abort();
node.remove();
},
};
}
Writing to a container after destroy leaks; the SDK does not catch it.
Narrow toolbars
The toolbar measures its own width. When the row runs past its container, controls move into an overflow menu at the end of the bar, starting from the last one, so the control you ordered first stays visible longest.
Each item declares how far it may collapse:
| Policy | Behaviour |
|---|---|
full | Never collapses, at any width |
icon | Reports display: 'icon' in the compact toolbar, and stays on the bar |
overflow | Also moves into the overflow menu when the bar runs out of width |
hide | Disappears in the compact toolbar |
Built-in controls default to overflow. Host items default to icon, because the SDK
cannot shrink DOM it does not own — it can only report that space is tight and let you
render accordingly.
{ id: 'firm-screener', label: 'Screener', collapse: 'overflow', extension: screenerExtension }
An item that declares overflow needs label; the SDK builds the menu row from it.
mount(container, context) {
const button = document.createElement('button');
const paint = (ctx) => {
button.innerHTML = ctx.display === 'full' ? `${iconSvg}<span>Screener</span>` : iconSvg;
};
paint(context);
container.append(button);
return { update: paint, destroy: () => button.remove() };
}
The mobileToolbar feature still forces the compact bar outright, unchanged. Width
measurement covers every other narrow case, including a desktop chart in a tight split.
Adding a settings section
The settings dialog takes host sections on the same contract.
features: {
chartControls: {
settingsCustomSections: [
{
id: 'alert-routing',
title: 'Alert routing',
order: 10,
extension: alertRoutingExtension,
},
],
},
}
The SDK renders the heading and the card. You render the body.
A negative order puts the section ahead of the built-in ones, anything else after them.
Sections on the same side render in ascending order.
Section context carries the chart and a way to close the dialog:
interface ChartSettingsSectionContext {
id: string;
chart?: ChartApi;
close: () => void;
}
One policy hides built-in and host sections alike:
features: {
chartControls: {
settingsSections: { trading: false, 'alert-routing': false },
},
}
Host sections render in both presentations — the draggable dialog and the mobile bottom sheet.
Making a control available to agents
Host controls stay invisible to the agent and MCP layer unless you say otherwise. Describe what the control does and an agent can use it:
{
id: 'firm-screener',
label: 'Screener',
extension: screenerExtension,
agentic: {
description: 'Opens the firm screener and returns matching symbols',
read: true,
write: false,
},
}
Omit agentic and the control stays human-only. Adding a control never makes it
agent-callable by itself.
Licensing
Extensibility is a separately licensed capability. A deployment whose entitlement does not carry
the extensions chart feature is refused when it calls setToolbar with items or
controlOverrides, and host sections never render.
Ordering and hiding built-in controls is not gated. actionIds and hiddenActionIds work on every
licence; only host-owned content requires the entitlement.
Switch the surfaces off in your own configuration with the extensions feature:
features: {
extensions: {
toolbarItems: true,
controlOverrides: false,
settingsSections: true,
},
}
extensions: false disables all three. Omitted, every surface your licence grants is available.
Ids and collisions
An item id may not match a built-in control id, and a section id may not match a
built-in section id. A colliding id would make the built-in unreachable by
actionIds, hiddenActionIds, and settingsSections. The SDK keeps the built-in,
drops the host declaration, and reports it on the console. The same applies to a
duplicate id, and to a declaration carrying neither render nor extension.
Styling
Your content inherits the chart's CSS custom properties, so it can match without importing anything:
.firm-control {
background: var(--ts-chart-surface-bg);
color: var(--ts-chart-surface-text);
border: 1px solid var(--ts-chart-surface-divider);
}
To match a built-in control exactly, use the SDK's button class:
button.className = 'ts-chart-button';
The container is yours; ignore all of it and style however you like.
Related pages
- Feature Gates — the control inventory these ids come from.
- Chrome Slots — restyling the built-in surfaces rather than replacing their contents.
- Toolbars — the toolbar anatomy these ids attach to.
- Widget Options — where
toolbarandfeaturesare passed at construction.