Beautiful, animated, fully customizable charts for Jetpack Compose.
Drop-in chart cards that look great by default, and a styling API that lets you change every colour, size and spacing.
The full docs live at nameisjayant.github.io/Compose-Charts: a getting started guide, plus a page for every chart with a live preview, copyable code for the data, the chart card and the bare chart, and notes on how it behaves.
- 📊 Ready-made chart cards: title, subtitle, legend and an action slot, all built in.
- 🎨 Every visual is configurable through immutable
XxxStyleclasses built withXxxDefaults.style(...). - ✨ Animated by default: values animate on first draw and whenever the data changes. Pass
animationSpec = nullto turn it off. - 🧩 Use the parts you need: use a full
XxxChartCard, or put the bare chart composable inside your own layout. - 🪶 Light dependencies: only Compose UI, Foundation and Coroutines. No Hilt, Material or navigation library is forced on your app.
- 🔒 Explicit API mode, so the public API is exactly what is meant to be public.
| Chart | Composables | Highlights |
|---|---|---|
| Vertical bar | VerticalBarChart, VerticalBarChartCard |
One or two series (up and down from a baseline), pill-shaped bars, grid lines |
| Area range and line | AreaRangeLineChart, AreaRangeLineChartCard |
Smoothed line, fading ribbon, data points, axis ranges and label formatters |
| Donut | DonutChart, DonutChartCard |
Rounded overlapping segments on a track, animated percentage in the centre, side legend and a summary row |
| Doughnut | DoughnutChart, DoughnutChartCard |
Segments that get thicker one after another, a shaded hole, a tinted header and a footer slot |
| Spline area | SplineAreaChart, SplineAreaChartCard |
A thick smoothed line over a fading fill, edge to edge, under a headline value, change badge and action tile |
| Stacked bar | StackedBarChart, StackedBarChartCard |
Any number of series stacked per bar, rounded stack ends, a title icon, a description and a trend label |
| Horizontal bar | HorizontalBarChart, HorizontalBarChartCard |
Pill-shaped fills over lighter tracks, a profile row with avatar and action button, and a clipped description |
| Ranked bar | RankedBarChart, RankedBarChartCard |
Flush bars sorted longest first in a shaded palette, labels inside each bar in a contrasting colour, a list header and a footer note |
| Overlapping area | OverlappingAreaChart, OverlappingAreaChartCard |
Translucent straight-edged areas laid over each other, a coloured header, filter chips, a legend, a profile row and a footer note |
| Mirrored area | MirroredAreaChart, MirroredAreaChartCard |
Smooth translucent layers growing up and down from a shared baseline, a dated header, overlapping zone swatches and a summary value |
| Stacked column | StackedColumnChart, StackedColumnChartCard |
Flush side-by-side stripes per column, a glowing sparkline, selectable tabs, a legend and a tinted profile footer |
| Floating bar | FloatingBarChart, FloatingBarChartCard |
Pill-shaped bars from a low to a high value, each in its own colour, dashed grid lines with 8k-style labels, a caption and time range chips |
| Heatmap | HeatmapChart, HeatmapChartCard, HeatmapLegend |
Rounded cells shaded into levels by value, column labels, outlined year tabs, a Less to More legend and a full-width footer button |
More charts are on the way.
Compose Charts is published on Maven Central. No extra repository is needed, just make sure mavenCentral() is listed (new Android projects include it by default):
// settings.gradle.kts
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
}
}Then add the dependency:
// app/build.gradle.kts
dependencies {
implementation("io.github.nameisjayant:compose-charts:0.1.0")
}Version catalog
[versions]
composeCharts = "0.1.0"
[libraries]
compose-charts = { group = "io.github.nameisjayant", name = "compose-charts", version.ref = "composeCharts" }Every chart comes in two versions:
XxxChartCard: the chart inside a ready-made card, with a title, subtitle, legend and action slot.XxxChart: the bare chart, with no card frame, to put inside your own layout. Give it a size withmodifier.
val upperSeries = BarSeries(
label = "Data one",
values = listOf(90f, 24f, 63f, 39f, 46f, 49f, 61f),
color = VerticalBarChartDefaults.UpperBarColor,
)
val lowerSeries = BarSeries(
label = "Data two",
values = listOf(14f, 32f, 20f, 28f, 45f, 32f, 28f),
color = VerticalBarChartDefaults.LowerBarColor,
)Chart card
VerticalBarChartCard(
title = "Vertical bar",
subtitle = "Statistics of the month",
upperSeries = upperSeries,
lowerSeries = lowerSeries,
chartStyle = VerticalBarChartDefaults.style(barWidth = 24.dp),
)Chart only
VerticalBarChart(
upperSeries = upperSeries,
lowerSeries = lowerSeries,
modifier = Modifier.fillMaxWidth().height(160.dp),
style = VerticalBarChartDefaults.style(barWidth = 24.dp),
)val series = AreaRangeLineSeries(
label = "Temperature",
points = listOf(
LinePoint(x = -77f, y = 75f),
LinePoint(x = -39f, y = 53f),
LinePoint(x = -66f, y = 24f),
LinePoint(x = -40f, y = 14f),
LinePoint(x = -17f, y = 5f),
),
lineColor = AreaRangeLineChartDefaults.LineColor,
bandColor = AreaRangeLineChartDefaults.BandColor,
pointColor = AreaRangeLineChartDefaults.PointColor,
)Chart card
AreaRangeLineChartCard(
title = "Temperatures",
subtitle = "Average temperatures for July",
series = series,
xRange = AxisRange(min = -80f, max = -10f, step = 10f),
yRange = AxisRange(min = 0f, max = 75f, step = 25f),
yLabelFormatter = { "${it.roundToInt()} km" },
)Chart only
AreaRangeLineChart(
series = series,
modifier = Modifier.fillMaxWidth().height(220.dp),
xRange = AxisRange(min = -80f, max = -10f, step = 10f),
yRange = AxisRange(min = 0f, max = 75f, step = 25f),
yLabelFormatter = { "${it.roundToInt()} km" },
)Points are joined in list order, so the line can run top to bottom, as in the altitude profile above. If you leave out xRange or yRange, a range with round step values is picked for you.
val segments = listOf(
DonutSegment("Documents", 22f, DonutChartDefaults.DocumentsColor),
DonutSegment("Photos", 47f, DonutChartDefaults.PhotosColor),
DonutSegment("Music", 38f, DonutChartDefaults.MusicColor),
DonutSegment("Videos", 30f, DonutChartDefaults.VideosColor),
)Chart card
DonutChartCard(
title = "Storage of your device",
segments = segments,
maxValue = 178f,
caption = "Used storage",
value = "137GB",
action = { ChartSettingsIcon() },
)Chart only
DonutChart(
segments = segments,
modifier = Modifier.size(160.dp),
maxValue = 178f,
)With maxValue the ring works as a gauge: the segments fill 137 of 178, and the centre shows 77%. Leave it out and the segments share the whole ring. Use centerLabelFormatter to change the centre text, and DonutChartDefaults.style(startAngle = -90f) to start at 12 o'clock.
val segments = listOf(
DonutSegment("css", 11f, DoughnutChartDefaults.CssColor),
DonutSegment("html", 12f, DoughnutChartDefaults.HtmlColor),
DonutSegment("sass", 50f, DoughnutChartDefaults.SassColor),
)Chart card
DoughnutChartCard(
title = "Doughnut",
subtitle = "Browser market shares at a specific website, 2014",
segments = segments,
maxValue = 100f,
footer = { /* e.g. an avatar, a name and a profile button */ },
)Chart only
DoughnutChart(
segments = segments,
modifier = Modifier.size(200.dp),
maxValue = 100f,
)Each segment is a little thicker than the one before, and the last one reaches the edge. The segments run clockwise and finish at 12 o'clock, so the empty track sits just before it, and the centre shows the filled share (73%). Use DoughnutChartDefaults.style(endAngle = ..., firstSegmentRadiusFraction = ..., holeRadiusFraction = ...) to move the end point, change how much the segments thicken, or resize the hole.
val trend = listOf(180f, 148f, 194f, 184f, 255f, 120f, 200f, 248f, 194f, 200f, 140f, 196f, 192f, 248f)Chart card
SplineAreaChartCard(
title = "Expected earnings",
value = "€682.5",
badge = "+2.45%",
values = trend,
action = { ChartBarsIcon(color = SplineAreaChartCardDefaults.ActionIconColor) },
)Chart only
SplineAreaChart(
values = trend,
modifier = Modifier.fillMaxWidth().height(170.dp),
)The values are spread evenly from the left edge to the right edge, and in the card the chart reaches the card's left, right and bottom edges. By default the line is scaled to the data's own range, with the lowest value 45% of the way up so the fill has room to fade. Pass minValue = 0f and SplineAreaChartDefaults.style(bottomSpaceFraction = 0f) to measure from zero instead.
val series = listOf(
BarSeries("Store", listOf(70f, 40f, 22f, 40f, 17f, 21f, 13f, 8f), StackedBarChartDefaults.BottomColor),
BarSeries("Web", listOf(30f, 62f, 55f, 28f, 15f, 19f, 10f, 6f), StackedBarChartDefaults.MiddleColor),
BarSeries("App", listOf(30f, 18f, 29f, 15f, 30f, 10f, 17f, 40f), StackedBarChartDefaults.TopColor),
)Chart card
StackedBarChartCard(
title = "Bar chart",
subtitle = "Orders per day, split by channel: store, website and mobile app.",
trend = "+86%",
series = series,
)Chart only
StackedBarChart(
series = series,
modifier = Modifier.fillMaxWidth().height(130.dp),
)The first series sits at the bottom of every stack. Only the top and bottom of each whole stack are rounded; the segments inside meet flush. The bars share the width left after barSpacing, so they get wider when there are fewer of them. Cap them with StackedBarChartDefaults.style(maxBarWidth = 24.dp). The tallest stack fills the height unless you pass maxValue. The legend is off by default; turn it on with showLegend = true, or pass your own action in place of the trend label.
val bars = listOf(
HorizontalBar(value = 31f, total = 60f),
HorizontalBar(value = 56f, total = 80f),
HorizontalBar(value = 30f, total = 51f),
HorizontalBar(value = 21f, total = 32f),
HorizontalBar(value = 38f, total = 63f),
)Chart card
HorizontalBarChartCard(
title = "Horizontal bar chart with border radius",
bars = bars,
name = "Andres",
role = "Team Leader",
avatar = { ChartInitialsAvatar(text = "A") }, // or your own Image clipped to a circle
action = { ChartPillButton(text = "PROFILE", onClick = { /* open profile */ }) },
description = "Tasks finished out of tasks assigned in each of the last five sprints.",
)Chart only
HorizontalBarChart(
bars = bars,
modifier = Modifier.fillMaxWidth(),
)Each bar is a dark fill (value) drawn over a lighter track (total). The chart sets its own height from the number of bars, barHeight and barSpacing, so only give it a width. The longest track fills the width unless you pass maxValue. Bars are pills by default; pass HorizontalBarChartDefaults.style(cornerRadius = 4.dp) for squarer ends. The profile row is left out when avatar, name, role and action are all null, and the description is cut off after two lines (HorizontalBarChartCardDefaults.style(descriptionMaxLines = …)).
val bars = listOf(
RankedBar(label = "Norway", value = 89f),
RankedBar(label = "Australia", value = 81f),
RankedBar(label = "Switzerland", value = 74f),
RankedBar(label = "Canada", value = 35f),
)Chart card
RankedBarChartCard(
title = "Average of the first economies",
bars = bars,
description = "Economy score of the leading countries, highest first.",
listTitle = "List of countries",
listCaption = "${bars.size} countries",
footer = "Scores are an index out of 100.",
)Chart only
RankedBarChart(
bars = bars,
modifier = Modifier.fillMaxWidth(),
)Bars are sorted by value, longest on top, and drawn flush so they form a staircase. Each one takes the next colour of RankedBarChartDefaults.Colors (navy to pale sky); pass your own list with RankedBarChartDefaults.style(colors = …), or give a single bar a color. Labels are written inside the bars in white or black, whichever reads better on that colour, and move just past the bar's end when they don't fit (outsideLabelColor). The chart sets its own height from the number of bars and barHeight, so only give it a width. The longest bar fills the width unless you pass maxValue. The list header row is left out when listTitle and listCaption are both null.
val series = listOf(
AreaSeries(label = "2011", values = listOf(78f, 40f, 26f, 33f, 0f, 19f, 37f), color = OverlappingAreaChartDefaults.FirstColor),
AreaSeries(label = "2012", values = listOf(28f, 98f, 70f, 78f, 60f, 10f, 61f), color = OverlappingAreaChartDefaults.SecondColor),
)Chart card
var region by remember { mutableIntStateOf(0) }
OverlappingAreaChartCard(
title = "Estimated Worldwide Population",
series = series,
filters = listOf("America", "Europe", "Asia"),
selectedFilter = region,
onFilterSelected = { region = it },
description = "Population index per month, 2011 laid over 2012.",
name = "Ralph Edwards",
role = "Team Leader",
avatar = { ChartInitialsAvatar(text = "R") },
profileAction = { ChartCircleButton(onClick = { /* open chat */ }) { ChartChatIcon() } },
footer = "Figures are an index, not head counts.",
)Chart only
OverlappingAreaChart(
series = series,
modifier = Modifier.fillMaxWidth().height(160.dp),
)Each series joins its values with straight lines, spread evenly from edge to edge, and is filled down to 0 at the bottom. Series are drawn in order, so the last is on top; give their colours some transparency (as FirstColor and SecondColor do) so the areas behind show through and overlaps read darker. The tallest value of any series reaches the top unless you pass maxValue. Pass OverlappingAreaChartDefaults.style(lineWidth = 2.dp) to outline each area's top edge. The filter row is left out when filters is empty and the profile row when avatar, name, role and profileAction are all null; filters are only tappable when onFilterSelected is given. The header colour, chip colours and spacings live in OverlappingAreaChartCardDefaults.style(...).
val upper = listOf(
AreaSeries(label = "Zone 4", values = listOf(60f, 30f, 75f, 85f, 40f, 100f), color = MirroredAreaChartDefaults.UpperColors[0]),
AreaSeries(label = "Zone 1", values = listOf(12f, 8f, 20f, 25f, 15f, 12f), color = MirroredAreaChartDefaults.UpperColors[3]),
)
val lower = listOf(
AreaSeries(label = "Zone 5", values = listOf(60f, 45f, 70f, 50f, 95f, 40f), color = MirroredAreaChartDefaults.LowerColors[0]),
AreaSeries(label = "Zone 3", values = listOf(30f, 45f, 22f, 50f, 28f, 55f), color = MirroredAreaChartDefaults.LowerColors[2]),
)Chart card
MirroredAreaChartCard(
title = "Intensity",
subtitle = "September 2021",
description = "Time spent in each heart-rate zone, day by day.",
upperSeries = upper,
lowerSeries = lower,
summaryLabel = "Intensity zones",
summaryValue = "80%",
)Chart only
MirroredAreaChart(
upperSeries = upper,
lowerSeries = lower,
modifier = Modifier.fillMaxWidth().height(240.dp),
)upperSeries grow up from the baseline and lowerSeries hang down from it, each joined with a smooth curve and spread edge to edge. Within each half the last series is drawn in front, so put the tallest first and give the colours some transparency (UpperColors and LowerColors are ready-made, back to front). Both halves share one scale: the tallest value of any series reaches the edge unless you pass maxValue. MirroredAreaChartDefaults.style(...) sets the smoothing (0 for straight lines), the share of height above the baseline (upperFraction) and the baseline colour. In the card, the chart runs to the card's edges; the footer's overlapping swatches default to MirroredAreaChartDefaults.ZoneColors, and the footer is left out when summaryLabel, summaryValue, swatches and action are all empty. Set swatchBorderColor in MirroredAreaChartCardDefaults.style(...) to your card's background.
val series = listOf(
BarSeries(label = "Apple", values = listOf(100f, 73f, 55f, 85f, 27f), color = StackedColumnChartDefaults.FirstColor),
BarSeries(label = "Banana", values = listOf(100f, 73f, 55f, 85f, 27f), color = StackedColumnChartDefaults.SecondColor),
)Chart card
var month by remember { mutableIntStateOf(1) }
StackedColumnChartCard(
title = "Stacked column chart",
series = series,
tabs = listOf("Nov", "Dec", "Jan", "Feb", "Mar", "Apr"),
selectedTab = month,
onTabSelected = { month = it },
description = "Apples and bananas sold each week, side by side.",
name = "Jerome Bell",
role = "Marketing Coordinator",
avatar = { ChartInitialsAvatar(text = "J") },
profileAction = { ChartChatIcon() },
)Chart only
StackedColumnChart(
series = series,
modifier = Modifier.fillMaxWidth().height(136.dp),
)Each column holds value i of every series as a stripe, side by side, with the first series on the left. Stripes split the column's width and each is as tall as its own value. Columns are spread edge to edge, and the tallest value reaches the top unless you pass maxValue. StackedColumnChartDefaults.style(...) sets columnSpacing, seriesSpacing (0 keeps the stripes flush) and maxColumnWidth. In the card, the default action is a glowing StackedColumnSparkline. The tab row is left out when tabs is empty, and the profile footer when avatar, name, role and profileAction are all null. Tabs can only be tapped when onTabSelected is given. Tab and footer colours live in StackedColumnChartCardDefaults.style(...). ChartSparkIcon also takes haloColor and haloWidth if you want its glow elsewhere.
val bars = listOf(
FloatingBar(low = 2800f, high = 5000f, color = FloatingBarChartDefaults.PrimaryColor),
FloatingBar(low = 1800f, high = 3000f, color = FloatingBarChartDefaults.LightColor),
FloatingBar(low = 500f, high = 5000f, color = FloatingBarChartDefaults.AccentColor),
)Chart card
var range by remember { mutableIntStateOf(2) }
FloatingBarChartCard(
title = "Statistic",
description = "Minim dolor in amet nulla laboris enim dolore consequatt.",
bars = bars,
action = { ChartCircleButton(onClick = {}) { ChartSortIcon() } },
caption = "Year 2021",
ranges = listOf("24h", "1d", "3d", "1w", "3w", "1m"),
selectedRange = range,
onRangeSelected = { range = it },
)Chart only
FloatingBarChart(
bars = bars,
modifier = Modifier.fillMaxWidth().height(168.dp),
)Each bar spans from its low to its high value instead of standing on the baseline, and takes its own colour. The axis runs from 0 to maxValue in gridSteps equal steps (4 by default). Without maxValue, the highest value is rounded up so every step is a round number (1, 2, 2.5 or 5 × 10ⁿ). Labels go through labelFormatter, which defaults to FloatingBarChartDefaults.formatValue (8000f becomes 8k). Bars are spread edge to edge across the grid and grow out from their middles when they animate in. FloatingBarChartDefaults.style(...) sets barWidth, barInset, the grid's colour and dash (gridDashLength = 0.dp draws solid lines) and the label style. In the card, the chip row is left out when ranges is empty, and chips can only be tapped when onRangeSelected is given. Chip colours live in FloatingBarChartCardDefaults.style(...). ChartSortIcon is the three-line glyph from the header.
// Rows from top to bottom, each from left to right.
val values = listOf(
listOf(0f, 7f, 0f, 12f, 14f, 0f),
listOf(0f, 8f, 0f, 0f, 13f, 2f),
listOf(3f, 0f, 6f, 2f, 0f, 11f),
)Chart card
var year by remember { mutableIntStateOf(0) }
HeatmapChartCard(
title = "Sales per employee per month",
subtitle = "263 contributions in the last year",
values = values,
action = { ChartCircleButton(onClick = {}) { ChartSettingsIcon() } },
tabs = listOf("2016", "2017", "2018", "2019"),
selectedTab = year,
onTabSelected = { year = it },
description = "Minim dolor in amet nulla laboris enim dolore consequatt.",
columnLabels = listOf("Nov", "Dec", "Jan", "Feb"),
note = "Learn how we count contributions",
footerText = "Show more activity",
onFooterClick = {},
)Chart only
HeatmapChart(
values = values,
columnLabels = listOf("Nov", "Dec", "Jan", "Feb"),
modifier = Modifier.fillMaxWidth(),
)Each cell takes one of colors (four shades from HeatmapChartDefaults.Colors by default, emptiest first). A value of 0 or less takes the first colour, and the rest are split evenly between the other colours up to maxValue, which defaults to the highest value. Shorter rows are padded with empty cells. Columns are spread edge to edge, and columnLabels are spread evenly above them, the first over the first column and the last over the last. Unless you give it a height, the chart is as tall as its rows at cellSize; cells shrink when the grid would not fit. Cells pop in column by column, then blend to their new colours when the data changes. HeatmapChartDefaults.style(...) sets cellSize, cellCornerRadius, minColumnSpacing, rowSpacing and the label style. In the card, the tab row is left out when tabs is empty, tabs can only be tapped when onTabSelected is given, the legend is left out when showLegend is false, and the footer button when footerText is null. Tab, legend and footer colours live in HeatmapChartCardDefaults.style(...). HeatmapLegend can be used on its own.
Every chart card has three style layers: the card, the legend and the chart itself.
VerticalBarChartCard(
title = "Revenue",
upperSeries = series,
cardStyle = ChartCardDefaults.style(
backgroundColor = MaterialTheme.colorScheme.surface,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
),
legendStyle = ChartLegendDefaults.style(),
chartStyle = VerticalBarChartDefaults.style(
barWidth = 16.dp,
showGridLines = false,
),
animationSpec = tween(durationMillis = 1_000),
)The bare chart takes only the chart layer, through its style parameter (VerticalBarChart(style = VerticalBarChartDefaults.style(...))).
The :app module is a showcase app with one screen per chart. Each screen has Shuffle data and Reset buttons so you can watch the charts animate. It uses Hilt and MVI ViewModels, and it follows the system light or dark theme.
./gradlew :app:installDebugComposeCharts/
├── charts/ # The library: chart composables, styles, shared ChartCard and ChartLegend
├── app/ # Showcase app (Hilt + MVI) that demos every chart
└── docs/ # Documentation website (index.html), chart GIFs and logo
Issues and pull requests are welcome. When you add a chart:
- Add the chart composable, its
XxxStyleandXxxDefaults, plus aXxxChartCardwrapper in:charts. - Add a
ChartSampleentry inChartSampleRepositoryand a screen in:app. - Add a test to
RecordChartGifsin:app, run./scripts/record-chart-gifs.shto render the GIFs intodocs/gifs/(needsffmpeg), and add the chart to the tables above (the Preview table holds three GIFs per row). - Add an entry to the
CHARTSlist indocs/index.htmlso the chart gets its own page on the website.
The :charts module is published with the vanniktech maven-publish plugin. The Central Portal token and the GPG signing key live in ~/.gradle/gradle.properties, never in this repo:
mavenCentralUsername=<portal token username>
mavenCentralPassword=<portal token password>
signingInMemoryKeyId=<last 8 chars of the key id>
signingInMemoryKeyPassword=<key passphrase>
signingInMemoryKey=<ascii-armored private key, on one line>To release:
- Bump
VERSION_NAMEingradle.properties. - Optionally test the build locally with
./gradlew :charts:publishToMavenLocal. - Run
./gradlew :charts:publishToMavenCentral. The release is published automatically and shows up on Maven Central within about 30 minutes.
Licensed under the Apache License 2.0.
Made with ❤️ and Jetpack Compose by Jayant Kumar












