Skip to content

Customization

ChromaDial offers two approaches to customization: the simple DialColors API for color customization, and fully custom thumb and track composables for complete control.

For quick color customization without writing custom composables, use DialColors:

Dial(
degree = degree,
onDegreeChange = { degree = it },
colors = DialColors.default(
inactiveTrackColor = Zinc700,
activeTrackColor = Blue500,
thumbColor = Zinc950,
thumbStrokeColor = Blue400,
inactiveTickColor = Zinc700,
activeTickColor = Blue300,
),
)
PropertyTypeDefaultDescription
inactiveTrackColorColorZinc700Color of the track background
activeTrackColorColorLime500Color of the active/progress arc
thumbColorColorZinc950Fill color of the thumb
thumbStrokeColorColorLime400Stroke color of the thumb
inactiveTickColorColorZinc700Color of tick marks outside active range
activeTickColorColorLime300Color of tick marks within active range

The default track (used with DialColors) includes:

  • Progress arc - Shows the active range from start to current degree
  • Tick marks - Automatically displayed when interval > 0
  • Multi-ring display - When sweepDegrees > 360, completed rotations scale outward with animated transitions and decreasing alpha
  • Overshoot animation - The active arc visually extends when the user drags beyond the limits

For full control, use custom thumb and track composables. Both receive a DialState object that provides all the information needed to create rich, interactive designs.

The thumb is the draggable handle that users interact with. It’s positioned and rotated automatically by the Dial.

Dial(
degree = degree,
onDegreeChange = { degree = it },
thumb = { state ->
Box(
Modifier
.size(32.dp)
.background(
brush = Brush.verticalGradient(
colors = listOf(Blue500, Cyan400)
),
shape = CircleShape
)
.border(2.dp, White, CircleShape)
)
},
)

For designs where the track shows all the visual feedback:

thumb = { Box(Modifier.fillMaxSize()) }

The track is the background that shows the dial’s path and can display progress, tick marks, or any custom graphics.

Use the library’s drawArc helper (from DrawArc.kt) for correctly positioned arcs. It treats as 12 o’clock and the radius parameter as the outer edge of the stroke:

track = { state ->
Box(
Modifier
.fillMaxSize()
.drawBehind {
val sweepAngle = state.degreeRange.endInclusive - state.degreeRange.start
// Background arc
drawArc(
color = Zinc700,
startAngle = state.startDegrees,
sweepAngle = sweepAngle,
radius = state.radius,
strokeWidth = 8.dp,
)
// Progress arc
drawArc(
color = Blue500,
startAngle = state.startDegrees,
sweepAngle = state.degree,
radius = state.radius,
strokeWidth = 8.dp,
)
}
)
}

Use the drawEveryInterval utility to draw tick marks at regular angular positions. When passing a DialState, use the spacing parameter:

track = { state ->
Box(
Modifier
.fillMaxSize()
.drawBehind {
drawEveryInterval(
dialState = state,
spacing = 30f, // degrees between each tick mark
padding = 16.dp,
) { data ->
rotate(data.rotationAngle, pivot = data.position) {
drawLine(
color = if (data.inActiveRange) Blue500 else Zinc500,
start = data.position,
end = data.position + Offset(0f, 15f),
strokeWidth = 2.dp.toPx()
)
}
}
}
)
}

When using explicit parameters instead of a DialState, the parameter is named interval:

drawEveryInterval(
startDegrees = 180f,
sweepDegrees = 275f,
radius = state.radius,
interval = 30f,
padding = 16.dp,
currentDegree = state.degree,
) { data -> /* ... */ }
track = { state ->
Box(Modifier.fillMaxSize()) {
Text(
text = "${(state.value * 100).toInt()}%",
modifier = Modifier.align(Alignment.Center),
fontSize = 24.sp,
fontWeight = FontWeight.Bold
)
}
}

Both thumb and track receive a DialState object with these useful properties:

PropertyTypeDescription
degreeFloatCurrent rotation in degrees (relative to start)
valueFloatNormalized 0-1 value based on position within range
mappedValueFloatvalue mapped to valueRange
degreeRangeClosedFloatingPointRange<Float>Allowed rotation range
startDegreesFloatVisual start angle in screen coordinates
radiusFloatCalculated radius in pixels
thumbSizeFloatMeasured thumb size in pixels
overshootDegreesFloatDecay-adjusted overshoot when dragging beyond limits

The value property normalizes the current degree to a 0-1 range:

track = { state ->
val percentage = (state.value * 100).toInt()
}

React to the user dragging beyond the dial’s limits:

thumb = { state ->
val scale = 1f - (state.overshootDegrees.absoluteValue / 180f).coerceIn(0f, 0.3f)
Box(
Modifier
.size(32.dp)
.graphicsLayer { scaleX = scale; scaleY = scale }
.background(Blue500, CircleShape)
)
}

When using drawEveryInterval or DialInterval, you receive an IntervalData object with:

PropertyTypeDescription
indexIntThe index of this interval (0-based)
positionOffsetPixel position on the dial path
rotationAngleFloatTangent angle in degrees at this position (use for rotating content to align with the arc)
intervalDegreeFloatActual degree value on the dial (in 0..sweepDegrees space)
inActiveRangeBooleanWhether within the active range
progressFloatNormalized progress (0-1) within the range

For placing composables at interval positions around the dial, use DialInterval. Pass the DialState directly via the state overload:

track = { state ->
DialInterval(
state = state,
modifier = Modifier.fillMaxSize(),
spacing = 30f,
currentDegree = state.degree,
) { data ->
Text(
text = "${data.intervalDegree.toInt()}°",
color = if (data.inActiveRange) Blue500 else Zinc500,
fontSize = 12.sp
)
}
}

Or use explicit parameters without a DialState:

DialInterval(
startDegrees = 180f,
sweepDegrees = 275f,
radius = state.radius, // null = use layout width
spacing = 30f,
padding = 8.dp,
currentDegree = state.degree,
) { data ->
Text("${data.index}")
}

Combine with Compose animations for smooth interactions:

var degree by remember { mutableFloatStateOf(0f) }
val animatedDegree by animateFloatAsState(
targetValue = degree,
animationSpec = spring(
stiffness = Spring.StiffnessHigh,
dampingRatio = Spring.DampingRatioLowBouncy,
)
)
Dial(
degree = animatedDegree,
onDegreeChange = { degree = it },
interval = 30f,
)

Or use DialState.animateTo() for programmatic animation:

val state = rememberDialState(sweepDegrees = 360f)
val scope = rememberCoroutineScope()
Dial(state = state)
Button(onClick = { scope.launch { state.animateTo(0f) } }) {
Text("Reset")
}
@Composable
fun GradientArcDial() {
var degree by remember { mutableFloatStateOf(0f) }
Dial(
degree = degree,
onDegreeChange = { degree = it },
modifier = Modifier.size(200.dp, 100.dp),
startDegrees = 270f,
sweepDegrees = 180f,
radiusMode = RadiusMode.HEIGHT,
thumb = { state ->
Box(
Modifier
.size(24.dp)
.background(
brush = Brush.radialGradient(
colors = listOf(White, Blue500)
),
shape = CircleShape
)
.border(2.dp, White, CircleShape)
)
},
track = { state ->
Box(
Modifier
.fillMaxSize()
.drawBehind {
val sweepAngle = state.degreeRange.endInclusive - state.degreeRange.start
// Background
drawArc(
color = Zinc700,
startAngle = state.startDegrees,
sweepAngle = sweepAngle,
radius = state.radius,
strokeWidth = 16.dp,
)
// Progress with gradient brush
drawArc(
brush = Brush.sweepGradient(
colors = listOf(Cyan400, Blue500, Violet500)
),
startAngle = state.startDegrees,
sweepAngle = state.degree,
radius = state.radius,
strokeWidth = 16.dp,
)
}
) {
Text(
text = "${(state.value * 100).toInt()}%",
modifier = Modifier.align(Alignment.BottomCenter),
fontSize = 20.sp,
fontWeight = FontWeight.Bold
)
}
}
)
}