External Publication
Visit Post

How to create custom Dials

sinasamaki March 14, 2026
Source

In the last article, I introduced you to my new library, ChromaDial. Now let's learn how to go beyond the default and create custom, delightful dials.

Thumb

The thumb is one of the two major components of the Dial. It is the movable part that the user drags along the dial circumference to change the degrees.

We can define it through the thumb argument in Dial, like this:

Dial(
    degree = degree,
    onDegreeChange = { degree = it },
    thumb = {
        Box(
            Modifier
                .size(32.dp)
                .background(Lime500.copy(alpha = .4f), CircleShape)
                .border(2.dp, Lime400, CircleShape)
        )
    }
)

This gives us a circular green thumb that automatically moves along the track as the user drags it.

0:00

/0:03

Drag & Hover effects

When the user interacts with the Dial, it's best to add some animation to make it feel tactile and responsive. We can do this by reacting to hover and drag events through MutableInteractionSource.

We can simply create one and pass it in to the Dial, like most other interactive Composables.

Then, we can collect hover and drag interactions, and use them for animations.

val interactionSource = remember { MutableInteractionSource() }
val isDragging by interactionSource.collectIsDraggedAsState()
val dragScale by animateFloatAsState(if (isDragging) 1.3f else .95f)

Dial(
    degree = degree,
    onDegreeChange = { degree = it },
    interactionSource = interactionSource,
    thumb = { _ ->
        Box(
            Modifier
                .size(32.dp)
                .background(Lime500.copy(alpha = .4f), CircleShape)
                .border(2.dp, Lime400, CircleShape)
                .drawBehind {
                    scale(dragScale) {
                        drawCircle(color = Lime500.copy(alpha = .3f))
                    }
                }
        )
    }
)

Here, we add a light circle that scales up with an animation, as the user drags the thumb along the Dial.

0:00

/0:03

Invisible Thumb

Sometimes, we do not need a visual thumb. Depending on the design of the track, we may just want the user to drag on the track itself.

ChromaDial depends on the thumb to define the size of the touch area. So in the case that we want an invisible thumb, we can just create a Box with a size, but render nothing.

Dial(
    degree = degree,
    onDegreeChange = { degree = it },
    thumb = { Box(Modifier.size(32.dp)) }
)

This way, you have control over the size of the touch area, but without drawing anything. Then it will seem the user is dragging directly on the track, but in reality, there is a thumb receiving the touch events.

0:00

/0:03

Full-Size Thumb

What if we want a dial with no particular thumb along the circumference, but rather we allow the user to drag anywhere on the area of the dial to move it.

Don't worry, ChromaDial can do that too.

We just have to define an invisible thumb that fills the entire area.

val interactionSource = remember { MutableInteractionSource() }

Dial(
    degree = degree,
    onDegreeChange = { degree = it },
    interactionSource = interactionSource,
    thumb = { state ->
        Box(
            modifier = Modifier
                .fillMaxSize()
        )
    }
)

Since the thumb expands inwards, while maintaining a tangent point with the dial's circumference, calling fillMaxSize() will give us a thumb that is the exact same size as the dial.

One application of this is creating a voice recorder dial, like the one found on Nothing Phones.

0:00

/0:03

Track

The track is the arc the thumb rides along. By default you get a simple styled arc, but you can draw anything you want in this space.

The track parameter is a composable lambda that receives a DialState. Inside it, you can use a Box with Modifier.drawBehind to draw on the Canvas.

Drawing Arcs

You could use the default canvas functions to draw arcs. But in my testing, I found myself always using extension functions to make my life easier, which I ended up including in the ChromaDial library.

They work similar to the default functions and can even take in gradient brushes, as well as solid colors.

Dial(
    degree = degree,
    onDegreeChange = { degree = it },
    track = { state ->
        Box(
            Modifier
                .fillMaxSize()
                .drawBehind {
                    // Background arc
                    drawArc(
                        color = Gray400.copy(alpha = 0.1f),
                        startAngle = state.startDegrees,
                        sweepAngle = state.degreeRange.endInclusive - state.degreeRange.start,
                        radius = state.radius - (32.dp.toPx()) / 2,
                        strokeWidth = 6.dp,
                        strokeCap = StrokeCap.Round,
                    )
                    // Progress arc
                    drawArc(
                        color = Lime400,
                        startAngle = state.startDegrees,
                        sweepAngle = state.degree,
                        radius = state.radius - (32.dp.toPx()) / 2,
                        strokeWidth = 6.dp,
                        strokeCap = StrokeCap.Round,
                    )
                }
        )
    }
)

state.startDegrees is where the arc begins, and state.degreeRange.endInclusive - state.degreeRange.start gives you the full sweep. state.degree is the current position as a sweep angle from the start, so you pass it directly as sweepAngle for the progress arc. No coordinate conversion needed. ChromaDial's drawArc handles that for you, along with removing some unnecessary parameters.

0:00

/0:03

drawEveryInterval

Other than drawing the arc, ChromaDial comes with more helper functions that you may find useful. This one helps you with drawing something over an arbitrary interval.

Think tick marks on a clock face.

To use the function, we first need to define the degree range, radius and the spacing between each mark in degrees. Then we have the onDraw lambda, in which we define what is drawn after each interval.

track = { state ->
    Box(
        Modifier
            .fillMaxSize()
            .drawBehind {
                drawEveryInterval(
                    startDegrees = state.startDegrees,
                    sweepDegrees = state.degreeRange.endInclusive - state.degreeRange.start,
                    radius = state.radius - 16.dp.toPx(),
                    spacing = 2f,
                    currentDegree = state.degree,
                ) { data ->
                    rotate(data.rotationAngle, pivot = data.position) {
                        val isLarge = data.intervalDegree % 30f == 0f
                        drawLine(
                            color = if (data.inActiveRange) Lime400 else Gray400.copy(alpha = .2f),
                            start = data.position,
                            end = data.position + Offset(0f, if (isLarge) 15f else 8f),
                            strokeWidth = if (isLarge) 2.dp.toPx() else 1.dp.toPx(),
                        )
                    }
                }
            }
    )
}

Inside the lambda, we use the IntervalData to draw our desired marking in its correct position and rotation. We can also pass in the current degree, and the IntervalData object will give us a boolean to let us know we are in the active range. Moreover we use the intervalDegree or index to create larger tick marks to delineate certain intervals.

0:00

/0:03

Overshoot

When the user drags past the end of the dial's range, ChromaDial automatically calculates overshoot animations. You can define its behavior by passing in overshootAnimationSpec to control the stiffness and damping.

Then ChromaDial calculates based on how far the user has moved the Dial out of bounds, and provides state.overshootDegrees.

The thumb has overshootDegrees automatically added onto it. But for the track, you are in complete control over how it is applied.

For example, you can make the track stretch by adding overshootDegrees onto the active arc's sweepDegrees.

val interactionSource = remember { MutableInteractionSource() }
val isDragging by interactionSource.collectIsDraggedAsState()
val dragScale by animateFloatAsState(if (isDragging) 1.3f else .95f)

Dial(
    degree = degree,
    onDegreeChange = { degree = it },
    interactionSource = interactionSource,
    overshootAnimationSpec = spring(
        stiffness = Spring.StiffnessLow,
        dampingRatio = Spring.DampingRatioMediumBouncy
    ),
    thumb = { _ -> ... },
    track = { state ->
        Box(Modifier.fillMaxSize().drawBehind {
            // draw inactive arc
            drawArc(
                color = Lime400,
                startAngle = state.startDegrees + state.overshootDegrees / 2,
                sweepAngle = state.degree + state.overshootDegrees / 2,
                radius = state.radius - (64.dp.toPx()) / 2,
                strokeWidth = 12.dp,
                strokeCap = StrokeCap.Round,
            )
        })
    },
)

When the user pushes past the limit, the active arc stretched towards their finger. On release, the spring kicks in and the dial bounces back to the end. This creates a better experience rather than just hitting an invisible wall.

0:00

/0:04


ChromaDial is still in alpha, so the API isn't final. But the customization hooks here are already enough to build whatever you're imagining.

Thanks for reading and good luck!

Discussion in the ATmosphere

Loading comments...