{
"$type": "site.standard.document",
"bskyPostRef": {
"cid": "bafyreiagri74tucmlzc2cuzzv6uxpkpjtxyebx4pyuslpddpdtl3yu7p5q",
"uri": "at://did:plc:kw6erlpwsl4qpbgfibjr23vb/app.bsky.feed.post/3mh2c6fwtl752"
},
"coverImage": {
"$type": "blob",
"ref": {
"$link": "bafkreigrk3tquvlsq4lpqpyz37jq5zkamwk3kklhkv2sixk2jgtfah5vae"
},
"mimeType": "image/png",
"size": 1287538
},
"description": "ChromaDial is super customizable and delightful",
"path": "/how-to-create-custom-dials/",
"publishedAt": "2026-03-14T20:43:05.000Z",
"site": "https://www.sinasamaki.com",
"tags": [
"last article",
"ChromaDial"
],
"textContent": "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.\n\n## Thumb\n\nThe 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.\n\nWe can define it through the `thumb` argument in `Dial`, like this:\n\n\n Dial(\n degree = degree,\n onDegreeChange = { degree = it },\n thumb = {\n Box(\n Modifier\n .size(32.dp)\n .background(Lime500.copy(alpha = .4f), CircleShape)\n .border(2.dp, Lime400, CircleShape)\n )\n }\n )\n\n\nThis gives us a circular green thumb that automatically moves along the track as the user drags it.\n\n0:00\n\n/0:03\n\n1×\n\n### Drag & Hover effects\n\nWhen 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`.\n\nWe can simply create one and pass it in to the `Dial`, like most other interactive Composables.\n\nThen, we can collect hover and drag interactions, and use them for animations.\n\n\n val interactionSource = remember { MutableInteractionSource() }\n val isDragging by interactionSource.collectIsDraggedAsState()\n val dragScale by animateFloatAsState(if (isDragging) 1.3f else .95f)\n\n Dial(\n degree = degree,\n onDegreeChange = { degree = it },\n interactionSource = interactionSource,\n thumb = { _ ->\n Box(\n Modifier\n .size(32.dp)\n .background(Lime500.copy(alpha = .4f), CircleShape)\n .border(2.dp, Lime400, CircleShape)\n .drawBehind {\n scale(dragScale) {\n drawCircle(color = Lime500.copy(alpha = .3f))\n }\n }\n )\n }\n )\n\n\nHere, we add a light circle that scales up with an animation, as the user drags the thumb along the Dial.\n\n0:00\n\n/0:03\n\n1×\n\n### Invisible Thumb\n\nSometimes, 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.\n\nChromaDial 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.\n\n\n Dial(\n degree = degree,\n onDegreeChange = { degree = it },\n thumb = { Box(Modifier.size(32.dp)) }\n )\n\n\nThis way, you have control over the size of the touch area, but without drawing anything.\nThen it will seem the user is dragging directly on the track, but in reality, there is a thumb receiving the touch events.\n\n0:00\n\n/0:03\n\n1×\n\n### Full-Size Thumb\n\nWhat 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.\n\nDon't worry, ChromaDial can do that too.\n\nWe just have to define an invisible thumb that fills the entire area.\n\n\n val interactionSource = remember { MutableInteractionSource() }\n\n Dial(\n degree = degree,\n onDegreeChange = { degree = it },\n interactionSource = interactionSource,\n thumb = { state ->\n Box(\n modifier = Modifier\n .fillMaxSize()\n )\n }\n )\n\n\nSince 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.\n\nOne application of this is creating a voice recorder dial, like the one found on Nothing Phones.\n\n0:00\n\n/0:03\n\n1×\n\n## Track\n\nThe 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.\n\nThe `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.\n\n### Drawing Arcs\n\nYou 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.\n\nThey work similar to the default functions and can even take in gradient brushes, as well as solid colors.\n\n\n Dial(\n degree = degree,\n onDegreeChange = { degree = it },\n track = { state ->\n Box(\n Modifier\n .fillMaxSize()\n .drawBehind {\n // Background arc\n drawArc(\n color = Gray400.copy(alpha = 0.1f),\n startAngle = state.startDegrees,\n sweepAngle = state.degreeRange.endInclusive - state.degreeRange.start,\n radius = state.radius - (32.dp.toPx()) / 2,\n strokeWidth = 6.dp,\n strokeCap = StrokeCap.Round,\n )\n // Progress arc\n drawArc(\n color = Lime400,\n startAngle = state.startDegrees,\n sweepAngle = state.degree,\n radius = state.radius - (32.dp.toPx()) / 2,\n strokeWidth = 6.dp,\n strokeCap = StrokeCap.Round,\n )\n }\n )\n }\n )\n\n\n`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.\n\n0:00\n\n/0:03\n\n1×\n\n### `drawEveryInterval`\n\nOther 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.\n\nThink tick marks on a clock face.\n\nTo 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.\n\n\n track = { state ->\n Box(\n Modifier\n .fillMaxSize()\n .drawBehind {\n drawEveryInterval(\n startDegrees = state.startDegrees,\n sweepDegrees = state.degreeRange.endInclusive - state.degreeRange.start,\n radius = state.radius - 16.dp.toPx(),\n spacing = 2f,\n currentDegree = state.degree,\n ) { data ->\n rotate(data.rotationAngle, pivot = data.position) {\n val isLarge = data.intervalDegree % 30f == 0f\n drawLine(\n color = if (data.inActiveRange) Lime400 else Gray400.copy(alpha = .2f),\n start = data.position,\n end = data.position + Offset(0f, if (isLarge) 15f else 8f),\n strokeWidth = if (isLarge) 2.dp.toPx() else 1.dp.toPx(),\n )\n }\n }\n }\n )\n }\n\n\nInside 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.\nMoreover we use the `intervalDegree` or `index` to create larger tick marks to delineate certain intervals.\n\n0:00\n\n/0:03\n\n1×\n\n## Overshoot\n\nWhen the user drags past the end of the dial's range, ChromaDial automatically calculates overshoot animations.\nYou can define its behavior by passing in `overshootAnimationSpec` to control the stiffness and damping.\n\nThen ChromaDial calculates based on how far the user has moved the Dial out of bounds, and provides `state.overshootDegrees`.\n\nThe thumb has `overshootDegrees` automatically added onto it. But for the track, you are in complete control over how it is applied.\n\nFor example, you can make the track stretch by adding `overshootDegrees` onto the active arc's `sweepDegrees`.\n\n\n val interactionSource = remember { MutableInteractionSource() }\n val isDragging by interactionSource.collectIsDraggedAsState()\n val dragScale by animateFloatAsState(if (isDragging) 1.3f else .95f)\n\n Dial(\n degree = degree,\n onDegreeChange = { degree = it },\n interactionSource = interactionSource,\n overshootAnimationSpec = spring(\n stiffness = Spring.StiffnessLow,\n dampingRatio = Spring.DampingRatioMediumBouncy\n ),\n thumb = { _ -> ... },\n track = { state ->\n Box(Modifier.fillMaxSize().drawBehind {\n // draw inactive arc\n drawArc(\n color = Lime400,\n startAngle = state.startDegrees + state.overshootDegrees / 2,\n sweepAngle = state.degree + state.overshootDegrees / 2,\n radius = state.radius - (64.dp.toPx()) / 2,\n strokeWidth = 12.dp,\n strokeCap = StrokeCap.Round,\n )\n })\n },\n )\n\n\nWhen 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.\n\n0:00\n\n/0:04\n\n1×\n\n* * *\n\nChromaDial is still in alpha, so the API isn't final. But the customization hooks here are already enough to build whatever you're imagining.\n\nThanks for reading and good luck!",
"title": "How to create custom Dials",
"updatedAt": "2026-03-14T20:43:06.278Z"
}