charty

Interactions#

Charty supports several interaction modes: crosshair inspection, tap-to-click with tooltips, zoom and pan, brush (range) selection, and streaming scrollback. This page explains each mode, how to enable it, and — most importantly — which modes can share one chart.

Which gestures can coexist#

Compose delivers one gesture stream per chart, so the rules come down to which gesture type each feature uses.

FeatureGestureCoexists with a tap tooltip?
Tap tooltip (onXxxClick)Tap
Crosshair (crosshair = ChartCrosshair())Drag✅ Yes
Streaming scrollback (streamingState)Drag✅ Yes
Zoom / pan (viewPortState)Drag + pinch✅ Yes
Brush selection (brushSelectionState)Drag✅ Yes
Drag-to-track tooltip (dragTooltipEnabled)Drag

A tap tooltip always coexists with a drag feature. Drag detection only reports movement once the pointer has travelled past touch slop, so a plain tap falls through to the tooltip handler untouched.

Two drag features on one chart do not coexist. In particular:

Zoom/pan also takes precedence over a rolling visibleWindow: with a viewPortState configured, the window is ignored and the chart draws statically.


Click handlers#

Every chart exposes an optional click callback. When the user taps a data point, Charty computes which element was hit and invokes the callback with the relevant data object. A tooltip is automatically shown near the tapped element whenever the callback is set.

The parameter name varies by chart type but the pattern is always the same:

ChartCallback parameterData type passed
BarChartonBarClickBarData
HorizontalBarChartonBarClickBarData
StackedBarChartonSegmentClickStackedBarSegment
StackedHorizontalBarChartonSegmentClickStackedHorizontalBarSegment
GroupedHorizontalBarChartonBarClickGroupedHorizontalBarEntry
NormalizedHorizontalBarChartonSegmentClickNormalizedHorizontalBarSegment
MosaicBarChartonSegmentClickMosaicBarSegment
SpanChartonSpanClickSpanData
WaterfallChartonBarClickBarData
LineChartonPointClickLineData
AreaChartonPointClickLineData
MultilineChartonPointClickMultilinePoint
StackedAreaChartonAreaClickStackedAreaPoint
PointChartonPointClickPointData
BubbleChartonBubbleClickBubbleData
ComboChartonDataClickComboChartData
PieChartonSliceClick(PieData, Int)
MultipleRadarChartonDataSetClick(label: String, index: Int)

Example#

Kotlin
BarChart(    data = { barData },    barConfig = BarChartConfig(        tooltipConfig = TooltipConfig(            backgroundColor = Color.Black.copy(alpha = 0.85f),        ),        tooltipFormatter = { barData -> "Value: ${barData.value}" },    ),    onBarClick = { clickedBar ->        println("Tapped bar with value ${clickedBar.value}")    },)
Kotlin
LineChart(    data = { lineData },    lineConfig = LineChartConfig(        tooltipFormatter = { point -> "${point.label}: ${point.value}" },    ),    onPointClick = { point ->        selectedPoint = point    },)

If no click handler is provided, no hit bounds are recorded and no tap tooltip is shown. The chart may still be interactive through a crosshair, zoom/pan, brush selection, or streaming scrollback — those are independent of the click callback.


Tooltip#

Tooltips are shown automatically at the tapped position whenever a click handler is registered. You do not need to manage tooltip state manually. Without a click handler no hit bounds are recorded, so no tooltip appears.

TooltipConfig controls the visual appearance. Note that the bubble is styled with a Compose Shape and a TooltipPadding, not raw pixel values:

Kotlin
data class TooltipConfig(    val shape: Shape = RoundedCornerShape(8.dp),    val cornerRadius: Dp = 8.dp,    val backgroundColor: ChartyColor = ChartyColor.Solid(Color(0xFF2D2D2D)),    val borderColor: ChartyColor? = null,    val borderWidth: Dp = 1.dp,    val textStyle: TextStyle = TextStyle(color = Color.White, fontSize = 14.sp),    val textColor: ChartyColor? = null,    val padding: TooltipPadding = TooltipPadding(),    val elevation: Dp = 4.dp,    val offsetY: Dp = 8.dp,    val minDistanceFromEdge: Dp = 16.dp,    val showArrow: Boolean = true,    val arrowSize: Dp = 8.dp,)data class TooltipPadding(    val horizontal: Dp = 12.dp,    val vertical: Dp = 8.dp,)

There is no textColor or cornerRadius property — set the text colour through textStyle.color and the rounding through shape.

Pass it through the chart's config object:

Kotlin
BarChart(    data = { barData },    barConfig = BarChartConfig(        tooltipConfig = TooltipConfig(            backgroundColor = Color(0xFF1A237E),            shape = RoundedCornerShape(12.dp),            textStyle = TextStyle(color = Color.White, fontSize = 13.sp),            padding = TooltipPadding(horizontal = 14.dp, vertical = 10.dp),        ),        tooltipFormatter = { bar -> "${bar.label}: ${bar.value}" },    ),    onBarClick = { },)

Tooltip positiontooltipPosition on the chart config takes TooltipPosition.ABOVE, BELOW, or AUTO (the default), which places the tooltip to keep it on screen.

Choosing how the tooltip is rendered#

Every chart takes a tooltip: ChartTooltip<T> parameter that selects the rendering strategy:

Kotlin
tooltip = ChartTooltip.canvas()                  // built-in bubble drawn on the canvas (default)tooltip = ChartTooltip.none()                    // no tooltiptooltip = ChartTooltip.compose { MyCard(title = text, value = data.value) }  // your Composable

ChartTooltip.compose { } positions your composable over the selected point with edge-collision handling. Inside the block you have a TooltipScope<T> exposing data, the formatted text, and the anchor position.

Drag-to-track tooltips#

Set dragTooltipEnabled = true on ChartInteractionConfig and dragging across a rectangular chart tracks the item under the finger and shows its tooltip, dismissing on release. It is automatically suppressed whenever viewPortState or brushSelectionState is set, since those consume drags too.


Crosshair#

The crosshair is an inspection mode where a vertical and optional horizontal line follows the user's finger or pointer across the chart, snapping to the nearest data point and showing a label. This is more useful than tap-to-click for continuous inspection of line or area data.

Enabling it#

Every crosshair-capable chart takes a unified crosshair: ChartCrosshair<T>? parameter. null (the default) turns it off.

Kotlin
import com.himanshoe.charty.common.gesture.ChartCrosshairLineChart(    data = { lineData },    color = ChartyColor.Solid(ChartyColors.Blue),    crosshair = ChartCrosshair(),)
Kotlin
data class ChartCrosshair<T>(    val config: ChartCrosshairConfig? = null,    val label: (@Composable CrosshairScope<T>.() -> Unit)? = null,)

config styles the guide line — null, the default, takes it from the ambient theme. label is a Composable drawn over the line at the snapped point; leave it null for the built-in pill label.

Kotlin
LineChart(    data = { lineData },    color = ChartyColor.Solid(ChartyColors.Blue),    crosshair =        ChartCrosshair(            config = ChartCrosshairConfig(                verticalLineColor = ChartyColor.Solid(ChartyColors.Purple),                showHorizontalLine = false,                dismissOnRelease = false,            ),            label = { Text(text = "${data.label}: ${data.value}") },        ),)

Inside the label block you are in a CrosshairScope<T>, which exposes data (the point under the crosshair) and text (the chart's formatted label for it).

Charts with a crosshair parameter: LineChart, AreaChart, MultilineChart, StackedAreaChart, PointChart, BubbleChart, BarChart, HorizontalBarChart, WavyChart, and ComboChart.

ChartCrosshairConfig#

Kotlin
data class ChartCrosshairConfig(    val verticalLineColor: ChartyColor = ChartyColor.Solid(Color.Black.copy(alpha = 0.4f)),    val horizontalLineColor: ChartyColor = ChartyColor.Solid(Color.Black.copy(alpha = 0.15f)),    val lineWidth: Float = 1.5f,    val showHorizontalLine: Boolean = true,    val dotRadius: Float = 8f,    val showLabel: Boolean = true,    val tooltipConfig: TooltipConfig = TooltipConfig(showArrow = false),    val dismissOnRelease: Boolean = true,)
PropertyDefaultDescription
verticalLineColor40% blackColour of the vertical line. A gradient is applied top-to-bottom along it.
horizontalLineColor15% blackColour of the horizontal line. A gradient is applied left-to-right.
lineWidth1.5fStroke width for both lines, in pixels.
showHorizontalLinetrueWhether to draw the horizontal line at all.
dotRadius8fRadius of the highlight circle at the snapped point.
showLabeltrueWhether to display the value label.
tooltipConfigArrow-less defaultAppearance of the built-in label bubble.
dismissOnReleasetrueHide the crosshair when the finger lifts. false pins it after release.

You can also set crosshairConfig directly inside a chart's config (LineChartConfig, BarChartConfig, ComboChartConfig, …). Passing a crosshair to the composable is the preferred form — it also gives you the custom-label slot — and it overrides the config's crosshairConfig when both are set.

A crosshair does not disable tap-to-click#

A crosshair is a drag gesture and a tooltip is a tap, so the two coexist happily: tapping a point still raises its tooltip while dragging still moves the guide line.

Kotlin
LineChart(    data = { lineData },    crosshair = ChartCrosshair(),    onPointClick = { point -> selected = point },   // still fires)

What a crosshair does displace is streaming scrollback — the other drag gesture. See the coexistence table at the top of this page and Streaming.

Sharing one crosshair across stacked charts#

Wrap several charts in CrosshairSyncScope { } and they mirror a single guide position. Charts enrol automatically, but only when they have both a crosshair and a ViewPortState or StreamingState in their interactionConfig. See the synced crosshair guide.


Zoom and Pan#

Zoom and pan let a reader narrow the chart to part of the series and drag through it. It suits dense time series, where every point drawn at once is a row of slivers.

Create the state in a @Composable scope and pass it through ChartInteractionConfig:

Kotlin
val viewPortState = rememberViewPortState(initialVisibleFraction = 0.1f)BarChart(    data = { barData },    interactionConfig = ChartInteractionConfig(viewPortState = viewPortState),)

initialVisibleFraction is how much of the series to show at first, as a fraction of the whole — 0.1f opens on the first tenth. It matters more than it sounds: a chart showing everything has nothing off-screen to pan to, so at the default of 1f dragging does nothing until the reader thinks to zoom in first.

How a reader zooms#

InputGesture
TouchPinch to zoom, drag to pan, flick to fling
Mouse or trackpadWheel to zoom, horizontal wheel or two-finger swipe to pan

Wheel support matters on desktop and web, where there is no second touch point to pinch with.

Reading and driving the viewport#

The window is expressed as fractions of the whole series, not as item counts, so charts of different widths and different data sizes can be kept in step:

Kotlin
viewPortState.startFraction      // 0f is the first pointviewPortState.endFraction        // 1f is the lastviewPortState.visibleFraction    // how much of the series is on screenviewPortState.visibleIndices(totalItems = data.size)   // the indices actually drawnviewPortState.isAtStart          // true when the window is against the beginningviewPortState.isAtEnd            // true when it is against the endviewPortState.reset()             // back to the whole seriesviewPortState.animateScrollToEnd()  // slide to the newest data

Zoom and pan take precedence over a rolling visibleWindow: with a viewPortState configured the window is ignored and the chart draws statically. If what you want is a fixed-width window a reader drags through, use visibleWindow with a StreamingState instead — see Streaming.


Brush Selection#

Brush selection lets the user drag across the chart to select a range of data. This is useful for date-range pickers or any scenario where you need to identify a subset of the data.

Kotlin
val brushState = rememberBrushSelectionState()LineChart(    data = { lineData },    interactionConfig = ChartInteractionConfig(        brushSelectionState = brushState,    ),)// Read the selected range elsewhere in your composableval selectedRange = brushState.selectedRangeif (selectedRange != null) {    Text("Selected: ${selectedRange.start} – ${selectedRange.endInclusive}")}

Brush selection and zoom/pan can be combined in a single ChartInteractionConfig:

Kotlin
ChartInteractionConfig(    viewPortState = rememberViewPortState(initialVisibleFraction = 0.2f),    brushSelectionState = rememberBrushSelectionState(),)

Streaming scrollback#

On a chart with a rolling visibleWindow, a StreamingState lets the reader drag back through history while new data keeps accumulating off-screen instead of yanking the view to the newest point.

Kotlin
val streaming = rememberStreamingState()LineChart(    data = { points },    color = ChartyColor.Solid(ChartyColors.Blue),    lineConfig = LineChartConfig(visibleWindow = 30),    interactionConfig =        ChartInteractionConfig(            streamingState = streaming,            jumpToLatest = { state -> ChartJumpToLatestPill(state = state) },        ),)

The drag settles on the nearest whole data index when the finger lifts, and jumpToLatest renders only while the window is detached. Requires both a streamingState and a visibleWindow; a chart with neither gains no gesture it did not already have.

A crosshair on the same chart makes scrollback inert. Full details in the streaming guide.


ChartInteractionConfig#

All interaction states are grouped into a single ChartInteractionConfig object passed to Cartesian charts:

Kotlin
class ChartInteractionConfig(    val viewPortState: ViewPortState? = null,    val brushSelectionState: BrushSelectionState? = null,    val onRangeSelect: ((startIndex: Int, endIndex: Int) -> Unit)? = null,    val annotations: List<ChartAnnotation> = emptyList(),    val accessibilityDescription: String? = null,    val dragTooltipEnabled: Boolean = false,    val autoScrollToLatest: Boolean = false,    val edgeFade: ScrollEdgeFadeConfig? = null,    val streamingState: StreamingState? = null,    val jumpToLatest: (@Composable (StreamingState) -> Unit)? = null,)
PropertyDescription
viewPortStateEnables zoom and pan. Create with rememberViewPortState(initialVisibleFraction).
brushSelectionStateEnables brush range selection. Create with rememberBrushSelectionState().
onRangeSelectCalled with (startIndex, endIndex) when a brush selection gesture completes.
annotationsList of ChartAnnotation markers rendered on top of the chart content.
accessibilityDescriptionOverrides the auto-generated contentDescription for screen readers. Pass "" to suppress the description entirely.
dragTooltipEnabledDragging tracks the item under the finger and shows its tooltip. Automatically suppressed while viewPortState or brushSelectionState is set.
autoScrollToLatestWith viewPortState set, the viewport follows the end of the data as it grows, keeping the current zoom. No effect without a viewport.
edgeFadeWith viewPortState set, draws a scrim at the edges while data is scrolled off-screen, hinting there is more to pan to.
streamingStateEnables scrollback on a chart with a rolling visibleWindow. Create with rememberStreamingState(). Inert when a crosshair is configured.
jumpToLatestYour "jump to latest" control, rendered over the bottom centre of the plot while the window is detached. Requires both streamingState and a visibleWindow.

Note it is a plain class, not a data class — there is no copy().

Full example#

Kotlin
@Composablefun SalesChart(data: List<BarData>) {    val viewPortState = rememberViewPortState(initialVisibleFraction = 0.25f)    val brushState = rememberBrushSelectionState()    BarChart(        data = { data },        color = ChartyColor.Solid(ChartyColors.Blue),        barConfig = BarChartConfig(            tooltipFormatter = { bar -> "Sales: ${bar.value}" },        ),        interactionConfig = ChartInteractionConfig(            viewPortState = viewPortState,            brushSelectionState = brushState,            accessibilityDescription = "Monthly sales bar chart, showing the last 12 months",        ),        onBarClick = { bar ->            println("Selected month value: ${bar.value}")        },    )}

Accessibility#

All Charty charts generate a contentDescription automatically from the data, enabling TalkBack and VoiceOver support without any extra work. To override the auto-generated description, use:

Kotlin
// Override description on a PieChartPieChart(    data = { pieData },    accessibilityDescription = "Revenue breakdown by product category",)// Suppress description entirelyPieChart(    data = { pieData },    accessibilityDescription = "",)