DocumentationMobile & Touch

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.

Switch container widths, place drawings and compare animated and immediate autoscaling.

live
Rendering live chart…
Synthetic hourly bars. Start at 360 px to use the mobile header and Draw sheet. On a fine-pointer device, change width to compare the packaged layouts. A coarse pointer keeps mobile controls at either width. Show extrema sends a real wheel zoom through the chart, so Animation changes both the time zoom and price autoscale transition. The switch restores the widget layout and drawings; Reset view returns to the latest 80 bars.
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.