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 here, because this demo is under 600 px tall, the shape a phone on its side gives; a taller container on a tablet shows the desktop chrome above 640 px. 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
function stockBars(startTime, count, intervalSec, startPrice, seed, volatility = 0.004, baseVolume = 2000) {
  let state = seed >>> 0 || 1;
  const random = () => {
    state ^= state << 13; state >>>= 0;
    state ^= state >>> 17;
    state ^= state << 5; state >>>= 0;
    return state / 4294967296;
  };
  const round = value => Math.round(value * 100) / 100;
  const bars = [];
  const trends = [0.5, -0.35, 0.2, 0.85, -0.7, 0.15, -0.4];
  let close = round(startPrice);
  let regimeEnd = 0;
  let trend = 0;
  for (let i = 0; i < count; i++) {
    if (i >= regimeEnd) {
      trend = trends[Math.floor(random() * trends.length)] * volatility;
      regimeEnd = i + 14 + Math.floor(random() * 18);
    }
    const gap = i > 0 && i % 37 === 0 ? (random() - 0.5) * volatility * 6 : 0;
    const open = round(Math.max(0.01, close * (1 + gap)));
    const noise = (random() + random() + random() - 1.5) * volatility * 2;
    const move = trend + noise;
    close = round(Math.max(0.01, open * (1 + move)));
    const upperWick = Math.max(0.01, round(open * volatility * (0.12 + random() * 0.7)));
    const lowerWick = Math.max(0.01, round(open * volatility * (0.12 + random() * 0.7)));
    const volume = Math.round(baseVolume * (0.65 + random() * 0.8
      + Math.abs(move) / volatility * 0.18 + (i % 31 === 0 ? 1.5 : 0)));
    bars.push({ time: startTime + i * intervalSec, open,
      high: round(Math.max(open, close) + upperWick),
      low: round(Math.max(0.01, Math.min(open, close) - lowerWick)),
      close, volume });
  }
  return bars;
}
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 = stockBars(1700000000, 180, 3600, 100, 991, 0.006, 1000);
bars[94].high = Math.max(bars[94].high, 130);
bars[97].low = Math.min(bars[97].low, 74);

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. It paints inside the observer’s callback, before the browser shows the frame, so a resize or a rotation never flashes a cleared chart.

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 decides from the size of the widget container rather than the browser viewport, so a narrow chart in a wide dashboard receives the compact controls too. The pointer is the primary one, as the (pointer: coarse) media query reports it:

Widget container (CSS px)Fine pointerCoarse pointer
At most 640 wideCompactCompact
641 to 960 wide, under 600 tallDesktopCompact
641 to 960 wide, 600 tall or moreDesktopDesktop
Wider than 960DesktopDesktop

A phone therefore keeps the compact controls in both orientations: turned on its side it is wide but short. A tablet in either orientation, and a touch laptop, get the full top bar and drawing rail, which keep every tool one tap away. From 2.5.10; earlier releases chose the compact controls for any coarse pointer, whatever the container size.

The widget decides again whenever the container is resized (a rotation, a split view, a dashboard cell changing size) and whenever the primary pointer changes, as it does when a convertible folds into tablet mode. Two cases keep the current layout. A container that measures 0 wide (hidden, or not laid out yet) starts with the desktop controls, and one that later drops to 0 wide or 0 tall keeps the layout it had, so hiding a tab does not close an open sheet. While a form field inside the widget has focus (the symbol box, or an input in a dialog), a switch waits until focus leaves it, so an on-screen keyboard that shortens the container never hides the field being typed in.

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 opens with Undo and Redo, then 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, Redo, Magnet and Stay controls.

The mobile and desktop controls use the same DrawingController, object inventory, selection, chart-wide 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.

It follows a change of ratio too: a window dragged to a screen of another density, or a zoom. The chart watches a resolution media query for the ratio in force, made again after each change, and the window’s resize, and re-sizes and repaints every canvas at once. Pane boundaries land on whole device pixels at every ratio. At fractional ratios such as 1.25 and 1.5, common on laptop screens, the separator is one device pixel tall, laid over the top of the lower pane, so no pane is stretched by a fraction of a pixel; at whole ratios it is the 1 px border it has always been. Where the browser reports a canvas’s device-pixel box (Chromium, Firefox), the canvas takes exactly that many pixels.

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.