The chart interaction Mercury and Copilot ship that almost nobody else does.
Path-interpolated cursor marking with TradingView Lightweight Charts — the visual difference between premium fintech and a generic dashboard is ~180 lines of TypeScript.
Open Mercury, Copilot, or Origin and drag your cursor across the balance chart. The marker dot tracks your cursor pixel-by-pixel along the curve. Now open almost any other fintech app and do the same. The dot teleports between data points. The cursor moves smoothly, but the marker snaps. It's a tiny thing, and it's everywhere.
We hit this while rebuilding Arden's net-worth chart, and we wanted the Mercury feel. It turns out the reason almost nobody ships this is that every popular charting library — Recharts, Chart.js, ECharts, uPlot, TradingView Lightweight Charts — physically locks the marker to a data vertex. You can't turn the snap off. The fix is a custom primitive that paints a marker independent of the series' built-in one, interpolated against the visible path.
This post walks through how we built it on top of TradingView Lightweight Charts (LWC) using its v5 plugin API — plus the two ancillary problems you have to solve to make the marker visually flawless (UTC date math + canvas theme bridge). The full adapter ships in Arden today; we're extracting it as an open-source npm package later this year.
Why Recharts can't do this
Recharts is the default React charting library and it's fine for almost everything. The one thing it can't cleanly do is decouple the tooltip marker from data vertices. The active dot — that's the prop name — is the data point. When the cursor moves between two points, Recharts snaps to whichever is closer. With sparse data (monthly snapshots, weekly rollups), the snap is visually obvious: you scrub left to right and the dot teleports every quarter-second.
You can mitigate this by densifying the data — interpolate your monthly snapshots into one point per day, so the snaps shrink — but the marker still snaps. It now snaps at sub-pixel intervals on a fast cursor, but you can still see it shudder when you move slowly. The right fix isn't more data points. The right fix is a marker that doesn't lock to data points at all.
The marker should be interpolated from the visible path, not snapped to the underlying data. That's the only thing that separates Mercury's chart from a generic dashboard chart, and it's the only thing every popular library stops you from doing.
The CursorMarkerPrimitive
LWC v5 ships a plugin API called ISeriesPrimitive that lets you draw arbitrary content into a chart's paint loop. The contract is small: you register a primitive against a series, the primitive exposes one or more pane views, and each pane view returns a renderer. When LWC draws a frame, it calls your renderer's draw() method with a canvas target. You draw what you want.
We use it to draw exactly one thing: a marker dot at the cursor's X with a Y interpolated from the line. The primitive holds three pieces of state — cursor X (pushed in from the adapter on every crosshair move), the series data (kept in sync with whatever LWC has), and theme colors. On every draw, the renderer:
- Binary-search the data for the cursor's bracket. The two data points immediately to the left and right of the cursor's pixel X. Working in pixel space (not time space) sidesteps LWC's
Timegeneric — it can be a string, a UTC timestamp, or a business-day object depending on configuration — and is just as fast. - Linearly interpolate the value at cursor X. Standard
a + (b - a) × tbetween the two adjacent points. With densified daily data underneath, the linear interp between adjacent days sits within sub-pixel of the visibleLineType.Curvedspline. Indistinguishable from a marker that follows the curve. - Convert price to pixel Y via the series API.
series.priceToCoordinate(value)hands back the pixel Y. The series API knows the current chart bounds, padding, and price scale — you don't have to mirror any of that math yourself.
One more detail in the renderer matters: paint in bitmap coordinate space, not CSS pixels. LWC's useBitmapCoordinateSpace callback exposes device-pixel-ratio multipliers. You draw at the bitmap resolution; the canvas handles CSS downscaling. Skip this step and the marker reads as a soft blob on high-DPI displays — which is exactly the “snapped to a generic dashboard” look we're trying to avoid.
The marker itself is the same recipe Mercury and Origin use: a fill dot in the series color, a 2px ring in the card-solid background color so the dot reads as “lifted over the trace” rather than “drawn on top of it,” and a soft halo underneath for weight without bulk.
The UTC date-math gotcha
Densifying monthly data into daily points sounds trivial — interpolate between consecutive months, emit one point per day, hand the array to LWC. The naive version is two lines of JavaScript and works perfectly nine months of the year.
Then DST happens.
If you compute day boundaries by parsing a string into a Date object and adding 86_400_000 ms each iteration, two consecutive days resolve to the same calendar date on the fall-back transition. The November 2nd you compute from the 1st is the November 2nd you compute from the 2nd — both 23 hours apart in UTC, both 24 hours apart in local time, both producing identical YYYY-MM-DD strings. LWC sees two points with the same timestamp and throws data must be asc ordered by time. The chart doesn't render. Your bug report comes from someone in a timezone that observes DST, two days after you shipped.
The fix is to do all date math in UTC. Date.UTC(yyyy, mm, 15) for the mid-month anchor, add ms in UTC, format with getUTCFullYear / getUTCMonth / getUTCDate. Never touch new Date(string) because the parsing rules depend on whether the string carries a timezone offset, which depends on whether you formatted it yourself or got it from a backend. UTC math is the only thing that's deterministic across every timezone, every DST-observing locale, and every browser.
Bridging CSS variables into canvas
The next problem is mundane and persistent: canvas can't read CSS variables. You hand the renderer a hex string. If your app supports dark mode — and yours probably does — the hex string you handed it at mount time is wrong the moment a user toggles the theme.
The brute-force fix is to subscribe to your theme provider and re-apply chart options on every flip. That works if you're using next-themes everywhere. If you're building a library that needs to work across consumers using different theme mechanisms — or no provider at all — you need a more universal signal.
What works for both is observing the class attribute on document.documentElement. Every dark-mode mechanism we've seen ultimately toggles .dark on <html>: next-themes does it, Tailwind's darkMode: 'class' requires it, custom toggles converge on it. A MutationObserver on that single attribute lets the chart respond to theme changes regardless of how the theme is being managed upstream.
Composing the three pieces
The Mercury / Copilot feel isn't one trick. It's three:
- Densify monthly to daily. Linear interpolation between consecutive monthly snapshots, UTC math throughout. Every adjacent pair of data points becomes 28-31 daily points. The cursor primitive now has enough resolution to interpolate against a visually continuous curve.
- Render the line with
LineType.Curved. LWC's cubic-spline rendering against the densified daily data. The spline is what the user sees. The linear interpolation in the cursor primitive sits within sub-pixel of the visible spline at every cursor X. - Paint the marker via the primitive. Cursor X pushed in from
subscribeCrosshairMove; interpolated Y computed in pixel space; marker painted in bitmap coordinate space with the Mercury-recipe fill + ring + halo. Crosshair mode set to Normal so the marker is the only visible cursor affordance.
Each piece in isolation feels small. Together, they're the difference between a chart that feels like a 2018 dashboard and one that feels like Mercury.
Why TradingView Lightweight Charts
We tried Recharts, ECharts, uPlot, and a half-baked d3 SVG implementation before landing on LWC. Each has tradeoffs; this is what tipped us:
- Real plugin API. ECharts can do similar interpolation with custom graphic elements, but the API surface is much larger and the ergonomics are noticeably heavier. LWC's
ISeriesPrimitiveis the smallest plugin contract we've worked with that still gives you full canvas access. - Bundle size. LWC ships around 50 KB minified; Recharts is roughly 200 KB with D3 dependencies. For a marketing site hero chart this matters.
- It's a finance library. Originally built by TradingView for stock charts. Time-axis math, price-scale formatting, candlestick rendering — all the financial-chart primitives are first- class. We don't use most of them, but the design center is exactly our use case.
- What it can't do. LWC genuinely cannot render pie charts, categorical bar charts (per category, not time), or composed multi-axis charts. We use Recharts for those surfaces and that's correct. The lane split is: time-series area / line goes through LWC; everything else stays Recharts.
What's next
The full adapter ships in production today on ardenmoney.com's homepage hero chart and inside every authenticated page's net-worth surface. We're extracting it as a standalone npm package later this year — separate from Arden, usable by anyone building a financial chart on top of LWC.
The package will include:
- The
<AreaChart>React adapter with theme-aware re-render CursorMarkerPrimitivefor path-interpolated cursor markingdensifyMonthlyToDaily()with UTC-safe date mathuseChartTheme()for CSS-variable → canvas bridging
If you're building a finance dashboard, a price chart, or any time-series interaction where the marker matters — this is the smallest piece of code we've found that crosses the visual-quality threshold separating premium fintech from generic dashboard. The whole thing is ~400 lines.
What this looks like in production
The chart on the homepage. The chart on every dashboard. Same adapter, same primitive, same feel.