Skip to main content

How to customize mark tooltips

This guide shows how to fully customize the pop-up tooltips of marks on the time scale and marks on the chart (bar marks) using your own web components. By default, mark tooltips display plain text and do not support HTML. With a custom web component you can render anything inside the tooltip: styled text, links, images, tables, or any other UI your users need.

The shared setup below uses timescale marks as the running example; the differences for bar marks are collected in Customize bar mark tooltips.

info

Custom mark tooltips and the custom_js_urls option are available in Trading Platform only.

How it works

  1. You implement a custom element (tv-custom-timescale-mark for timescale marks, tv-custom-bar-mark for bar marks) and register it with customElements.define.
  2. You load the JavaScript module that defines the element into the library iframe using the custom_js_urls property in the Widget Constructor, or the addCustomJSFile widget method.
  3. Your datafeed returns timescale marks with the customTooltip property — an opaque string payload, typically JSON.
  4. When a user clicks the mark, the library renders your element inside the tooltip and sets the payload as the element's text content. Your component parses the payload and renders the UI.

The feature is opt-in per mark: marks without customTooltip display the default tooltip. If the tv-custom-timescale-mark element is not registered — for example, because the module failed to load — the library falls back to the default tooltip rendering as well.

Before you start

Consider taking the following steps before proceeding with the guide:

  1. Set up the Widget Constructor and run the library. Refer to the Quick start for more information.
  2. Connect data to the library. Refer to the Connecting data tutorial for more information.
  3. Make sure your datafeed supports the marks you need — supports_timescale_marks + getTimescaleMarks for timescale marks, and/or supports_marks + getMarks for bar marks.

1. Create a minimal component

The smallest possible component renders the payload as plain text inside a shadow root:

custom-timescale-mark.js
class CustomTimescaleMark extends HTMLElement {
connectedCallback() {
queueMicrotask(() => {
const shadowRoot = this.shadowRoot || this.attachShadow({ mode: 'open' });
const paragraph = document.createElement('p');
paragraph.textContent = this.textContent;
shadowRoot.replaceChildren(paragraph);
});
}
}

customElements.define('tv-custom-timescale-mark', CustomTimescaleMark);

Rules to keep in mind:

  • Do not remove or modify the element's light DOM children. The library owns the text node that carries the payload. Read this.textContent and render into your shadow root instead — light DOM content is hidden automatically because the shadow root contains no <slot>.
  • Render synchronously if possible. The tooltip is positioned when it opens, so content that grows later may overflow. If you need to load data asynchronously, reserve the final dimensions up front.
  • The available space varies — set max-width: 100% on your content. On desktop the tooltip is a popup near the mark, constrained by the chart size. On small screens (up to 419 px wide), mark tooltips are displayed in a full-width drawer at the bottom of the chart instead. A fixed width wider than the available space gets clipped; max-width: min(<your width>, 100%) adapts to both layouts.

2. Load the component

Pass the module URL to the Widget Constructor. The library loads it inside its iframe as a <script type="module"> element before the chart is created, so the element is guaranteed to be registered by the time a user can click a mark:

const widget = new TradingView.widget({
// ...
custom_js_urls: ['custom-timescale-mark.js'],
});

Each URL should be absolute, or relative to the library_path constructor option — the folder the library's static files are served from. Because the files are loaded as ES modules, they can use import statements to pull in further files.

You can also load additional files at runtime with the widget method:

widget.addCustomJSFile('another-module.js');

If a file fails to load, the library logs a warning to the browser console and continues loading the chart normally.

3. Return marks with a custom tooltip

Set the customTooltip property on the marks that should use your component. The value is an opaque string — the library never interprets it — so JSON is the natural format for structured data:

getTimescaleMarks: (symbolInfo, from, to, onDataCallback, resolution) => {
onDataCallback([
{
id: 'earnings-q2',
time: 1712016000,
color: '#089981',
label: 'E',
customTooltip: JSON.stringify({
type: 'key-values',
title: 'Q2 2026 Earnings',
rows: [
{ name: 'EPS actual', value: '2.18', trend: 'up' },
{ name: 'EPS estimate', value: '1.94' },
{ name: 'Revenue', value: '$94.2B', trend: 'up' },
],
}),
},
{
id: 'classic-mark',
time: 1710028800,
color: '#787b86',
label: 'C',
// No customTooltip: this mark uses the default tooltip.
tooltip: ['Plain text line one', 'Plain text line two'],
},
]);
},

Full example: a JSON payload renderer

The component below is a complete, copy-pasteable renderer that handles several payload types — plain text, styled rich text with links, key/value rows, and a logo card — and adapts to the light and dark themes. A single element name serves all marks, so route between layouts with a type field in your payload.

// custom-timescale-mark.js
class CustomTimescaleMark extends HTMLElement {
connectedCallback() {
// The library sets the mark's payload as this element's text content and owns
// that text node. Never remove or modify the light DOM: render into a shadow
// root instead. Light DOM content is hidden automatically because the shadow
// root has no <slot>.
queueMicrotask(() => this._render());

// The library toggles the 'theme-dark' class on the page's root element.
// Watch it so an open tooltip follows live theme switches.
this._themeObserver = new MutationObserver(() => this._syncTheme());
this._themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
}

disconnectedCallback() {
if (this._themeObserver) {
this._themeObserver.disconnect();
this._themeObserver = null;
}
}

_syncTheme() {
const container = this.shadowRoot && this.shadowRoot.querySelector('.card');
if (container) {
container.classList.toggle('theme-dark', document.documentElement.classList.contains('theme-dark'));
}
}

_render() {
const shadowRoot = this.shadowRoot || this.attachShadow({ mode: 'open' });
const payload = (this.textContent || '').trim();

let data = null;
try {
data = JSON.parse(payload);
} catch (error) {
// Not JSON: render the payload as plain text.
}

const style = document.createElement('style');
style.textContent = this._styles();

const container = document.createElement('div');
container.className = 'card';
container.appendChild(this._renderPayload(data, payload));

shadowRoot.replaceChildren(style, container);
this._syncTheme();
}

_renderPayload(data, rawPayload) {
if (data === null || typeof data !== 'object') {
return this._renderPlainText(rawPayload);
}
switch (data.type) {
case 'rich-text': return this._renderRichText(data);
case 'key-values': return this._renderKeyValues(data);
case 'logo-card': return this._renderLogoCard(data);
default: return this._renderPlainText(rawPayload);
}
}

_renderPlainText(text) {
const paragraph = document.createElement('p');
paragraph.className = 'plain';
paragraph.textContent = text;
return paragraph;
}

_renderRichText(data) {
const wrapper = document.createElement('div');
const blocks = Array.isArray(data.blocks) ? data.blocks : [data.blocks];
for (const block of blocks) {
const paragraph = document.createElement('p');
paragraph.className = 'block';
const items = Array.isArray(block.content) ? block.content : [{ text: String(block.content) }];
for (const item of items) {
paragraph.appendChild(this._renderInline(item));
}
this._applyTextStyles(paragraph, block);
wrapper.appendChild(paragraph);
}
return wrapper;
}

_renderInline(item) {
let element;
if (item.url !== undefined) {
element = document.createElement('a');
element.href = item.url;
element.target = '_blank';
element.rel = 'noopener noreferrer';
} else {
element = document.createElement('span');
}
element.textContent = item.text || item.url || '';
this._applyTextStyles(element, item);
return element;
}

_applyTextStyles(element, styles) {
if (styles.color) { element.style.color = styles.color; }
if (styles.bold) { element.style.fontWeight = 'bold'; }
if (styles.italic) { element.style.fontStyle = 'italic'; }
if (styles.size) { element.style.fontSize = styles.size; }
}

_renderKeyValues(data) {
const wrapper = document.createElement('div');
if (data.title) {
const heading = document.createElement('div');
heading.className = 'heading';
heading.textContent = data.title;
wrapper.appendChild(heading);
}
const table = document.createElement('div');
table.className = 'rows';
for (const row of data.rows || []) {
const name = document.createElement('span');
name.className = 'row-name';
name.textContent = row.name;
const value = document.createElement('span');
value.className = 'row-value';
value.textContent = row.value;
if (row.trend === 'up') { value.classList.add('up'); }
if (row.trend === 'down') { value.classList.add('down'); }
table.append(name, value);
}
wrapper.appendChild(table);
return wrapper;
}

_renderLogoCard(data) {
const wrapper = document.createElement('div');
wrapper.className = 'logo-card';
if (data.logoUrl) {
const logo = document.createElement('img');
logo.className = 'logo';
logo.src = data.logoUrl;
logo.alt = '';
wrapper.appendChild(logo);
}
const text = document.createElement('div');
const heading = document.createElement('div');
heading.className = 'heading';
heading.textContent = data.title || '';
text.appendChild(heading);
if (data.caption) {
const caption = document.createElement('div');
caption.className = 'caption';
caption.textContent = data.caption;
text.appendChild(caption);
}
if (data.url) {
const link = document.createElement('a');
link.href = data.url;
link.target = '_blank';
link.rel = 'noopener noreferrer';
link.textContent = data.linkText || 'Read more';
text.appendChild(link);
}
wrapper.appendChild(text);
return wrapper;
}

_styles() {
return `
:host {
display: block;
max-width: min(320px, 100%);
font-family: -apple-system, BlinkMacSystemFont, 'Trebuchet MS', Roboto, Ubuntu, sans-serif;
font-size: 13px;
line-height: 1.5;
}
.card {
/* Light theme defaults; the 'theme-dark' class is kept in sync
with the chart theme by _syncTheme(). */
--ctm-text: #131722;
--ctm-muted: #787b86;
--ctm-link: #2962ff;
--ctm-up: #089981;
--ctm-down: #f23645;
color: var(--ctm-text);
padding: 2px;
}
.card.theme-dark {
--ctm-text: #d1d4dc;
--ctm-muted: #868993;
--ctm-link: #5b9cf6;
}
p { margin: 0 0 6px; }
p:last-child { margin-bottom: 0; }
a { color: var(--ctm-link); text-decoration: none; }
a:hover { text-decoration: underline; }
.heading { font-weight: bold; margin-bottom: 4px; }
.caption { color: var(--ctm-muted); margin-bottom: 4px; }
.rows { display: grid; grid-template-columns: auto auto; gap: 2px 16px; }
.row-name { color: var(--ctm-muted); }
.row-value { text-align: right; font-variant-numeric: tabular-nums; }
.row-value.up { color: var(--ctm-up); }
.row-value.down { color: var(--ctm-down); }
.logo-card { display: flex; gap: 10px; align-items: flex-start; }
.logo { width: 36px; height: 36px; border-radius: 50%; flex: none; }
`;
}
}

customElements.define('tv-custom-timescale-mark', CustomTimescaleMark);

Example payloads for each layout:

// Rich text with inline styles and a link
customTooltip: JSON.stringify({
type: 'rich-text',
blocks: [
{ content: [{ text: 'Product announcement — ', bold: true }, { text: 'the keynote introduced two new devices.' }] },
{ content: [{ text: 'Read the ' }, { text: 'full coverage', url: 'https://example.com/news' }] },
],
}),

// Logo card
customTooltip: JSON.stringify({
type: 'logo-card',
title: 'Dividend paid',
caption: '$0.26 per share.',
logoUrl: 'https://s3-symbol-logo.tradingview.com/apple.svg',
url: 'https://example.com/dividends',
linkText: 'Details',
}),

Tooltips stay open while the user interacts with them, so links and buttons inside your component work as expected.

Adapt to the chart theme

The library indicates the active theme by toggling the theme-dark class on the root (<html>) element of the page your component runs in — the light theme has no class. Do not rely on the :host-context() pseudo-class to detect it: it is deprecated and was never supported across all browsers. Instead, read the class from document.documentElement and reflect it onto an element inside your shadow root, as the full example above does:

_syncTheme() {
const container = this.shadowRoot && this.shadowRoot.querySelector('.card');
if (container) {
container.classList.toggle('theme-dark', document.documentElement.classList.contains('theme-dark'));
}
}

Watch for changes with a MutationObserver (see connectedCallback in the full example), so that a tooltip that is open while the user switches the theme updates immediately. Then scope your colors with CSS custom properties:

.card {
--my-text-color: #131722;
color: var(--my-text-color);
}
.card.theme-dark {
--my-text-color: #d1d4dc;
}

Note that CSS custom properties inherit through the shadow boundary. If you already define your own theme colors on the page — for example, via a custom CSS file loaded with custom_css_url — your component can consume those properties directly instead of duplicating the values.

Customize bar mark tooltips

Marks on the chart (bar marks, requested via getMarks) support the same mechanism with two differences:

  • The element name is tv-custom-bar-mark. Register it in the same module as the timescale mark element — one file loaded via custom_js_urls can define both.
  • By default the tooltip is shown on hover on desktop (or tap on touch devices) and hides shortly after the pointer leaves the mark. To reveal it on click and keep it open instead, enable the pin_bar_mark_tooltips_on_click featureset.
  • On small screens (up to 419 px wide), a tooltip revealed by a click or tap is displayed in a drawer at the bottom of the chart — the same presentation that timescale mark tooltips use. Make sure your component's content works at full drawer width (see the sizing rules above).

Set the customTooltip property on marks returned from getMarks:

getMarks: (symbolInfo, from, to, onDataCallback, resolution) => {
onDataCallback([
{
id: 'earnings-q2',
time: 1712016000,
color: 'green',
label: 'E',
labelFontColor: '#ffffff',
minSize: 24,
// Shown when your custom element is not registered.
text: 'Q2 2026 Earnings',
customTooltip: JSON.stringify({
type: 'key-values',
title: 'Q2 2026 Earnings',
rows: [
{ name: 'EPS actual', value: '2.18', trend: 'up' },
{ name: 'EPS estimate', value: '1.94' },
],
}),
},
]);
},

As with timescale marks, the payload is injected as the element's text content and is never parsed as HTML. If tv-custom-bar-mark is not registered, the default text tooltip is displayed instead.

Reveal tooltips on click

By default the bar mark tooltip appears on hover and hides when the pointer leaves the mark, so users cannot reach interactive content — such as links or buttons — inside it. To reveal the tooltip on click and keep it open, enable the pin_bar_mark_tooltips_on_click featureset:

enabled_features: ['pin_bar_mark_tooltips_on_click'],

When enabled, bar mark tooltips no longer appear on hover. Clicking (or tapping) a mark reveals its tooltip and pins it open; the pinned tooltip stays open until the user clicks elsewhere on the chart, clicks the mark again, scrolls the chart, or changes the symbol. The featureset applies to all bar mark tooltips — plain text and custom — and works in both Advanced Charts and Trading Platform.

Security considerations

  • Custom JavaScript is fully trusted code. The library iframe shares the origin of your page, so any module loaded through custom_js_urls or addCustomJSFile runs with complete access to your application. Only load files that you control and serve from your own infrastructure, and never construct these URLs from user input.
  • The library never parses the payload as HTML. The customTooltip value is injected exclusively as text content, so the payload itself cannot inject markup. That guarantee ends inside your component: avoid innerHTML with unsanitized data, and build DOM nodes with createElement and textContent as the examples above do. This matters most when the payload contains user-generated content, such as news headlines or comments.
  • Use a shadow root. Besides style isolation, it keeps the library-owned light DOM intact.
  • Content Security Policy. If your page uses a CSP, the script-src directive must allow the module URLs. The library applies its CSP nonce to the injected <script> elements, so strict-dynamic policies work as well.

Try it out

The Trading Platform package includes a runnable demo of everything in this guide (see custom_marks.html). It shows all four payload layouts for timescale marks, custom bar mark tooltips with click-to-pin enabled, classic marks rendering side by side with custom ones, and the light/dark theming.