Mobile & Touch
All input handling is built on the Pointer Events API, so the same input system serves mouse, touch, and stylus input. Touch retains two-axis panning; mouse and pen also pan both axes by default, with optional time-only movement through navigation.mousePan: 'horizontal'.
The engine gestures below work in every host. The optional
openalgo-charts/widget tier also supplies a
responsive header, bottom controls and drawing sheets.
Navigation demo
Switch container widths, place drawings and compare animated and immediate autoscaling.
View example code
el.style.display = 'flex';
el.style.flexDirection = 'column';
const controls = document.createElement('div');
controls.style.cssText = 'display:flex;gap:6px;align-items:center;flex-wrap:wrap;padding:8px;flex-shrink:0';
const stageWrap = document.createElement('div');
stageWrap.style.cssText = 'flex:1;min-height:0;display:flex;justify-content:center;overflow:hidden;padding:0 8px 8px';
const stage = document.createElement('div');
stage.style.cssText = 'width:360px;max-width:100%;height:100%;min-height:0';
stageWrap.appendChild(stage);
el.append(controls, stageWrap);
const bars = Array.from({ length: 180 }, (_, index) => {
const close = 100 + Math.sin(index / 9) * 3 + index * 0.025;
const bar = { time: 1700000000 + index * 3600, open: close - 0.35,
high: close + 0.9, low: close - 0.9, close, volume: 500 + index * 4 };
if (index === 94) bar.high = 130;
if (index === 97) bar.low = 74;
return bar;
});
let widget;
let animated = true;
let narrow = true;
let extrema = false;
const listeners = new AbortController();
const listen = (node, event, handler) => node.addEventListener(event, handler, { signal: listeners.signal });
function button(label, action) {
const node = document.createElement('button');
node.type = 'button';
node.textContent = label;
node.style.cssText = 'min-height:34px;padding:5px 9px;border:1px solid var(--oac-card-border);border-radius:5px;background:var(--oac-card);color:inherit;font:12px system-ui;cursor:pointer';
listen(node, 'click', action);
controls.appendChild(node);
return node;
}
const status = document.createElement('span');
status.setAttribute('role', 'status');
status.style.cssText = 'font:12px system-ui;color:var(--oac-muted);padding:5px';
function showLatest() {
widget.chart.resetScale();
extrema = false;
extremaButton.textContent = 'Show extrema';
extremaButton.setAttribute('aria-pressed', 'false');
status.textContent = 'Latest 80 bars';
}
function build(saved, selected = [], activeTool = null) {
widget?.destroy();
stage.replaceChildren();
widget = lib.createWidget(stage, {
symbol: 'NAV SIM', exchange: 'NSE', interval: '1h', intervals: ['15m', '1h', '1d'],
mobile: 'auto', statusline: false, indicators: false,
rail: { tools: ['trend-line', 'horizontal-line', 'rectangle'] },
navigation: { defaultVisibleBars: 80 },
animZoom: animated,
animAutoscale: animated,
});
widget.series.setData(bars);
if (saved) {
widget.restoreState(saved);
widget.draw.select(selected);
if (activeTool) widget.draw.setTool(activeTool);
}
}
const widthButton = button('Width: 360 px', () => {
narrow = !narrow;
stage.style.width = narrow ? '360px' : '760px';
widthButton.textContent = narrow ? 'Width: 360 px' : 'Width: 760 px';
widthButton.setAttribute('aria-pressed', String(narrow));
});
widthButton.setAttribute('aria-pressed', 'true');
const extremaButton = button('Show extrema', () => {
extrema = !extrema;
if (extrema) {
const plot = widget.root.querySelector('.oac-chart');
const rect = plot.getBoundingClientRect();
plot.dispatchEvent(new WheelEvent('wheel', {
deltaY: 300,
deltaMode: WheelEvent.DOM_DELTA_PIXEL,
clientX: Math.max(rect.left + 1, rect.right - 58),
clientY: rect.top + rect.height / 2,
bubbles: true,
cancelable: true,
}));
extremaButton.textContent = 'Show latest';
status.textContent = 'Zooming out to the earlier high and low';
} else showLatest();
extremaButton.setAttribute('aria-pressed', String(extrema));
});
extremaButton.setAttribute('aria-pressed', 'false');
const animationButton = button('Animation: on', () => {
const saved = widget.getState();
const selected = Array.from(widget.draw.selection());
const activeTool = widget.draw.activeTool();
animated = !animated;
animationButton.textContent = 'Animation: ' + (animated ? 'on' : 'off');
animationButton.setAttribute('aria-pressed', String(animated));
build(saved, selected, activeTool);
});
animationButton.setAttribute('aria-pressed', 'true');
button('Reset view', showLatest);
controls.appendChild(status);
build();
status.textContent = 'Latest 80 bars';
return {
destroy() {
listeners.abort();
widget?.destroy();
},
};Built-in touch gestures
One-finger drag
Dragging with a single finger pans both axes at once: horizontal movement scrolls the time axis left or right, and vertical movement shifts the price scale of the touched pane up or down. This is unchanged by navigation.mousePan: 'horizontal'. Releasing after a fast swipe launches a kinetic animation that decelerates the scroll naturally.
Two-finger pinch and pan
Placing two fingers on the chart starts a pinch gesture. On every animation frame the engine compares the current two-pointer state with the previous one:
- Distance between fingers increases — zooms in on the time axis around the midpoint.
- Distance between fingers decreases — zooms out on the time axis.
- Midpoint translates horizontally — pans the time axis.
- Midpoint translates vertically — pans the price scale of the touched pane.
All four effects are computed in a single frame, so spreading fingers while sliding sideways zooms and pans simultaneously.
Some browsers expose a trackpad pinch as Ctrl-wheel or Meta-wheel. The chart treats that as proportional time zoom at the pointer. Pixel, line and page wheel deltas are normalized before use. Hardware and browser event delivery can differ, so test the devices your host supports rather than assuming identical gestures everywhere.
Double-tap to reset
A double-tap fires the browser’s synthetic dblclick event. With the default doubleClick: 'reset', it calls resetScale(): the configured default view is restored and every price axis returns to auto-scaling. navigation.defaultVisibleBars: 0 fits all loaded bars; a positive count targets the newest N loaded bars plus four empty bar slots on the right, subject to data and bar-spacing limits. You can trigger the same action programmatically:
chart.resetScale();Use chart.fitContent() to explicitly fit all loaded history. The visible-bar setting
changes the viewport only; it does not limit history requests or remove loaded bars.
Tapping to interact
A tap (pointer-down followed by pointer-up with less than 3 px of movement) is treated as a click. When the tap lands on a hit-testable primitive — a series marker, an event marker, or a price line — the chart calls any subscribeClick callback with the primitive’s externalId and emits a click event. See Events.
chart.subscribeClick((externalId) => {
console.log('tapped primitive:', externalId);
});A tap-and-drag on a draggable price line (used for order, stop-loss, and take-profit levels) moves the line in real time and fires the subscribeDrag callbacks. For the higher-level trading layer, which manages order/SL/TP visuals and emits trading:* events, see Trading API.
chart.subscribeDrag(
(externalId, price) => { /* called on every move */ },
(externalId, price) => { /* called on release */ },
);Responsive layout
The chart installs a ResizeObserver on its container element at construction time. Whenever the container changes size — viewport resize, device orientation change, or a flex/grid parent reflowing — the chart recalculates pane layout and repaints automatically with no extra code required.
Use a fluid container so the chart fills its available space:
<div id="chart" style="width: 100%; height: 400px;"></div>For a fixed aspect ratio at any screen width:
#chart {
width: 100%;
aspect-ratio: 16 / 9;
}Packaged mobile widget controls
WidgetOptions.mobile accepts 'auto', 'always' or 'never'. The default is
'auto', which switches when the widget container is at most 640 CSS px wide or the
primary pointer is coarse. It observes the container rather than the browser viewport,
so a narrow chart in a wide dashboard receives the compact controls, and a phone retains
them in landscape.
import { createWidget } from 'openalgo-charts/widget';
const widget = createWidget('#terminal', {
mobile: 'auto',
rail: { tools: ['trend-line', 'horizontal-line', 'rectangle'] },
});When active, the desktop top bar, status line and drawing rail are replaced by a touch
header and bottom bar. The header provides symbol entry and interval selection. The
bottom bar provides Draw, Studies, Objects and More as enabled by topbar, rail and
indicators. More contains theme, chart settings and chart type. Selecting a drawing
shows Properties, Lock or Unlock, and Delete. While a tool is active, the Drawing sheet
also provides Finish, Cancel, Undo, Magnet and Stay controls.
The mobile and desktop controls use the same DrawingController, object inventory,
selection, undo history, settings dialogs and overlay stack. Changing layout does not
copy or reset drawings. rail.tools filters both the desktop rail and mobile Drawing
sheet to the same allowed ids.
The widget checks prefers-reduced-motion: reduce when it creates the chart. If the host
did not set them, the widget disables both animZoom and animAutoscale. Explicit option
values remain in force.
For a custom packaged host, the widget entry also exports mountMobile and its
MobileMode, MobileOptions and MobileHandle types. Most applications should use
createWidget, which wires the handle to the shared widget context and destroys it with
the rest of the chrome. See The Widget Tier and
Scales and Panes.
Crisp rendering
The chart reads window.devicePixelRatio automatically at startup and draws every canvas at the physical pixel density of the screen. On 2x Retina displays and 3x mobile screens the output is sharp with no CSS-scaling blur.
To override the ratio — for testing, fixed-density screenshots, or environments without window — pass a pixelRatio function in ChartOptions. The value is re-read on every resize and repaint, so a function that reads window.devicePixelRatio dynamically is valid:
const chart = createChart(el, {
pixelRatio: () => 2, // force 2x regardless of the device
});Page-level setup
Viewport meta tag
Add the standard viewport meta tag to your HTML so mobile browsers do not apply default page scaling:
<meta name="viewport" content="width=device-width, initial-scale=1">touch-action
The chart sets touch-action: none on its container element automatically in the constructor, so browser-native pan and pinch-to-zoom do not intercept chart gestures.
If the chart container is nested inside a scrollable parent element (a modal, a page body with overflow: scroll, or any custom scroll container), the ancestor can still capture touch events before they reach the chart. In that case, add touch-action: none to the scrollable ancestor, or restructure the layout so the chart is not inside a native-scroll region.
Accessibility
The chart container carries role="application", tabindex="0", and an aria-label that screen readers announce when the element is focused. You can override the default label via ChartOptions.ariaLabel:
const chart = createChart(el, {
ariaLabel: 'NIFTY 5-minute candlestick chart',
});A visually-hidden aria-live="polite" region is appended to the container. It updates with the bar count and latest close price each time data is loaded, so screen readers announce changes without the user having to navigate to the element.