Async Drilldown Column in Vue

Using ApexCharts with Vue

This Async Drilldown Column example wires ApexCharts into Vue through the vue3-apexcharts component.

Install it with npm install vue3-apexcharts apexcharts, then mount the chart with <apexchart type="..." :options="options" :series="series" />.

Vue charts guide
<template>
  <div>
    <div class="wrap">
      <h1>Drilldown against a real backend</h1>
      <p>
        The root shows three years. Each year's quarters are fetched on click,
        with a spinner while the request is in flight. Levels are cached, so
        drilling back down a branch you have already visited is instant. Tick
        "make the next request fail" to see that a failed fetch leaves the chart
        exactly where it was and reports the error, rather than stranding the
        view.
      </p>

      <div class="actions">
        <button id="up">Back</button>
        <button id="clear-cache">Clear cache</button>
        <label>
          <input type="checkbox" id="fail" />
          Make the next request fail
        </label>
      </div>

      <div class="chart-wrap">
        <div id="chart">
          <apexchart
            type="bar"
            height="400"
            :options="chartOptions"
            :series="series"
          ></apexchart>
        </div>
      </div>

      <div class="status" id="status"></div>
    </div>
  </div>
</template>

<script>
import VueApexCharts from 'vue-apexcharts'
import ApexCharts from 'apexcharts'

import 'apexcharts/features/drilldown'

// Shared by the vanilla-js, React and Vue builds.
//
// Levels are FETCHED, not inlined, which is how a real dashboard works: the
// root is small, and each child is a request. A fake backend stands in for the
// API so the demo is self-contained and deterministic.
var BACKEND = {
  '2023-q': [
    { x: 'Q1', y: 21 },
    { x: 'Q2', y: 28 },
    { x: 'Q3', y: 24 },
    { x: 'Q4', y: 27 },
  ],
  '2024-q': [
    { x: 'Q1', y: 33 },
    { x: 'Q2', y: 41 },
    { x: 'Q3', y: 36 },
    { x: 'Q4', y: 40 },
  ],
  '2025-q': [
    { x: 'Q1', y: 44 },
    { x: 'Q2', y: 52 },
    { x: 'Q3', y: 48 },
    { x: 'Q4', y: 56 },
  ],
}

// Flipped by the checkbox, so the failure path is demonstrable rather than
// described. A failed fetch must leave the chart exactly where it was.
var failNext = false

function fakeFetch(id) {
  return new Promise(function (resolve, reject) {
    window.setTimeout(function () {
      if (failNext) {
        reject(new Error('Request failed (simulated)'))
        return
      }
      var rows = BACKEND[id]
      if (!rows) {
        reject(new Error('No level "' + id + '"'))
        return
      }
      resolve({ id: id, name: id.replace('-q', ' by quarter'), data: rows })
    }, 700)
  })
}

function setStatus(text, isError) {
  var el = document.querySelector('#status')
  if (!el) return
  el.textContent = text || ''
  el.className = isError ? 'status error' : 'status'
}

export default {
  components: {
    apexchart: VueApexCharts,
  },
  data: function () {
    return {
      series: [
        {
          name: 'Revenue',
          data: [
            { x: '2023', y: 100, drilldown: '2023-q' },
            { x: '2024', y: 150, drilldown: '2024-q' },
            { x: '2025', y: 200, drilldown: '2025-q' },
          ],
        },
      ],
      chartOptions: {
        chart: {
          id: 'asyncDrill',
          type: 'bar',
          height: 400,
          toolbar: {
            show: false,
          },
        },
        plotOptions: {
          bar: {
            distributed: true,
            columnWidth: '50%',
            borderRadius: 6,
            borderRadiusApplication: 'end',
          },
        },
        legend: {
          show: false,
        },
        dataLabels: {
          enabled: false,
        },
        drilldown: {
          enabled: true,
          // Deliberately empty: every level below the root comes from onDrillDown.
          series: [],
          breadcrumb: {
            show: true,
            rootLabel: 'All years',
          },
          loading: {
            show: true,
          },
          cache: true,
        },
      },
      drillTimer: null,
    }
  },
  mounted: function () {
    // The vue-apexcharts wrapper owns the render, so reach the live instance by
    // its chart.id, then wire the resolver and controls. (BACKEND/fakeFetch/
    // setStatus live in the shared head script.)
    var me = this

    me.drillTimer = window.setInterval(function () {
      var chart = ApexCharts.getChartByID('asyncDrill')
      if (!chart) return
      window.clearInterval(me.drillTimer)

      chart.updateOptions({
        drilldown: {
          onDrillDown: function (ctx) {
            setStatus('Fetching ' + ctx.id + '...')
            return fakeFetch(ctx.id)
          },
        },
      })

      chart.addEventListener('drillDownEnd', function () {
        setStatus('')
      })
      chart.addEventListener('drillDownError', function (info) {
        setStatus(String(info.error && info.error.message), true)
      })

      document.querySelector('#up').addEventListener('click', function () {
        chart.drillUp()
      })
      document
        .querySelector('#clear-cache')
        .addEventListener('click', function () {
          chart.clearDrilldownCache()
          setStatus('Cache cleared - the next drill will refetch.')
        })
      document.querySelector('#fail').addEventListener('change', function (e) {
        failNext = e.target.checked
      })
    }, 50)
  },
  beforeDestroy: function () {
    window.clearInterval(this.drillTimer)
  },
}
</script>

<style>
body {
  font-family:
    -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
  background: #fafbfc;
  color: #2c3e50;
  margin: 0;
  padding: 32px 16px;
}
.wrap {
  max-width: 760px;
  margin: 0 auto;
}
h1 {
  font-size: 22px;
  margin: 0 0 8px;
}
p {
  color: #5b6b78;
  line-height: 1.5;
  margin: 0 0 24px;
}
.chart-wrap {
  background: #fff;
  border-radius: 8px;
  padding: 16px;
  box-shadow: 0 2px 10px rgba(0, 0, 0, 0.04);
  position: relative;
}
.actions {
  margin: 20px auto 12px;
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  justify-content: center;
  align-items: center;
}
.actions button {
  color: #fff;
  background: #008ffb;
  border: none;
  padding: 8px 16px;
  font-weight: 600;
  font-size: 13px;
  border-radius: 6px;
  cursor: pointer;
}
.actions label {
  font-size: 13px;
  color: #5b6b78;
  display: inline-flex;
  align-items: center;
  gap: 6px;
}
.status {
  min-height: 20px;
  text-align: center;
  font-size: 13px;
  color: #5b6b78;
  margin-top: 12px;
}
.status.error {
  color: #d7263d;
  font-weight: 600;
}
</style>