Drop-in D3.js charts for Rails. Write Ruby, get beautiful interactive visualizations. No JavaScript required.
Trackplot gives you a Recharts-like DSL that feels right at home in your .erb templates. Under the hood it renders a <trackplot-chart> custom element powered by D3.js — with animations, tooltips, theming, and Turbo support out of the box.
<%= trackplot_chart @monthly_sales do |c| %>
<% c.line :revenue, color: "#6366f1", curve: true %>
<% c.bar :orders, color: "#06b6d4", opacity: 0.6 %>
<% c.axis :x, data_key: :month %>
<% c.axis :y, format: :currency %>
<% c.tooltip format: :currency %>
<% c.reference_line y: 10_000, label: "Goal" %>
<% c.legend %>
<% c.grid %>
<% end %>That's it. No JavaScript files to write, no chart config objects to manage, no build step drama.
| Cartesian | Radial | Hierarchical | Other |
|---|---|---|---|
| Line | Pie / Donut | Treemap | Horizontal Bar |
| Bar (grouped + stacked) | Radar | Funnel | |
| Area (+ stacked) | Candlestick (OHLC) | ||
| Scatter | Heatmap |
Plus sparklines for inline mini-charts in tables and dashboards.
Mix and match freely — bars + lines on the same chart just work.
Add to your Gemfile:
gem "trackplot"Then run the install generator:
bin/rails generate trackplot:installThe generator auto-detects your JS setup (importmap vs jsbundling), installs the right packages, and adds import "trackplot" to application.js.
Pass --stimulus to also get a Stimulus controller with auto-refresh polling and export actions:
bin/rails generate trackplot:install --stimulusYou're done. The engine auto-registers D3 from CDN and pins the trackplot module.
Install D3 and trackplot from npm:
yarn add d3 trackplotThen import it in your app/javascript/application.js:
import "trackplot"Pass any array of hashes (or ActiveRecord collection) and describe what you want:
<%= trackplot_chart @data, height: "300px" do |c| %>
<% c.line :temperature, color: "#ef4444", curve: true %>
<% c.axis :x, data_key: :date %>
<% c.axis :y, label: "Temp (F)" %>
<% c.tooltip %>
<% c.grid %>
<% end %>Your data can use symbol or string keys — Trackplot normalizes both:
@data = [
{ date: "Mon", temperature: 72 },
{ date: "Tue", temperature: 68 },
{ date: "Wed", temperature: 75 }
]<% c.line :revenue, color: "#6366f1", curve: true, dashed: true %>Options: color, curve (smooth), dashed, stroke_width, dot (true/false), dot_size, y_axis.
<% c.bar :sales, color: "#06b6d4", opacity: 0.8, radius: 6 %>Multiple bar series render as grouped bars automatically. Options: color, opacity, radius (corner rounding), stack (group name for stacking), y_axis.
Stack bars by giving them the same stack name:
<% c.bar :revenue, stack: "main", color: "#6366f1" %>
<% c.bar :costs, stack: "main", color: "#f59e0b" %><% c.area :revenue, color: "#8b5cf6", curve: true %>Renders a gradient fill with a stroke line. Stack multiple areas by giving them the same stack name:
<% c.area :revenue, stack: "main", color: "#10b981", curve: true %>
<% c.area :costs, stack: "main", color: "#f59e0b", curve: true %><% c.scatter :weight, color: "#ec4899", dot_size: 6 %>Options: color, dot_size, opacity, x_key (override x-axis key), y_axis.
<% c.pie :value, label_key: :segment %>
<% c.pie :value, label_key: :segment, donut: true %>Options: label_key, donut, pad_angle.
<% c.radar :player_a, color: "#6366f1" %>
<% c.radar :player_b, color: "#ef4444" %>Options: color, opacity, stroke_width, dot, dot_size.
<% c.horizontal_bar :popularity, color: "#14b8a6" %>Same options as regular bar. The x-axis data_key becomes the category axis.
<% c.candlestick open: :open, high: :high, low: :low, close: :close %>Options: up_color, down_color.
<% c.funnel :count, label_key: :stage %>Options: label_key.
Visualize density or intensity across two dimensions:
<%= trackplot_chart @activity_data, height: "300px" do |c| %>
<% c.heatmap x_key: :day, y_key: :hour, value_key: :count,
color_range: ["#f0f9ff", "#1e40af"] %>
<% c.tooltip %>
<% end %>Options: x_key, y_key, value_key, color_range (two-color array), radius.
Show hierarchical data as nested rectangles:
<%= trackplot_chart @budget_data, height: "300px" do |c| %>
<% c.treemap value_key: :amount, label_key: :name, parent_key: :department %>
<% c.tooltip %>
<% end %>Options: value_key, label_key, parent_key (optional — groups data into a hierarchy). Without parent_key, data is treated as flat.
Layer different series types on the same chart:
<%= trackplot_chart @data do |c| %>
<% c.bar :revenue, color: "#06b6d4", opacity: 0.6 %>
<% c.line :profit, color: "#6366f1", curve: true %>
<% c.axis :x, data_key: :month %>
<% c.axis :y %>
<% c.tooltip %>
<% c.legend %>
<% c.grid %>
<% end %>Inline mini-charts for tables and dashboards — no axes, no labels, just the shape:
<%= trackplot_sparkline @trend_data, key: :revenue, type: :line, color: "#6366f1" %>Types: :line (default, with last-dot indicator), :bar, :area.
Options: key: (required), type:, color:, width: (default "120px"), height: (default "32px"), stroke_width:, dot:.
Use them in tables:
<table>
<% @metrics.each do |metric| %>
<tr>
<td><%= metric.name %></td>
<td><%= trackplot_sparkline metric.history, key: :value, color: "#10b981" %></td>
<td><%= metric.current_value %></td>
</tr>
<% end %>
</table><% c.axis :x, data_key: :month %>
<% c.axis :y, label: "Revenue ($)", format: :currency, tick_count: 5 %>Options: data_key, label, format, tick_count, tick_rotation, axis_id.
Compare series with different scales on the same chart:
<%= trackplot_chart @data do |c| %>
<% c.bar :revenue, color: "#6366f1" %>
<% c.line :conversion_rate, color: "#ef4444", y_axis: :right %>
<% c.axis :x, data_key: :month %>
<% c.axis :y, format: :currency %>
<% c.axis :y, axis_id: :right, format: :percent %>
<% end %>Add axis_id: :right to a y-axis, then bind series to it with y_axis: :right. Works on line, bar, area, and scatter.
<% c.tooltip format: :currency %>Options: format, label_format.
<% c.legend position: :top %>Options: position (:top or :bottom), clickable.
<% c.grid horizontal: true, vertical: true %>Options: horizontal (default true), vertical.
Draw horizontal or vertical lines for targets, thresholds, or annotations:
<% c.reference_line y: 5000, label: "Target", color: "#ef4444" %>
<% c.reference_line x: "Mar", label: "Launch", color: "#6366f1", dashed: false %>Options: y or x (value), label, color, dashed (default true), stroke_width.
Show formatted values directly on bars, dots, and pie slices:
<% c.data_label format: :currency, position: :top %>Options: format (any format preset or D3 format string), position (:top, :center, :outside), font_size (default 11).
Interactive range selection for exploring large datasets:
<% c.brush axis: :x %>Renders a mini preview below the chart. Drag to select a range — the chart zooms in. Double-click to reset.
Options: axis (default :x), height (default 40).
Both axes and tooltips accept format presets or raw D3 format strings:
| Preset | Output | Example |
|---|---|---|
:currency |
$1,234 |
format: :currency |
:percent |
42% |
format: :percent |
:compact |
1.2k |
format: :compact |
:decimal |
1,234.56 |
format: :decimal |
:integer |
1,234 |
format: :integer |
Or pass a raw D3 format string: format: "$,.2f".
Four built-in themes, plus fully custom themes via Hash:
<%= trackplot_chart @data, theme: :dark do |c| %>
...
<% end %>Available presets: :default, :dark, :vibrant, :minimal.
Custom theme (merges with defaults):
<%= trackplot_chart @data, theme: { colors: ["#ff0000", "#00ff00"], background: "#111" } do |c| %>
...
<% end %>Theme properties: colors, background, text_color, axis_color, grid_color, tooltip_bg, tooltip_text, tooltip_border, font.
Generate color palettes programmatically instead of hand-picking every color:
# Light-to-dark ramp from a single color
Trackplot::ColorScale.sequential("#6366f1", count: 6)
# => ["#d8d9fb", "#b1b2f7", "#8a8cf3", "#6366f1", "#3c3de0", "#2627a0"]
# Two-color diverging gradient (light midpoint)
Trackplot::ColorScale.diverging("#ef4444", "#3b82f6", count: 7)
# Evenly-spaced hues preserving saturation and lightness
Trackplot::ColorScale.categorical("#6366f1", count: 8)Use them as theme colors:
<%= trackplot_chart @data, theme: { colors: Trackplot::ColorScale.sequential("#6366f1") } do |c| %>
...
<% end %>| Method | Description |
|---|---|
.sequential(hex, count: 8) |
Light-to-dark palette varying lightness from 0.90 to 0.25 |
.diverging(hex1, hex2, count: 8) |
Two-color gradient with light midpoint |
.categorical(hex, count: 8) |
Evenly-spaced hues (360°/count steps) |
All methods accept standard #RGB or #RRGGBB hex strings. count: 0 returns [], count: 1 returns a single color, and invalid hex raises ArgumentError.
Charts support ARIA attributes for screen readers:
<%= trackplot_chart @data, title: "Monthly Revenue", description: "Revenue trend from Jan to Jul" do |c| %>
<% c.line :revenue %>
<% c.axis :x, data_key: :month %>
<% c.axis :y %>
<% end %>When title: is set:
- The
<trackplot-chart>element getsrole="img"andaria-label - The SVG includes
<title>and<desc>elements - Decorative elements (grid lines, crosshair) are marked
aria-hidden="true" - Data points get
aria-labelattributes with their values
Charts gracefully handle empty data with a centered message:
<%= trackplot_chart [], empty_message: "No sales data for this period" do |c| %>
<% c.line :revenue %>
<% c.axis :x, data_key: :month %>
<% c.axis :y %>
<% end %>The default message is "No data available". Customize it with empty_message:.
Click a bar, pie slice, heatmap cell, or treemap rectangle to zoom into nested sub-data. All drill levels are pre-loaded in the JSON config — no server round-trips required. A breadcrumb appears for navigation back.
<%= trackplot_chart @data do |c| %>
<% c.bar :revenue %>
<% c.axis :x, data_key: :region %>
<% c.axis :y, format: :currency %>
<% c.drilldown :breakdown %>
<% end %>Nest sub-data under the drill key. Multi-level drilling is supported:
@data = [
{ region: "North", revenue: 500, breakdown: [
{ region: "NYC", revenue: 200, breakdown: [
{ region: "Manhattan", revenue: 120 },
{ region: "Brooklyn", revenue: 80 }
]},
{ region: "Boston", revenue: 300 }
]},
{ region: "South", revenue: 300, breakdown: [
{ region: "Atlanta", revenue: 300 }
]}
]Works with pie/donut charts too:
<% c.pie :value, label_key: :name %>
<% c.drilldown :breakdown %>Items without the drill key (leaf nodes) fire a normal trackplot:click event instead.
Navigate drill levels programmatically:
const chart = document.querySelector("trackplot-chart")
chart.drillUp() // Go back one level (returns false if at root)
chart.drillReset() // Return to root from any depth (returns false if at root)// Fires after drilling into sub-data
chart.addEventListener("trackplot:drilldown", (e) => {
e.detail.level // current drill depth (1, 2, ...)
e.detail.datum // the clicked datum
e.detail.label // label of the drilled item ("North", "NYC", ...)
})
// Fires after drilling back up
chart.addEventListener("trackplot:drillup", (e) => {
e.detail.level // new drill depth (0 = root)
})Every interactive element (bars, dots, pie slices, funnel stages...) dispatches a trackplot:click CustomEvent that bubbles up the DOM:
document.addEventListener("trackplot:click", function(e) {
console.log(e.detail)
// => { chartType: "bar", dataKey: "revenue", datum: {...}, index: 2, value: 4200 }
})Works great with Stimulus:
<div data-controller="chart" data-action="trackplot:click->chart#onClick">
<%= trackplot_chart @data do |c| %>
...
<% end %>
</div>Download charts as images from JavaScript:
const chart = document.querySelector("trackplot-chart")
// PNG (default 2x resolution)
chart.exportPNG()
chart.exportPNG(3, "revenue.png") // custom scale and filename
// SVG
chart.exportSVG()
chart.exportSVG("revenue.svg")Both methods return a Promise that resolves with the Blob.
Trackplot is built for Turbo. Charts clean up before Turbo caches pages, survive morphing, and re-render cleanly on Turbo Stream updates.
Pass id: so turbo_stream.replace can target your chart:
<%= trackplot_chart @data, id: "revenue-chart" do |c| %>
<% c.line :revenue, curve: true %>
<% c.axis :x, data_key: :month %>
<% c.axis :y %>
<% end %>Then push updates from your server:
<%= turbo_stream.replace "revenue-chart" do %>
<%= trackplot_chart @fresh_data, id: "revenue-chart" do |c| %>
<% c.line :revenue, curve: true %>
<% c.axis :x, data_key: :month %>
<% c.axis :y %>
<% end %>
<% end %>Update chart data from JavaScript (e.g., from a Stimulus controller receiving ActionCable broadcasts):
const chart = document.getElementById("revenue-chart")
chart.updateData(newDataArray)Push new data points without a full re-render — great for live dashboards:
const chart = document.getElementById("live-chart")
chart.appendData(
[{ time: "12:05", value: 42 }],
{ maxPoints: 50 } // sliding window
)Dispatches a trackplot:data-update event after each append.
The trackplot:render event fires after every render:
document.addEventListener("trackplot:render", function(e) {
console.log("Chart ready:", e.target.id)
})Run bin/rails generate trackplot:install --stimulus to get a controller with auto-refresh polling and export actions:
<div data-controller="trackplot"
data-trackplot-url-value="/api/chart_data.json"
data-trackplot-interval-value="5000">
<%= trackplot_chart @data do |c| %>
<% c.line :revenue, curve: true %>
<% c.axis :x, data_key: :month %>
<% c.axis :y %>
<% end %>
<button data-action="trackplot#exportPng">Download PNG</button>
<button data-action="trackplot#exportSvg">Download SVG</button>
</div>| Value | Type | Description |
|---|---|---|
url |
String | JSON endpoint to poll for fresh data |
interval |
Number | Polling interval in ms (0 = disabled) |
Actions: exportPng, exportSvg, refresh (manual trigger).
If you use ViewComponent or Phlex, Trackplot provides optional integrations.
# In your view
render Trackplot::Component.new(data: @data, height: "300px") { |c|
c.line :revenue, color: "#6366f1"
c.axis :x, data_key: :month
c.axis :y
}Requires view_component in your Gemfile. Trackplot loads the component class only when ViewComponent is available.
render Trackplot::PhlexComponent.new(data: @data, height: "300px") { |c|
c.line :revenue, color: "#6366f1"
c.axis :x, data_key: :month
c.axis :y
}Requires phlex in your Gemfile.
Pass options directly to trackplot_chart:
| Option | Default | Description |
|---|---|---|
id: |
auto-generated | Stable DOM id for Turbo targeting |
width: |
"100%" |
CSS width |
height: |
"400px" |
CSS height |
animate: |
true |
Entry animations |
theme: |
:default |
Theme preset or custom Hash |
class: |
nil |
Additional CSS classes |
title: |
nil |
Accessibility label (adds ARIA attributes) |
description: |
nil |
Accessibility description (requires title:) |
empty_message: |
"No data available" |
Message shown when data is empty |
Trackplot ships TypeScript declarations for the npm package. Type-checked querySelector narrows to the correct element:
const chart = document.querySelector("trackplot-chart")
// => TrackplotElement | null
chart?.addEventListener("trackplot:click", (e) => {
e.detail.chartType // string
e.detail.dataKey // string
e.detail.value // unknown
})
chart?.exportPNG(2, "report.png") // Promise<Blob | null>All config interfaces are exported: ChartConfig, LineConfig, BarConfig, ThemeConfig, SparklineConfig, etc.
Run the Ruby test suite:
ruby -Ilib -Itest -e "Dir['test/**/*_test.rb'].each { |f| require File.expand_path(f) }"Run the JavaScript test suite (Vitest + jsdom):
npm testBoot the demo app:
cd test/dummy && bin/rails serverSee CONTRIBUTING.md for development setup and contribution guidelines.
MIT License. See LICENSE.txt.
Made️ by eagerworks