Chart
Area, line, bar and pie, drawn from a queryset your view already has — no charting library, no npm, and the series follow your theme on their own.
Move across it: the reading follows the pointer. Switch the site to dark and the two series change colour without a line of JavaScript running again.
Installation
uvx django_shadcn@latest add chart
Nothing else. No npm package, no script tag: scripts is empty for this
component, and the drawing is done with SVG paths and positioned elements the
component writes itself.
Usage
The view serialises; the template passes it on.
import json
def dashboard(request):
rows = (
Order.objects
.values('month')
.annotate(revenue=Sum('total'), profit=Sum('margin'))
.order_by('month')
)
return render(request, 'dashboard.html', {
'series': json.dumps(list(rows)),
'config': json.dumps({
'revenue': {'label': 'Revenue'},
'profit': {'label': 'Profit'},
}),
})
<c-chart data="{{ series }}" config="{{ config }}">
<c-chart.plot>
<c-chart.grid />
<c-chart.axis-y />
<c-chart.axis-x key="month" />
<c-chart.area data_key="revenue" />
</c-chart.plot>
<c-chart.tooltip />
<c-chart.legend />
</c-chart>
Do not put |safe on it. The JSON has to reach the attribute escaped —
Django escapes it, the browser decodes it back, and the component reads the
decoded string. Marked safe, the first quote ends the attribute and the chart
gets nothing.
The config
config maps a key in your rows to how it should be presented. It is the same
shape as upstream's:
{
'revenue': {'label': 'Revenue'},
'profit': {'label': 'Profit', 'color': 'var(--chart-3)'},
}
Without a colour, a key takes the next of the five chart tokens in order.
Those tokens are defined in input.css for light and dark, which is why a
series changes colour when the site does and why a project that themes the
tokens themes the charts along with everything else.
Upstream writes a <style> block per chart to do this. A component that is only
a template cannot inject CSS, so the properties are set on the chart element
instead — the result is the same --color-<key> your own CSS can read.
Anatomy
| Tag | What it is |
|---|---|
<c-chart> |
the data, the config, the colours and the shared scale |
<c-chart.plot> |
the drawing area, measured, and the pointer tracking |
<c-chart.grid> |
a line at each value on the y axis |
<c-chart.axis-y> |
the value ticks; claims its width from the plot |
<c-chart.axis-x> |
the category labels; claims its height from the plot |
<c-chart.area> |
a filled line, stackable |
<c-chart.line> |
a line, with or without dots |
<c-chart.bar> |
columns, grouped or stacked |
<c-chart.pie> |
pie or doughnut; polar, so it replaces the plot |
<c-chart.tooltip> |
the reading under the pointer |
<c-chart.legend> |
one entry per series, or per row for a pie |
Order matters inside the plot: the grid goes first so the series sit on top of it.
Bars
<c-chart.plot>
<c-chart.grid />
<c-chart.axis-y />
<c-chart.axis-x key="month" />
<c-chart.bar data_key="revenue" />
<c-chart.bar data_key="profit" />
</c-chart.plot>
Two bar series share the category side by side. Add stack="true" to both and
they pile up instead, with the axis rescaling to the total:
stack="true" works the same way on <c-chart.area>.
Lines
<c-chart.line> is the stroke without the fill. dots="false" drops the
points, which is what you want when the series is dense.
Pie and doughnut
A pie is polar, so it replaces the plot rather than going inside it. Its config
is keyed by the row names, and the legend takes name_key to match:
<c-chart data="{{ shares }}" config="{{ palette }}">
<c-chart.pie data_key="visitors" name_key="browser" donut="true" />
<c-chart.legend name_key="browser" />
</c-chart>
thickness is how many pixels wide the ring is, measured inwards from the edge.
Sparklines
There is no sparkline component. A sparkline is this one with no grid and no axes, and since each axis claims its own room, dropping them gives the line the whole width:
<c-chart data="{{ series }}" config="{{ config }}" class="aspect-auto h-12">
<c-chart.plot>
<c-chart.line data_key="revenue" dots="false" />
</c-chart.plot>
</c-chart>
That is what sits inside a Metric card.
Numbers follow the site's language
Ticks and readings are formatted with the language Django has active for the
request, not the one the visitor's browser is set to. The same page renders
3,000 in English and 3.000 in Portuguese without being told twice.
Props
| Prop | Where | Default | Meaning |
|---|---|---|---|
data |
<c-chart> |
[] |
the rows, as JSON |
config |
<c-chart> |
{} |
key to label and colour, as JSON |
label |
<c-chart.plot> |
Chart |
names the drawing for a screen reader |
key |
<c-chart.axis-x> |
— | the field holding each row's name |
height |
<c-chart.axis-x> |
24 |
pixels reserved at the bottom |
width |
<c-chart.axis-y> |
44 |
pixels reserved at the left |
data_key |
series | — | the field this series draws |
stack |
area, bar |
false |
pile onto the series declared before it |
dots |
line |
true |
draw a point at each value |
donut |
pie |
false |
leave a hole in the middle |
thickness |
pie |
60 |
how wide the ring is, in pixels |
name_key |
pie, legend |
— | the field naming each row |
Reading it from your own markup
Anything inside the chart can read its scope:
<c-chart data="{{ series }}" config="{{ config }}">
...
<p x-show="hovered >= 0" x-text="reading ? reading.month : ''"></p>
</c-chart>
rows, series, hovered, reading, ticks and format(value) are all
there.
What this does not do
- Nothing is drawn before Alpine starts. The paths are computed from the measured width, and there is no width until the page is laid out. Every charting library works this way; the rest of this library does not, so it is worth knowing.
- No zoom, brush or pan.
- No log scale, and no time axis that picks its own ticks. The x axis is categorical: the view sends the label it wants shown.
- Not for very large series. A few thousand points is where positioned elements start to cost something. A KPI dashboard is nowhere near that.
If one of those is what you need, the honest answer is a charting library, and the registry can declare one per component when the time comes.