
Replace or Keep: How DataLens Migrated from Highcharts
Replace or Keep: How DataLens Migrated from Highcharts

Hi, my name is Evgeny Alaev, I’m a frontend developer on the Yandex DataLens team. It’s a cloud BI tool for data analysis and dashboard building, and charts in it are not “just one of the features” — they are the heart of the product. A user opens a dashboard and the first thing they see is visualizations. They are the ones that answer the question: “What’s happening with my data?”
DataLens runs in two installations — for Yandex itself and for external users. In total, more than 18.3 million charts have been created to date. Each of these charts is the result of work by the very visualization library we’re going to talk about.
For a long time, charts in DataLens were built on Highcharts. Initially it was a reasonable choice: fast start, a rich set of chart types, a large community. But a BI tool grows more complex over time — non-standard behavior requirements emerge, along with a design system that needs to be maintained in a consistent style. And at some point Highcharts started getting in the way more than it helped.
In this article I’ll explain how and why we decided to write our own open-source visualization library — @gravity‑ui/charts. My colleague and I are core contributors to this library, so I’ll describe in detail what we didn’t like about Highcharts, which alternatives we considered, how the architecture is structured, and what specific technical challenges we faced along the way.
From Convenience to Constraints
License and vendor lock
Highcharts is a commercial library. It’s free for non-commercial use, but as soon as a product is monetized, a license is required. That in itself is not a problem — many teams work with paid tools. The problem starts where the usage model becomes more complex.
DataLens has three distribution formats: cloud, open-source, and on-premises. A non-free license in dependencies — even an optional one — complicates delivery of each of these for us.
Beyond the license — there’s a hard dependency on someone else’s roadmap. Want a new chart type? Wait for the vendor to build it. Found a bug in tooltip behavior? Open a ticket and hope it makes it into the priority queue. Need non-standard behavior when clicking on a legend? Search for a workaround in the docs or on Stack Overflow. When a product is actively evolving and visualization requirements are non-trivial, this kind of dependency starts to slow things down.
Major upgrades are a story of their own. We were using the latest version of Highcharts within the eighth major release. Moving to the next major looked expensive on its own, but one DataLens peculiarity made it especially difficult: we have an Editor — a mode where users write JavaScript code to configure charts. They essentially work with Highcharts directly, building configuration through code. Such user code cannot be migrated automatically: you can’t write a codemod that safely moves arbitrary JS from one interface to another.
Control over behavior
In a BI tool, users actively interact with charts: clicking on a legend to hide a series, hovering over data points, pinning tooltips. Each of these scenarios in Highcharts required fine-tuning via callbacks and events — which were not always documented and not always stable between versions.
Here are a few characteristic examples:
-
Pinning a tooltip. The standard Highcharts tooltip disappears when the mouse moves away. Native support for a “sticky” tooltip was not available in major version 8 — it only appeared in version 12. We implemented it by overriding internal methods: intercepting mouse events, substituting the hiding logic. The code worked, but it was fragile.
-
Custom tooltip rendering. The formatter returns an HTML string, which means manually assembling markup through concatenation. No React, no components. The tooltip render ended up becoming a separate layer with its own templating logic.
-
Legend click. The standard behavior toggles series visibility. Fully overriding it without breaking everything else is non-trivial: the handler had to be attached via internal events with careful cancellation of the default behavior.
-
Theming. Colors, fonts, and spacing in Highcharts are set through JavaScript configuration and applied as inline styles. On top of that we maintained a separate SCSS file for overriding base styles, which had to be manually synchronized with every palette update in Gravity UI.
Each such workaround is not an isolated problem, but a contribution to overall technical debt. One hack lives on its own, two can be kept in mind, but by the tenth they start interacting: fix one thing and something else breaks. Maintenance complexity grows non-linearly, and every Highcharts update turns into an audit: which of our workarounds has quietly broken this time.
Evaluating alternatives
Before building our own, we honestly went through existing solutions. The main criteria were:
-
Open license — MIT or compatible, no paid tiers and no OEM agreements.
-
Full customization of rendering and styles — ability to control the appearance of any element without workarounds.
-
HTML embedding — tooltips, chart labels, and axis labels must render as full HTML markup, not as SVG text or string templates.
We looked at ECharts, Recharts, Plotly, Chart.js, and D3.
Chart.js and Plotly dropped out quickly: Canvas rendering by default closes off CSS style customization options, and embedding arbitrary HTML into chart elements is simply not supported there.
Recharts — React-first, SVG, MIT. Well-suited for simple dashboards, but deep customization of shapes and behavior quickly runs into limitations of the internal API.
ECharts was the closest option: a rich set of types, flexible configuration, support for custom renderers. But on closer inspection it did not satisfy one of the key criteria: full HTML embedding in arbitrary parts of a chart. For our use cases this was essential. On top of that — vendor lock wasn’t going anywhere: the MIT license resolved the legal question, but dependency on someone else’s roadmap and update cycle remained.
D3 is not a charting library, it’s a set of primitives: scales, shape generators, data transformations. By itself it doesn’t solve the task, but it provides the perfect foundation for building your own solution on top of it.
The conclusion was logical: take D3 as the foundation and build our own library on top of it — this gave us both freedom and control, and completely removed the dependency on any vendor.
Why D3 and not something else
D3 doesn’t draw charts. It’s a set of tools for working with data and the DOM: scales, geometric shape generators, transformations. That’s exactly what we needed — primitives from which any visualization can be assembled, without being limited by what the library author envisioned.
D3 provides scaleLinear, scaleBand, scaleUtc for axes, d3.line (), d3.arc (), d3.area () for shapes, d3.extent () and d3.group () for data transformations. Everything else is ours.
Architecture of @gravity‑ui/charts
Entry point — the config object
The user passes a single object with data and settings to the component: series, axes, title, legend, tooltip. This is a deliberate choice in favor of a declarative approach — the majority of the config is easily serialized, logged, and passed between systems.
Where declarative description is insufficient, the config accepts functions: a custom tooltip renderer, click handlers, axis label formatters. This is not a contradiction, but an intentional boundary — the data structure remains predictable, and extension points are explicit.
Data flow
Inside the library the config goes through several processing stages:
-
Normalization. Input data is brought to a unified internal format. At this stage defaults are applied, ambiguities are resolved, and data for each series is typed.
-
Series preparation. Each chart type (line, bar, pie, and so on) is processed by its own module. Colors are assigned, metadata for the legend is formed.

- Building scales and axes. Based on the data, D3 builds scales: linear, logarithmic, temporal, categorical. Scales define how values from the data are translated into pixel coordinates.

- Shape rendering. Each series type draws its own SVG elements: lines, rectangles, arcs, points. D3 provides shape generators, React manages the lifecycle of elements.
SVG + HTML: hybrid rendering
The main rendering is SVG. This gives sharp boundaries, scaling without quality loss, and full control over positioning.
But SVG cannot wrap text and does not support rich HTML markup inside elements. For data labels with line breaks, custom tooltips, and interactive elements overlaid on the chart, a separate HTML layer is used — an absolutely positioned div that overlays the SVG and synchronizes with its coordinates.
Event system
User interactions with the chart — hover, click, mouse movement — are handled through a centralized event bus based on d3.dispatch. This allows different parts of the interface (tooltip, crosshair, legend) to react to the same event independently and consistently, without direct dependencies between components.
But this approach has another important consequence — performance. Mouse movement over a chart generates events at a high frequency. If a React render is triggered for every such event, it leads to expensive recalculations of the entire component tree. Instead, some updates — for example, hovering over shapes, finding the nearest point to the pointer — are applied directly through D3, bypassing React. The component is not re-rendered: D3 simply updates the necessary DOM attributes. React is only involved where it is truly necessary — when the structure or state changes in a way that affects the entire graph.
Migration Without Stopping the Product
The data path from source to chart
Before talking about switching libraries, it’s important to understand how chart rendering in DataLens is structured — because this architecture determined how we could migrate.
When a user opens a chart (or that chart is rendered on a dashboard), a request for data is sent to our Node.js backend. The Node.js backend, in turn, goes to our Python service for raw data. But data by itself is not yet a chart. Preparation happens on Node: the visualization type is determined, it’s checked whether data limits for this type are exceeded, and if everything is fine — the data preparation function specific to the particular chart type is selected.
The key point: each visualization type has its own data preparation function. For a line chart — one function, for a bar chart — another, for an area chart — a third. The result of this function is the config that flies to the client. There it doesn’t go directly into @gravity‑ui/charts, but into @gravity‑ui/chartkit — a separate package we use for working with multiple visualization libraries simultaneously. It dynamically loads only the library needed for the specific chart type, and provides a unified interface for all of them — client code doesn’t know or care which library is actually rendering the particular type.
Beyond routing, chartkit contains wrappers on top of the libraries themselves: logic that doesn’t fit within the scope of a specific library, but is needed in DataLens. For example, showing a tooltip on tap on a mobile device — this behavior is equally needed for both Highcharts and @gravity‑ui/charts, so it is implemented at the chartkit level.
Such things are potentially useful not only in DataLens. So a detailed story about @gravity‑ui/chartkit deserves a separate article.
Gradual migration strategy
This architecture gave us the ability to migrate incrementally, without rewriting everything at once.
We introduced feature flags at the visualization type level. The logic is simple: if the flag for a specific visualization is set to true — Node prepares data in @gravity‑ui/charts format and the client uses the new library. If the flag is not set — data is prepared in Highcharts format and the old code is rendered.
This meant that at some point both libraries were running simultaneously in DataLens — each for its own set of chart types. This approach provided several advantages:
-
Isolated risk. A bug in the new bar chart implementation doesn’t affect column charts that are still on Highcharts
-
Gradual verification. Each type after the transition goes through an observation period in production before the next one migrates
-
Rollback capability. If something went wrong, it’s enough to turn off the flag
The migration went in three waves, and the order was chosen deliberately — from simple to complex.
Wave 1: pie and treemap. The most logical starting point — visualizations without axes. No scales, no crosshair, no complex coordinate system. This allowed us to work out the basic integration, set up the data preparation pipeline, and verify that the migration infrastructure worked correctly, without risking the most heavily used types.

Wave 2: bar-y, bar-y normalized, scatter. The next step — charts with axes, but with an important constraint: these types are not used in split mode (where multiple charts are stacked one below the other with a shared X axis) and are not involved in combined charts. This sharply reduced the number of edge cases and made the transition predictable.

Wave 3: area, area normalized, bar-x, bar-x normalized, line and their combinations. The most complex stage. These types are the most popular in DataLens: they are used on the majority of dashboards.

On top of that, combined charts appear here (e.g., line + bar-x on the same chart), split mode, and all the non-trivial interaction scenarios. Each of these visualizations required special attention during testing, and feature flags provided the ability to roll back if something went wrong in production.
Technical Solutions
Config object: familiar, but better. We deliberately chose a config object as the way to describe a chart. DataLens was already working with this approach, and a sudden paradigm shift would have created unnecessary friction. The config structure closely mirrors what developers are used to: series, axes, title, tooltip — all in their expected places. But where the Highcharts interface seemed flawed to us, we made our own decisions. Not for the sake of originality, but because we saw specific problems or inconveniences in real-world usage.
A minimal example — a line chart with a time axis:
import {Chart} from '@gravity-ui/charts';
<Chart
data={{
series: {
data: [
{
type: 'line',
name: 'Revenue',
data: [
{x: new Date('2024-01-01').getTime(), y: 120},
{x: new Date('2024-02-01').getTime(), y: 145},
{x: new Date('2024-03-01').getTime(), y: 132},
{x: new Date('2024-04-01').getTime(), y: 178},
],
},
],
},
xAxis: {type: 'datetime'},
yAxis: [{title: {text: 'thousands'}}],
}}
/>
It looks like this:

But you can also make a more complex line chart:

Sensible defaults and customization where needed. Over years of developing a BI tool, we have formed an understanding of how a charting library should behave in such a product. Typical scenarios should work out of the box — without configuration and without surprises. Where defaults are not enough, there are explicit customization points: pass your own React component as a tooltip renderer, override an axis label formatter, set line widths. All of this — through the same config object, without digging into the library internals.
Native integration with Gravity UI. DataLens is built on Gravity UI — a design system with components, icons, and CSS theming. Theming works through CSS variables from @gravity‑ui/uikit: connect a theme — and all axis, grid, and label colors automatically adapt to light or dark mode. The tooltip and buttons in the chart interface are standard uikit components that inherit accessibility, keyboard navigation, and event handling from the design system.

Scale without chaos. When there are many chart types, architecture starts to determine outcomes. Adding a new type should not require changes to the core or break what already works. We solved this through a unified structure for each type: its own data preparation, its own render component, its own types — all in an isolated module. The library core knows about the existence of types only through a shared contract, not through concrete implementations.
Visual testing. Visualization is above all what the user sees, so unit tests are not enough here. Every chart type is covered by screenshot tests on Playwright, which run in Docker for reproducibility. A change in one module cannot silently break the rendering of another: it’s immediately visible from a failed snapshot.
Results and Conclusions
The transition took time and required serious investment. But the result is not simply “replaced one library with another.” Here’s what we gained:
-
Full control over the code. When something doesn’t work as expected — we can go anywhere, understand the cause, and fix it. No black boxes, no workarounds on top of someone else’s code, no waiting for a vendor fix.
-
Open source. The library is available to everyone — not just the DataLens team. This means external contributions, public issues, a transparent history of changes. A problem reported by an external user helps improve the product for everyone.
-
A unified visual language. Charts have become an organic part of the DataLens interface, not an inserted element from a third-party vendor. Theming, typography, interactive components — all from one design system.
Writing your own charting library is not a decision to make lightly. It’s a large amount of work, and the need to maintain, document, and grow yet another product.
But if your tool is built around data visualization, if you have non-standard behavior requirements, if you want full control over the appearance and deep integration with a design system — vendor lock on someone else’s library will eventually become a ceiling. We hit that ceiling and decided to remove it.
What’s Next
The library is actively evolving, and we have several directions we’re working on right now.
Framework-agnostic core. Today @gravity‑ui/charts is a React library. We plan to extract the core — data preparation logic, scale building, coordinate calculations — into a separate package with no dependency on any framework. This will open two paths: use on plain JavaScript without React, and the ability to write thin wrappers for Vue, Angular, or other frameworks without duplicating the main logic.
Range slider for categorical axes. The range slider component currently works with numeric and temporal axes — it lets you select a range and “zoom in” to the relevant data segment. But categorical axes (for example, a list of countries or names) are no less important for BI analytics. We are extending the slider to work with categories as well: selecting a subset of values and passing it to data filtering.
Documentation for contributors. Right now, adding a new chart type is a clear process for those who know the library architecture from the inside. But for an external contributor it’s not so obvious. We want to create detailed guides: what a type module is, what contract it must fulfill, how to write tests, how to connect a new visualization to the overall system. The goal is for someone who has never worked with the codebase to be able to independently add a new chart type following the documentation.
The @gravity‑ui/charts library is open-sourced under the MIT license — feel free to try it in your project.
-
Documentation → gravity‑ui.github.io/charts
-
Storybook → preview.gravity‑ui.com/charts
-
GitHub → github.com/gravity‑ui/charts
We’d love a star on GitHub ⭐

Evgeny Alaev
User