ChartsEx

SVG chart rendering for Elixir powered by charts-rs -- 10 chart types, 9 built-in themes, an idiomatic builder API, and a Phoenix component for LiveView.

Installation

Add charts_ex to your dependencies in mix.exs:

def deps do
  [
    {:charts_ex, "~> 0.1.0"}
  ]
end

Quick Start

alias ChartsEx.BarChart

{:ok, svg} =
  BarChart.new()
  |> BarChart.title("Monthly Revenue")
  |> BarChart.theme(:grafana)
  |> BarChart.width(600)
  |> BarChart.height(400)
  |> BarChart.x_axis(["Jan", "Feb", "Mar", "Apr", "May", "Jun"])
  |> BarChart.add_series("Revenue ($k)", [48.2, 53.1, 61.7, 59.3, 72.0, 84.5])
  |> ChartsEx.render()

# svg is a self-contained SVG string -- write it to a file, embed it in HTML, etc.
File.write!("revenue.svg", svg)

Examples

Bar Chart

Basic vertical bar chart with two series and a theme:

alias ChartsEx.BarChart

{:ok, svg} =
  BarChart.new()
  |> BarChart.title("Quarterly Sales by Region")
  |> BarChart.sub_title("FY 2025 — All figures in $k")
  |> BarChart.theme(:ant)
  |> BarChart.width(700)
  |> BarChart.height(420)
  |> BarChart.x_axis(["Q1", "Q2", "Q3", "Q4"])
  |> BarChart.add_series("North America", [312.0, 348.0, 295.0, 410.0])
  |> BarChart.add_series("Europe", [228.0, 265.0, 241.0, 302.0])
  |> BarChart.series_colors(["#5470C6", "#91CC75"])
  |> BarChart.legend_align(:right)
  |> BarChart.radius(4.0)
  |> ChartsEx.render()

Bar chart with a line overlay (dual series types) and mark lines:

alias ChartsEx.BarChart

{:ok, svg} =
  BarChart.new()
  |> BarChart.title("Website Traffic vs. Conversion Rate")
  |> BarChart.theme(:walden)
  |> BarChart.width(720)
  |> BarChart.height(400)
  |> BarChart.x_axis(["Jan", "Feb", "Mar", "Apr", "May", "Jun"])
  |> BarChart.y_axis_configs([
    %{axis_font_size: 12},
    %{axis_font_size: 12}
  ])
  |> BarChart.add_series("Visitors", [14200.0, 16800.0, 15400.0, 18900.0, 21300.0, 24100.0],
    mark_lines: [%{category: "average"}]
  )
  |> BarChart.add_series("Conversion %", [2.1, 2.4, 1.9, 3.0, 3.5, 3.8],
    category: "line",
    y_axis_index: 1,
    stroke_dash_array: "4,2"
  )
  |> ChartsEx.render()

Line Chart

Smooth curves with area fill:

alias ChartsEx.LineChart

{:ok, svg} =
  LineChart.new()
  |> LineChart.title("Server Response Times")
  |> LineChart.sub_title("p50 and p99 latency in ms")
  |> LineChart.theme(:grafana)
  |> LineChart.width(700)
  |> LineChart.height(400)
  |> LineChart.smooth(true)
  |> LineChart.fill(true)
  |> LineChart.x_axis(["00:00", "04:00", "08:00", "12:00", "16:00", "20:00", "23:59"])
  |> LineChart.add_series("p50", [12.0, 8.0, 35.0, 62.0, 48.0, 24.0, 14.0])
  |> LineChart.add_series("p99", [45.0, 28.0, 120.0, 210.0, 165.0, 78.0, 52.0],
    mark_lines: [%{category: "average"}],
    mark_points: [%{category: "max"}]
  )
  |> LineChart.series_colors(["#73C0DE", "#EE6666"])
  |> ChartsEx.render()

Pie Chart

Donut chart with inner radius:

alias ChartsEx.PieChart

{:ok, svg} =
  PieChart.new()
  |> PieChart.title("Browser Market Share")
  |> PieChart.sub_title("Global — March 2025")
  |> PieChart.theme(:westeros)
  |> PieChart.width(500)
  |> PieChart.height(400)
  |> PieChart.inner_radius(80.0)
  |> PieChart.radius(150.0)
  |> PieChart.border_radius(4.0)
  |> PieChart.add_series("Chrome", [64.7])
  |> PieChart.add_series("Safari", [18.6])
  |> PieChart.add_series("Firefox", [3.2])
  |> PieChart.add_series("Edge", [5.3])
  |> PieChart.add_series("Other", [8.2])
  |> PieChart.series_colors(["#5470C6", "#91CC75", "#FAC858", "#EE6666", "#73C0DE"])
  |> ChartsEx.render()

Horizontal Bar Chart

alias ChartsEx.HorizontalBarChart

{:ok, svg} =
  HorizontalBarChart.new()
  |> HorizontalBarChart.title("Top Programming Languages by Job Postings")
  |> HorizontalBarChart.theme(:ant)
  |> HorizontalBarChart.width(650)
  |> HorizontalBarChart.height(400)
  |> HorizontalBarChart.x_axis([
    "Elixir", "Go", "Rust", "TypeScript", "Python", "Java"
  ])
  |> HorizontalBarChart.add_series("Job Postings (thousands)", [
    8.4, 22.1, 12.7, 58.3, 92.5, 74.0
  ], label_show: true)
  |> HorizontalBarChart.series_colors(["#5470C6"])
  |> ChartsEx.render()

Radar Chart

Multi-dimensional comparison with indicators:

alias ChartsEx.RadarChart

{:ok, svg} =
  RadarChart.new()
  |> RadarChart.title("Framework Comparison")
  |> RadarChart.sub_title("Phoenix vs. Rails vs. Next.js")
  |> RadarChart.theme(:vintage)
  |> RadarChart.width(560)
  |> RadarChart.height(460)
  |> RadarChart.indicators([
    %{name: "Performance", max: 100.0},
    %{name: "Scalability", max: 100.0},
    %{name: "Ecosystem", max: 100.0},
    %{name: "Learning Curve", max: 100.0},
    %{name: "Realtime", max: 100.0},
    %{name: "Deployment", max: 100.0}
  ])
  |> RadarChart.add_series("Phoenix", [92.0, 95.0, 58.0, 55.0, 98.0, 72.0])
  |> RadarChart.add_series("Rails", [65.0, 70.0, 90.0, 75.0, 50.0, 80.0])
  |> RadarChart.add_series("Next.js", [78.0, 72.0, 95.0, 68.0, 65.0, 88.0])
  |> RadarChart.series_colors(["#6C5CE7", "#E17055", "#00B894"])
  |> ChartsEx.render()

Scatter Chart

alias ChartsEx.ScatterChart

{:ok, svg} =
  ScatterChart.new()
  |> ScatterChart.title("House Price vs. Square Footage")
  |> ScatterChart.sub_title("Austin, TX — 2025 listings")
  |> ScatterChart.theme(:walden)
  |> ScatterChart.width(650)
  |> ScatterChart.height(420)
  |> ScatterChart.add_series("3-Bedroom", [
    [1200.0, 285.0], [1450.0, 320.0], [1680.0, 375.0],
    [1890.0, 410.0], [2100.0, 465.0], [2350.0, 520.0],
    [1550.0, 340.0], [1750.0, 390.0], [2000.0, 445.0]
  ])
  |> ScatterChart.add_series("4-Bedroom", [
    [1800.0, 420.0], [2050.0, 485.0], [2300.0, 540.0],
    [2600.0, 610.0], [2850.0, 675.0], [3100.0, 740.0],
    [2200.0, 520.0], [2500.0, 590.0], [2750.0, 650.0]
  ])
  |> ScatterChart.symbol_sizes([6.0, 8.0])
  |> ScatterChart.series_colors(["#5470C6", "#EE6666"])
  |> ChartsEx.render()

Candlestick Chart

Financial OHLC data:

alias ChartsEx.CandlestickChart

{:ok, svg} =
  CandlestickChart.new()
  |> CandlestickChart.title("AAPL Stock Price")
  |> CandlestickChart.sub_title("Weekly — Jan to Mar 2025")
  |> CandlestickChart.theme(:dark)
  |> CandlestickChart.width(750)
  |> CandlestickChart.height(420)
  |> CandlestickChart.up_color("#26A69A")
  |> CandlestickChart.up_border_color("#26A69A")
  |> CandlestickChart.down_color("#EF5350")
  |> CandlestickChart.down_border_color("#EF5350")
  |> CandlestickChart.x_axis([
    "Jan 6", "Jan 13", "Jan 20", "Jan 27",
    "Feb 3", "Feb 10", "Feb 17", "Feb 24",
    "Mar 3", "Mar 10", "Mar 17", "Mar 24"
  ])
  |> CandlestickChart.add_series("AAPL", [
    [243.0, 248.5, 240.1, 250.3],
    [248.5, 252.0, 246.2, 254.7],
    [252.0, 245.8, 243.5, 253.0],
    [245.8, 249.2, 244.0, 251.5],
    [249.2, 256.3, 248.0, 258.1],
    [256.3, 261.0, 254.5, 263.8],
    [261.0, 258.4, 255.2, 262.5],
    [258.4, 264.7, 257.0, 266.3],
    [264.7, 260.1, 258.3, 266.0],
    [260.1, 268.5, 259.0, 270.2],
    [268.5, 272.3, 266.8, 275.0],
    [272.3, 270.8, 268.5, 274.1]
  ])
  |> ChartsEx.render()

Heatmap Chart

Color-coded grid with gradient:

alias ChartsEx.HeatmapChart

{:ok, svg} =
  HeatmapChart.new()
  |> HeatmapChart.title("Deploy Frequency by Day and Hour")
  |> HeatmapChart.theme(:grafana)
  |> HeatmapChart.width(700)
  |> HeatmapChart.height(340)
  |> HeatmapChart.x_axis(["Mon", "Tue", "Wed", "Thu", "Fri"])
  |> HeatmapChart.y_axis(["9 AM", "11 AM", "1 PM", "3 PM", "5 PM"])
  |> HeatmapChart.series(%{
    data: [
      # [x_index, y_index, value]
      [0, 0, 3],  [0, 1, 5],  [0, 2, 8],  [0, 3, 12], [0, 4, 4],
      [1, 0, 7],  [1, 1, 11], [1, 2, 15], [1, 3, 9],  [1, 4, 2],
      [2, 0, 10], [2, 1, 18], [2, 2, 22], [2, 3, 14], [2, 4, 6],
      [3, 0, 5],  [3, 1, 8],  [3, 2, 19], [3, 3, 16], [3, 4, 7],
      [4, 0, 2],  [4, 1, 6],  [4, 2, 13], [4, 3, 10], [4, 4, 1]
    ],
    min: 0,
    max: 25,
    min_color: "#E0F3F8",
    max_color: "#0B3D91"
  })
  |> ChartsEx.render()

Table Chart

SVG-rendered data table with styled headers and alternating rows:

alias ChartsEx.TableChart

{:ok, svg} =
  TableChart.new()
  |> TableChart.title("Q1 2025 — SaaS Metrics")
  |> TableChart.theme(:light)
  |> TableChart.width(680)
  |> TableChart.height(300)
  |> TableChart.data([
    ["Metric", "January", "February", "March", "Trend"],
    ["MRR", "$142,300", "$148,900", "$156,200", "+9.8%"],
    ["Churn Rate", "2.1%", "1.8%", "1.6%", "-0.5pp"],
    ["New Customers", "87", "104", "119", "+36.8%"],
    ["NPS Score", "62", "65", "71", "+9pts"],
    ["Avg. Deal Size", "$4,280", "$4,510", "$4,750", "+11.0%"]
  ])
  |> TableChart.spans([2.0, 1.0, 1.0, 1.0, 1.0])
  |> TableChart.text_aligns([:left, :right, :right, :right, :right])
  |> TableChart.header_background_color("#4A5568")
  |> TableChart.header_font_color("#FFFFFF")
  |> TableChart.body_background_colors(["#F7FAFC", "#EDF2F7"])
  |> TableChart.border_color("#CBD5E0")
  |> TableChart.cell_styles([
    %{indexes: [4], font_color: "#38A169"}
  ])
  |> ChartsEx.render()

Multi Chart

Combine a bar chart and a line chart into one SVG:

alias ChartsEx.{BarChart, LineChart, MultiChart}

bar =
  BarChart.new()
  |> BarChart.title("Monthly Revenue ($k)")
  |> BarChart.theme(:ant)
  |> BarChart.width(600)
  |> BarChart.height(300)
  |> BarChart.x_axis(["Jan", "Feb", "Mar", "Apr", "May", "Jun"])
  |> BarChart.add_series("Product A", [42.0, 48.0, 53.0, 51.0, 60.0, 68.0])
  |> BarChart.add_series("Product B", [28.0, 32.0, 35.0, 41.0, 38.0, 45.0])

line =
  LineChart.new()
  |> LineChart.title("Cumulative Growth (%)")
  |> LineChart.theme(:ant)
  |> LineChart.width(600)
  |> LineChart.height(300)
  |> LineChart.smooth(true)
  |> LineChart.x_axis(["Jan", "Feb", "Mar", "Apr", "May", "Jun"])
  |> LineChart.add_series("Growth", [0.0, 14.3, 25.7, 31.4, 40.0, 61.4])

{:ok, svg} =
  MultiChart.new()
  |> MultiChart.add_chart(bar)
  |> MultiChart.add_chart(line)
  |> MultiChart.gap(20.0)
  |> MultiChart.margin(%{left: 10, top: 10, right: 10, bottom: 10})
  |> ChartsEx.render()

Themes

ChartsEx ships with 9 built-in themes. Pass any of them to the .theme/2 function:

Theme Description
:light Clean white background with soft colors. Default theme.
:dark Dark gray background with bright, high-contrast series colors.
:grafana Monitoring-dashboard aesthetic -- dark background with greens and oranges.
:ant Ant Design-inspired palette with blues and teals on a white background.
:vintage Muted, warm tones (burgundy, olive, tan) with a parchment feel.
:walden Nature-inspired soft blues and greens, light background.
:westeros Subdued earth tones with a medieval cartography vibe.
:chalk Bold neon colors on a dark background, chalkboard style.
:shine Vibrant, high-saturation gradients on a dark background.
# List all themes programmatically
ChartsEx.Theme.list()
# => [:light, :dark, :grafana, :ant, :vintage, :walden, :westeros, :chalk, :shine]

# Apply a theme
BarChart.new() |> BarChart.theme(:chalk)

Customization

Every chart supports fine-grained control over colors, fonts, margins, legends, and axes. Here is a "fully loaded" example showing many options at once:

alias ChartsEx.BarChart

{:ok, svg} =
  BarChart.new()
  # Title
  |> BarChart.title("Engineering Headcount by Department")
  |> BarChart.sub_title("Updated March 2025")
  # Dimensions
  |> BarChart.width(750)
  |> BarChart.height(450)
  # Theme as a starting point, then override individual settings
  |> BarChart.theme(:light)
  |> BarChart.background_color("#FAFAFA")
  # Custom series palette
  |> BarChart.series_colors(["#6366F1", "#F59E0B", "#10B981"])
  # Legend
  |> BarChart.legend_align(:right)
  |> BarChart.legend_margin(%{top: 5, bottom: 15, left: 0, right: 0})
  # Margins
  |> BarChart.margin(%{left: 15, top: 20, right: 15, bottom: 15})
  # Corner radius on bars
  |> BarChart.radius(3.0)
  # Dual y-axis config
  |> BarChart.y_axis_configs([
    %{axis_font_size: 12, axis_font_color: "#333333"},
    %{axis_font_size: 12, axis_font_color: "#999999"}
  ])
  # X-axis categories
  |> BarChart.x_axis(["Platform", "Backend", "Frontend", "Data", "DevOps", "Security"])
  # Bar series
  |> BarChart.add_series("Full-Time", [24.0, 38.0, 31.0, 18.0, 12.0, 9.0],
    label_show: true,
    mark_lines: [%{category: "average"}]
  )
  |> BarChart.add_series("Contractors", [6.0, 12.0, 8.0, 5.0, 3.0, 2.0])
  # Line overlay for growth rate
  |> BarChart.add_series("YoY Growth %", [15.0, 22.0, 18.0, 30.0, 25.0, 40.0],
    category: "line",
    y_axis_index: 1,
    stroke_dash_array: "5,3",
    mark_points: [%{category: "max"}]
  )
  |> ChartsEx.render()

Summary of shared builder functions

Most chart types support these common setters:

Function Purpose
title/2 Chart title text
sub_title/2 Subtitle below the title
theme/2 Apply a built-in theme
width/2, height/2 Chart dimensions in pixels
margin/2 Outer margin as %{left: _, top: _, right: _, bottom: _}
background_color/2 Background fill color (hex string)
series_colors/2 Series palette as a list of hex strings
legend_align/2 Legend position: :left, :center, or :right
legend_margin/2 Legend margin map
x_axis/2 X-axis category labels
y_axis_configs/2 Y-axis configuration list (supports dual axes)
add_series/3,4 Add a named data series with optional keyword opts

Three Input Modes

ChartsEx accepts chart definitions in three formats. All three produce identical SVG output.

1. Builder structs (recommended)

The builder API is the idiomatic way to construct charts. Each chart type has its own module with dedicated setter functions:

alias ChartsEx.LineChart

{:ok, svg} =
  LineChart.new()
  |> LineChart.title("Monthly Active Users")
  |> LineChart.theme(:grafana)
  |> LineChart.width(650)
  |> LineChart.height(380)
  |> LineChart.smooth(true)
  |> LineChart.x_axis(["Jul", "Aug", "Sep", "Oct", "Nov", "Dec"])
  |> LineChart.add_series("MAU (thousands)", [124.0, 131.0, 148.0, 162.0, 155.0, 178.0])
  |> ChartsEx.render()

2. Atom-key maps

Pass a plain Elixir map with atom keys. Use the :type key to specify the chart type. This is useful when chart configurations come from a database or config file:

{:ok, svg} =
  ChartsEx.render(%{
    type: :line,
    title_text: "Monthly Active Users",
    theme: "grafana",
    width: 650,
    height: 380,
    series_smooth: true,
    x_axis_data: ["Jul", "Aug", "Sep", "Oct", "Nov", "Dec"],
    series_list: [
      %{name: "MAU (thousands)", data: [124.0, 131.0, 148.0, 162.0, 155.0, 178.0]}
    ]
  })

Supported :type values: :bar, :horizontal_bar, :line, :pie, :radar, :scatter, :candlestick, :heatmap, :table, :multi_chart.

3. Raw JSON

Pass a JSON string directly. This is handy if you receive chart configs from an API or store them as JSON blobs:

json = ~s({
  "type": "line",
  "title_text": "Monthly Active Users",
  "theme": "grafana",
  "width": 650,
  "height": 380,
  "series_smooth": true,
  "x_axis_data": ["Jul", "Aug", "Sep", "Oct", "Nov", "Dec"],
  "series_list": [
    {"name": "MAU (thousands)", "data": [124.0, 131.0, 148.0, 162.0, 155.0, 178.0]}
  ]
})

{:ok, svg} = ChartsEx.render(json)

Phoenix / LiveView

Component usage

ChartsEx ships with a Phoenix function component that renders charts as inline SVG inside a wrapping <div>:

<ChartsEx.Component.chart config={@chart} class="mx-auto max-w-2xl" id="sales-chart" />

The config attribute accepts any of the three input modes (struct, map, or JSON string).

Manual rendering

If you prefer to render the SVG yourself:

{raw(ChartsEx.render!(@chart))}

Full LiveView example

A chart that updates dynamically when assigns change:

defmodule MyAppWeb.DashboardLive do
  use MyAppWeb, :live_view

  alias ChartsEx.BarChart

  @impl true
  def mount(_params, _session, socket) do
    if connected?(socket), do: :timer.send_interval(5_000, self(), :refresh)

    {:ok, assign(socket, chart: build_chart(fetch_metrics()))}
  end

  @impl true
  def handle_info(:refresh, socket) do
    {:noreply, assign(socket, chart: build_chart(fetch_metrics()))}
  end

  defp build_chart({labels, values}) do
    BarChart.new()
    |> BarChart.title("Live Request Volume")
    |> BarChart.theme(:grafana)
    |> BarChart.width(620)
    |> BarChart.height(380)
    |> BarChart.x_axis(labels)
    |> BarChart.add_series("Requests/min", values)
    |> BarChart.series_colors(["#73C0DE"])
  end

  defp fetch_metrics do
    # Replace with your actual data source
    labels = ["API", "Web", "WebSocket", "Webhook", "Cron"]
    values = Enum.map(labels, fn _ -> :rand.uniform(500) * 1.0 end)
    {labels, values}
  end

  @impl true
  def render(assigns) do
    ~H"""
    <div class="p-8">
      <h1 class="text-2xl font-bold mb-4">System Dashboard</h1>
      <ChartsEx.Component.chart config={@chart} class="mx-auto max-w-3xl" id="req-volume" />
    </div>
    """
  end
end

Raster Output

ChartsEx produces SVG strings. To convert to PNG, JPEG, or other raster formats, use Vix (libvips bindings for Elixir):

# Add to mix.exs: {:vix, "~> 0.30"}

alias ChartsEx.BarChart

{:ok, svg} =
  BarChart.new()
  |> BarChart.title("Export Example")
  |> BarChart.x_axis(["A", "B", "C"])
  |> BarChart.add_series("Values", [10.0, 20.0, 15.0])
  |> ChartsEx.render()

# SVG -> PNG
{:ok, {image, _flags}} = Vix.Vips.Operation.svgload_buffer(svg)
:ok = Vix.Vips.Image.write_to_file(image, "chart.png")

# SVG -> JPEG (with quality setting)
:ok = Vix.Vips.Image.write_to_file(image, "chart.jpg[Q=90]")

Building from Source

By default, ChartsEx uses precompiled NIF binaries (via rustler_precompiled) so you do not need Rust installed. To compile the Rust NIF locally instead:

export CHARTS_EX_BUILD=true
mix deps.compile charts_ex --force

Requirements:

Supported targets: aarch64-apple-darwin, aarch64-unknown-linux-gnu, x86_64-apple-darwin, x86_64-unknown-linux-gnu, x86_64-unknown-linux-musl.

License

MIT