Skip to content

Guides

Styling

Four attributes decide how the chart looks, your CSS decides everything around it, and the boundary between those two is the whole story. Three of the four need no code at all.

The three that change the shape of it

Everything about the chart’s own furniture is these, and each is an attribute on your placeholder or a query parameter on the iframe. The fourth, data-colors, has a section of its own further down.

AttributeValuesDefaultWhat it does
data-themelight · darkdarkThe whole palette: ground, grid, axis furniture, candle colours, chrome.
data-toolbar0 · 10The header, the drawing rail and the range bar. Off means the plot and nothing else.
data-legend0 · 11The symbol and OHLC readout in the top-left of the plot.

Every value is on URL parameters, each with its own live chart. Two are worth seeing side by side here, because they are the ones that change the SHAPE of the thing rather than its colour.

data-toolbar 0, the default: a plot
data-toolbar 1: a chart somebody can work in
The toolbar decides more than looks. The currency picker, the settings dialog and the fx indicator list all live in it, so turning it off withdraws them as well. The one that surprises people is fx: without the toolbar a reader has no way to connect their own indicator library. That is stated on the indicators guide too, because it is a real trade rather than a detail.

Height is yours, and it does not grow

The chart fills its frame exactly, so the height you give the iframe is the height of the plot. data-height sets it, in pixels; with nothing given the loader uses 420, which suits an article.

A taller chart
<div class="vp-chart"
     data-symbol="BTC-USDT"
     data-datafeed="myfeed"
     data-height="560"></div>

Nothing about the chart makes it taller. There is no content to grow to: a plot occupies whatever room it is given and redraws to fit. So a chart in a fixed 200px box is a legible chart 200px tall, and one at 900px is the same chart with more price on screen. Neither is a mistake, and neither will correct itself.

The frame does send ready and resize messages carrying a height, and the loader applies them. That exists so a host doing something unusual with the layout is told the real number rather than assuming one. It is not an auto-height that measures content, and there is no content to measure.

Making it responsive

The iframe is width: 100% already, so the width takes care of itself. Height is the one to think about, and a ratio reads better across breakpoints than a fixed number does.

A ratio instead of a number
.vp-chart iframe {
  height: auto;
  aspect-ratio: 16 / 9;
  min-height: 320px;     /* below this a price scale and a plot stop coexisting */
}

@media (max-width: 640px) {
  .vp-chart iframe { aspect-ratio: 4 / 3; }   /* a phone wants height, not width */
}

The height the loader writes is an inline style, so a stylesheet rule needs to be at least as specific to win: .vp-chart iframe is, iframe alone is not. The min-height is the part worth keeping: a chart squeezed under about 300px is a price scale, a time axis and almost no plot.

Following the reader’s system theme

The theme is fixed at mount by the attribute, and changed afterwards by a message. That second half is what a page with its own light and dark modes needs, and it is four lines.

Match the page, then keep matching it
<!-- The attribute is the FIRST paint, and only that. A page whose
     theme can change still needs the four lines on the other tab. -->
<div class="vp-chart"
     data-symbol="BTC-USDT"
     data-datafeed="myfeed"
     data-theme="dark"></div>
Applied before the first paint, which is the half the message cannot do.
Set data-theme to your page's usual mode as well, rather than relying on the message alone. The attribute is applied before the first paint and the message arrives after it, so without both a reader on the other theme sees one frame of the wrong ground.
data-theme dark, the default
data-theme light

Everything around the frame is ordinary CSS

The iframe is an element in your document like any other. Round it, border it, shadow it, put it in a grid. The loader gives it display:block, width:100% and no border of its own precisely so your own rules have nothing to fight.

Rather than print four variants, here is one you can turn. The CSS underneath is regenerated from whatever the chips are set to, so what is on screen and what you copy cannot disagree.

Corners
Colour
Inset
Shadow
Clip
The CSS for what is on screen
.vp-chart {
  border-radius: 16px;
  overflow: hidden;
  border: 1px solid #d4d4d8;
  box-shadow: 0 1px 2px rgb(0 0 0 / 0.06);
}

overflow: hidden is the line people leave out, which is why it is a control rather than a sentence. Turn Clip off with the corners rounded and the iframe's square corners cut straight through the border: a radius on a container does not round what is inside it, and the result reads as a rendering bug rather than as a missing property.

Padding changes the answer rather than adding to it. Once the frame is inset there is nothing touching the corner to clip, so the radius goes on the iframe itself, which is what the generated CSS switches to. That is also the shape to reach for if you want a coloured surround: the padding is where a card's own background shows.

What your CSS does not reach

Anything inside the frame. Not with a descendant selector, not with !important, not by injecting a stylesheet. The chart is a document on our origin, and the browser does not let one origin restyle another’s.

That is the same boundary the security model is built on, seen from the other side. The reason your page cannot be read by the chart is the reason the chart cannot be written to by your page. It is not a setting of ours and we could not lift it if we wanted to.

Reachable from your page?
The iframe: size, border, radius, shadow, positionyes, ordinary CSS
Which of the two themesdata-theme
Ground, axis ink, grid, candles, crosshair, hairlines, chrome, accentyes, data-colors or setStyle(), below
Which chrome is drawndata-toolbar and data-legend
A control of your own IN the toolbaryes, createToolbarButton
Colours of lines and markers YOU drawyes, per object, below
Fonts, type sizes, spacing inside the plotno
One grid line, one candle body, anything individuallyno
The attribution markno, and there is no parameter for it
What is left is the SHAPE of the thing rather than its colour: the type face, the size of the axis labels, how the plot is spaced. Those are the engine's, and they are why a chart drawn by us is still readable at 200 pixels tall on a phone. There is no parameter for them.

Recolouring the chart itself

data-colors takes role:value pairs and repaints the chart. 10 roles, hex values, and anything you leave out keeps the theme's own colour. The same names are an object on chart.setStyle(), which is the version that can change while the chart is on screen.

A paper-coloured chart
<div class="vp-chart"
     data-symbol="BTC-USDT"
     data-datafeed="myfeed"
     data-theme="light"
     data-colors="bg:fffdf7,text:44403c,grid:e7e5e4,up:15803d,down:b91c1c"></div>
Read once, when the frame mounts. Nothing after that reads the attribute.
RoleWhat it paints
bgPlot background
textAxis labels and legend
gridGrid lines
upRising candles, body wick and border
downFalling candles, body wick and border
crosshairThe crosshair’s two lines
mutedSecondary text: the countdown to the next bar, hints, sub-labels
borderHairlines: the price scale, the time axis, the edges between panes
chromeToolbars, dialogs and menus — only visible with the toolbar on
accentThe highlight: active controls, and a drawing while it is selected
Palette

a light editorial page

The palette on screen
<div class="vp-chart"
     data-symbol="BTC-USDT"
     data-toolbar="1"
     data-colors="bg:#fffdf7,text:#44403c,grid:#e7e5e4,up:#15803d,down:#b91c1c,crosshair:#a8a29e,muted:#78716c,border:#e7e5e4,chrome:#faf8f2,accent:#b45309"></div>
Read once, when the frame mounts.

A value is #rrggbb, #rgb or #rrggbbaa, and the # is optional because it opens a fragment in a URL: ?colors=bg:#0b0f19 loses everything after the hash, so the natural spelling in a query string has none and the natural spelling in an HTML attribute has one. Both are accepted.

Nothing else is accepted, including rgb() and plain colour names. That is a security property rather than a simplification: these values reach fillStyle, a CSS colour may be url(...), and a canvas that fetches is a beacon we would be hosting in your reader's browser. A strict grammar with one shape is checkable at a glance.
up and down each set the body, the wick AND the border, because a host asking for green up-candles would be astonished to get a green body still wearing the engine's outline. accent sets the highlight and the selection together for the same reason. 10 names, 15 tokens. What you cannot do is colour a family's members separately, which is the same rule as the row above about individual grid lines.

Three colours in the theme have no role, and each is a decision rather than an oversight. Two of them are the crosshair's price chip, which is a foreground and a background that have to contrast: one name cannot set both, and a name that set only the ground would let you make the number on it invisible. The third is the second chrome shade, which despite the name paints the countdown chip on the price scale rather than anything in the toolbar.

A misspelled role in data-colors is DROPPED and the rest of the palette still applies, the same rule indicators and currency follow. Dropping is safe here in a way it is not for symbol: every value you leave out falls back to a theme colour that is legible by construction, so there is no way for a dropped colour to produce an unreadable chart.

setStyle REFUSES instead, by name, and refuses the whole call rather than the one key. That is not an inconsistency with the attribute above it. A URL is written once by you and then read by every reader of your page, so a typo there should cost its own colour and not the chart; a method call has an error channel back to the person typing it, so setStyle({ background: '#fff' }) comes back as a commandError naming background and listing the roles that do exist.
Green up and red down is a convention your readers are relying on. Nothing stops you inverting it or setting both to the same colour, and nothing will warn you. It is your page, so it is your call, but it is the one change here that makes a chart harder to read rather than merely different.

Changing the palette after it is on screen

data-colors is read once, when the frame mounts. The chart's palette is a construction option, so the attribute cannot be changed afterwards and nothing re-reads it. chart.setStyle() is the same palette as a live call, which is what a page with its own theme switcher, or one that tints a chart per section, needs.

One role at a time, merged onto the rest
const chart = vpEmbed.chart('#mine');

// Set a whole palette up front…
await chart.setStyle({ bg: '#0b0f19', grid: '#151a28', text: '#c3c8d4' });

// …then change one thing. Everything else keeps what it had.
await chart.setStyle({ accent: '#f59e0b' });
**A palette outlives a theme switch.** setTheme('light') swaps the base colours and then re-applies yours on top, so a bg you set dark stays dark on the light theme. That is the behaviour you want when the colours are your brand, and a trap when they were intended as a tweak to one theme. In that case, set them again after switching, or set only the roles that make sense in both.

The colours you do control

Every object your page draws carries its own colours, because those are yours rather than the chart's: a stop is red on your book, and the chart has no opinion. Order lines, position lines and execution markers each take a full set.

Per object, not per chart
await chart.createOrderLine({
  price: 42000,
  text: 'Bid',
  lineColor: '#26a69a',
  lineWidth: 2,
  lineStyle: 2,                    // 0 solid · 1 dotted · 2 dashed · 3 large dash · 4 sparse
  bodyBackgroundColor: '#0b1e17',
  bodyBorderColor: '#26a69a',
  bodyTextColor: '#d1fae5',
  quantityBackgroundColor: '#26a69a',
  quantityTextColor: '#04140d',
});

Colours are hex or rgb(). A url() is refused, because it is a legal CSS colour and would make the canvas fetch from somewhere, which is a beacon we would be hosting on your reader's browser. Every property of all three objects is on Objects.

An unknown property is refused BY NAME rather than ignored, so a British lineColour comes back as a commandError saying so. The whole call is refused, not just that key, which is why a typo shows up as a line that never appeared rather than a line in the wrong colour.

Three lines, three palettes, one chart. Switching palette sends handle.set() to the lines already drawn rather than removing and re-creating them, so they recolour in place and your handles stay valid.

palette

Three order lines, each carrying its own colours. Switching palette sends handle.set() to the lines already on the chart, so they are recoloured rather than redrawn. The chart around them does not change, because nothing here can change it.

The stop line, as the palette above draws it
await chart.createOrderLine({
  price: stopPrice,
  text: 'Stop',
  quantity: '0.5',
  lineColor: '#ef5350',
  lineWidth: 2,
  lineStyle: 0,
  bodyBackgroundColor: '#2a0d0d',
  bodyBorderColor: '#ef5350',
  bodyTextColor: '#fecaca',
  quantityBackgroundColor: '#ef5350',
  quantityTextColor: '#2a0d0d',
});

Notice what does NOT change as you switch: the candles, the grid, the axis furniture and the toolbar are the theme's, and no palette here can reach them. That is the boundary from the previous section seen from the useful side, and it is why these colours are per OBJECT rather than a chart-wide setting: they describe your book, not our chart.

The shape of a line, not just its colour

Colour is the half people reach for first. The rest of a line's look is its GEOMETRY, and all of it is yours. The corner radius of every box on it, the font its label and its quantity chip are set in, how far left it reaches, and how its bracket legs are drawn.

A rounded, branded position
const pos = await chart.createPositionLine({
  price: 79649,
  text: 'Long 0.5',
  quantity: '0.5',

  // ONE radius for every box in the row: the body, the quantity chip and
  // each button. 0 is square, 2 is the default, and anything past half the
  // row's height clamps to a full pill.
  borderRadius: 100,

  bodyFont: 'bold 11px Inter, system-ui, sans-serif',
  quantityFont: '11px Inter, system-ui, sans-serif',

  // How far left it reaches. lineLength is only consulted when extendLeft
  // is off, since a line pinned to the left edge already spans everything.
  extendLeft: false,
  lineLength: 60,
});
borderRadius is the shared default. Override bodyBorderRadius, quantityBorderRadius and buttonBorderRadius separately, or set an override to null to inherit again. A font is a CSS shorthand and a url() in one is refused for the same reason a url() colour is.

Style every part of a trading line

Style properties work on creation and through handle.set(). The body, quantity badge, action buttons, P&L and protection labels have their own controls. Fonts resize the row and its click targets; quantity width uses the quantity font. These are canvas properties, so use the API rather than CSS selectors.

A complete position style
await position.set({
  bodyAlignment: 'left', bodyPaddingLeft: 16, bodyPaddingRight: 16,
  bodyPaddingHorizontal: 10, bodyPaddingVertical: 6,
  bodyFont: '600 14px system-ui', quantityFont: '600 12px system-ui',
  bodyBackgroundColor: '#ffffff', bodyTextColor: '#166534',
  bodyBorderColor: '#16a34a', bodyBorderWidth: 2,
  borderRadius: 8, bodyBorderRadius: 12,
  quantityBorderRadius: 4, buttonBorderRadius: 100,
  quantityBackgroundColor: '#dcfce7', quantityTextColor: '#166534',
  quantityBorderColor: '#16a34a',
  buttonWidth: 28, buttonIconWidth: 2,
  closeButtonBackgroundColor: '#ffffff', closeButtonBorderColor: '#94a3b8',
  closeButtonIconColor: '#475569',
  reverseButtonBackgroundColor: '#ffffff', reverseButtonBorderColor: '#94a3b8',
  reverseButtonIconColor: '#475569',
  pnlPositiveColor: '#16a34a', pnlNegativeColor: '#dc2626',
  protectLabelFont: '600 12px system-ui',
  protectLabelPaddingHorizontal: 8, protectLabelPaddingVertical: 4,
  protectLabelBorderRadius: 8, protectLabelBorderWidth: 1,
  protectLabelBackgroundColor: '#ffffff', protectLabelBorderColor: '#94a3b8',
  protectLabelTextColor: '#334155',
  protectButtonBackgroundColor: '#f8fafc', protectButtonBorderColor: '#94a3b8',
  protectButtonTextColor: '#334155',
});

// Restore inheritance without removing the line.
await position.set({ bodyBorderRadius: null, protectLabelBackgroundColor: null });

Orders use the same body, quantity and button geometry, with cancelButtonBackgroundColor, cancelButtonBorderColor and cancelButtonIconColor for their cancel action. P&L and protection properties belong to positions. The existing lineColor, lineWidth, lineStyle, protectLineWidth, protectLineStyle and per-leg colors still style the horizontal lines independently.

PropertyDefault and behavior
bodyPaddingLeft / bodyPaddingRight0px; only the matching alignment uses the inset
bodyPaddingHorizontal / bodyPaddingVertical6px / 3px on the body and quantity row
bodyBorderWidth1px on body, quantity and buttons; 0 hides outlines
bodyBorderRadius / quantityBorderRadius / buttonBorderRadiusnull inherits borderRadius (2px)
buttonWidth / buttonIconWidth18px minimum width / 1.5px icon stroke
pnlPositiveColor / pnlNegativeColornull follows the chart’s up / down colors
protectLabelFontbold 10px system-ui
protectLabelPaddingHorizontal / protectLabelPaddingVertical5px / 2px
protectLabelBorderRadius / protectLabelBorderWidth0px / 0px
protectLabelBackgroundColor / protectLabelBorderColornull follows each leg’s color
protectLabelTextColornull uses white
protectButtonBackgroundColor / protectButtonBorderColor / protectButtonTextColornull follows close-button fill / leg ink / leg ink
Embed dimensions accept finite numbers from 0 to 100 pixels; buttonWidth keeps an 18px minimum for the action glyphs. Colors accept the same values as the existing line colors, including transparent. Nullable color and radius overrides accept null to restore inheritance. Unspecified values retain their defaults. During hover, drag-out buttons use their leg color as the fill; an explicit protectButtonTextColor remains in effect.

Move line controls to the left

Set bodyAlignment: 'left' on an order or position to keep its controls away from recent candles on narrow charts. The quantity, label, P&L and action buttons move together. Position TP/SL, breakeven and drag-preview labels follow. The default is 'right'; lineLength and extendLeft still control the line itself. Choose alignment per line, on creation or update. Add bodyPaddingLeft: 16 to inset left-aligned controls by 16 pixels; the default is zero.

Left-aligned controls
const position = await chart.createPositionLine({
  price: 79649,
  text: 'Long',
  quantity: '0.5',
  bodyAlignment: 'left',
  bodyPaddingLeft: 16,
});

// Switch back without recreating the line.
await position.set({ bodyAlignment: 'right' });

The bracket has its own palette

A take-profit and a stop are the two levels a reader actually watches, and they used to be a fixed green and red: the only chrome on the chart you could not brand. Each leg now carries its own colour, its own name, and a width and dash independent of the entry's.

Every leg, styled
await pos.set({
  takeProfit: 83000,
  stopLoss: 78200,
  breakeven: 79649,

  // null on any of these RESTORES the built-in colour rather than painting
  // nothing, which is why they are nullable.
  takeProfitColor: '#22c55e',
  stopLossColor: '#ef4444',
  breakevenColor: null,

  // What each leg is CALLED on the pill at the end of its level: the "TP"
  // in "TP 83,000". An empty string draws no pill at all.
  takeProfitLabel: 'TARGET',
  stopLossLabel: 'STOP',
  breakevenLabel: 'BE',

  // The legs' own line, independent of the entry's lineWidth / lineStyle.
  protectLineWidth: 2,
  protectLineStyle: 2,     // dashed
});
The take-profit's colour also paints its drag-out handle and the level while it is being dragged, so one leg is one colour for its whole life rather than changing under the pointer. Breakeven keeps its own dotted pattern whatever protectLineStyle says: it is a reference mark rather than a resting order, and that difference is what the dash is carrying.

…and so do the drag-out handles

The two small squares a reader pulls off the pill to ask for a bracket leg are controls, so they take a label of their own. The handle MEASURES it and grows to fit, which is what makes a longer one legible rather than clipped.

Rounded, labelled handles
await pos.set({
  canAddTakeProfit: true,
  canAddStopLoss: true,
  side: 'long',            // set it whenever you offer them (see Trading)

  borderRadius: 100,       // the handles round with everything else
  addTakeProfitLabel: '+ TP',
  addStopLossLabel: '+ SL',
  protectFont: 'bold 9px Inter, system-ui, sans-serif',
});
addTakeProfitLabel and takeProfitLabel look like the same string and are not: the first is the text on a BUTTON, the second is a NAME printed beside a price. They are separate properties precisely so writing + TP on the button does not put + TP 83,000 on the level.

The wash under a level being dragged

While a reader drags one of those handles, the chart shades everything between the entry and the level under the pointer, in that leg's own colour: red down to a stop, green up to a target. A level on its own says where; the span says how much, which is the question a stop is actually answering. It is drawn at the same strength as the depth wash in the price gutter, so a chart showing both reads as one thing rather than two.

Turning the wash down, or off
await pos.set({
  protectFillOpacity: 0.3,   // louder than the 0.16 default
});

await pos.set({
  protectFillOpacity: 0,     // off; the dashed level and its price stay
});
It belongs to the GESTURE and not to the order: the wash goes the instant the release books the leg, because a resting stop is a level and not a region, and a chart carrying one shaded slab per bracket ever placed would be unreadable within a session. There is deliberately no separate fill COLOUR, either: stopLossColor already decides what colour the stop is, and a second property would be a second answer to the same question.

A fill is a moment, not a level

The three above are LEVELS: they run the width of the chart and stay there until you move them. An execution marker is the other shape: one marker, at one price, on one bar, where a fill happened. createExecutionShape takes its own palette: arrowColor, textColor, and the two pixel figures that decide how big it is drawn and how far it sits off the price it marks. markerStyle picks between an arrow with its label beside it and a badge with the label inside it; switch the demo below between the two, because that is the whole of the difference.

paletteshape

A buy marker, a sell marker and the position line between them. Both numbers of each fill come off the same bar — a real low on the left of the chart, a real high on the right — so the arrow tip lands on the wick it marks rather than floating beside it.

The entry marker and the position line, as the controls above draw them
// A fill has a time AND a price, and they have to agree — so both come
// off the same bar. `time` is in SECONDS, the unit a marker takes.
await chart.createExecutionShape({
  time: bar.time,
  price: bar.low,
  direction: 'buy',
  text: 'Buy 0.5',
  tooltip: 'Buy 0.5 · BTC-USDT',
  arrowColor: '#26a69a',
  textColor: '#d1d4dc',
  arrowHeight: 12,
  arrowSpacing: 4,
});

await chart.createPositionLine({
  price: bar.low,
  text: 'LONG 0.5',
  quantity: '0.5',
  lineColor: '#26a69a',
  bodyBackgroundColor: '#04211c',
  bodyTextColor: '#a7f3d0',
  quantityBackgroundColor: '#26a69a',
  quantityTextColor: '#04211c',
});

The direction decides which side of the candle the marker hangs: a buy sits below the bar's low, a sell above its high, so a round trip reads without needing the labels. It clears the bar rather than sitting on the fill price, because a fill is almost never at an extreme of its own bar and a marker measured from the price is drawn on top of the candle it marks. arrowSpacing is the gap between the two; an arrow's tip and a badge's near EDGE both land on that edge; worth raising past the default when your markers sit on the same bars as a study, and worth leaving alone otherwise.

**time is in SECONDS**, while a bar's time in your datafeed is in milliseconds. The asymmetry is inherited from the contract this mirrors, and it is why the demo above asks getVisibleRange for its anchors rather than reading a clock: that call answers in seconds too, so the two agree by construction. Anything at or above 1e12 is taken as milliseconds already, so a bar time straight off your own feed lands where you meant rather than fifty thousand years out.

A reader’s own changes do not persist

With the toolbar on, a reader can change the interval, add an indicator, draw on the chart and open Settings. None of it survives a reload: the embed runs with browser storage for chart settings switched off.

That is the right default for a chart on somebody else’s page. An embed is part of an article rather than a workspace, and a reader who tried a study last week should not find it waiting on a different site this week. Where the chart genuinely is the product, drive it from your page with the control API and keep the state in your own storage, where you can also decide what it belongs to.

Two shapes worth knowing

Most embeds are one of these, and each is a default rather than a configuration.

An article chart: no toolbar, legend on, short. Nothing set but the height.
A page where the chart is the point: toolbar on, taller, matching a light page.