Project Foundations
To begin customizing the Material3 Slider in Jetpack Compose, let’s first set up the project and configure the necessary dependencies.
In your libs.versions.toml
file, add the following:
1
2
3
4
5
6
|
[versions]
composeBom = "2024.11.00"
[libraries]
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
|
In your build.gradle.kts
, make sure to include these dependencies:
1
2
3
4
5
|
dependencies {
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.material3)
// other dependencies
}
|
This will ensure that you’re using the correct versions of Material3 and Jetpack Compose.
We will use next colors as a base:
1
2
3
|
val AccentGreen = Color(0xFFa7c957)
val LightGreen = Color(0xFFf2e8cf)
val BackgroundGreen = Color(0xFF386641)
|
And this Composable
as a base:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
|
@Composable
fun PlaybackSliderStart(
modifier: Modifier = Modifier,
currentTime: Long,
totalDuration: Long,
onSliderPositionChange: (Float) -> Unit
) {
val seekBarValue = if (totalDuration > 0) {
(1f / totalDuration) * currentTime
} else 0f
Row(
modifier = modifier
.fillMaxWidth()
.wrapContentHeight(),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = getTimeString(currentTime),
style = MaterialTheme.typography.labelMedium,
color = LightGreen
)
Spacer(Modifier.width(8.dp))
Slider(
modifier = Modifier.weight(1f),
value = seekBarValue,
onValueChange = onSliderPositionChange
)
Spacer(Modifier.width(8.dp))
Text(
text = getTimeString(totalDuration),
style = MaterialTheme.typography.labelMedium,
color = LightGreen
)
}
}
fun getTimeString(timeInMillis: Long): String {
val minutes = timeInMillis / 60 / 1_000L
val seconds = ((timeInMillis / 1_000f) % 60).toInt()
return String.format(
Locale.getDefault(),
"%02d:%02d",
minutes,
seconds
)
}
|

With everything set up, you’re ready to start customizing the Material3 Slider.
Exploring the Material3 Slider
The basic structure of the Slider
function looks like this:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
fun Slider(
value: Float,
onValueChange: (Float) -> Unit,
onValueChangeFinished: (() -> Unit)? = null,
...
thumb: @Composable (SliderState) -> Unit = {
SliderDefaults.Thumb(
interactionSource = interactionSource,
colors = colors,
enabled = enabled
)
},
track: @Composable (SliderState) -> Unit = { sliderState ->
SliderDefaults.Track(
colors = colors,
enabled = enabled,
sliderState = sliderState
)
}
)
|
Here, we see that thumb
and track
are composable lambdas, which allows us to fully customize the slider’s appearance. You can replace these defaults with any custom composables that fit your design needs.
Let’s begin with a simple modification: using Canvas
to create a custom track for the slider.
Inside the Slider
composable, we’ll use the Canvas
composable to draw a custom track. We will receive a SliderState
inside the lambda, which contains the current value of the slider (ranging from 0f
to 1f
). Based on this value, we will draw two lines to represent the track.
Here’s how you can draw the first line:
1
2
3
4
5
6
7
|
drawLine(
color = AccentGreen,
start = Offset(0f, canvasHeight / 2),
end = Offset(canvasWidth * state.value, canvasHeight / 2),
strokeWidth = 4.dp.toPx(),
cap = StrokeCap.Round
)
|
This will draw the first part of the track up to the current value. Now, for the second part of the track, we’ll do something similar:
1
2
3
4
5
6
7
|
drawLine(
color = LightGreen,
start = Offset(canvasWidth * state.value, canvasHeight / 2),
end = Offset(canvasWidth, canvasHeight / 2),
strokeWidth = 4.dp.toPx(),
cap = StrokeCap.Round
)
|
By combining these two lines, we get a slider with a two-toned track based on the current slider value.
Here’s the full implementation of the custom track:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
|
Slider(
modifier = Modifier.weight(1f),
value = seekBarValue,
onValueChange = onSliderPositionChange,
track = { state ->
Canvas(
modifier = Modifier
.fillMaxWidth()
.height(4.dp)
) {
val canvasWidth = size.width
val canvasHeight = size.height
// Draw the first segment of the track
drawLine(
color = AccentGreen,
start = Offset(0f, canvasHeight / 2),
end = Offset((canvasWidth * state.value), canvasHeight / 2),
strokeWidth = 4.dp.toPx(),
cap = StrokeCap.Round
)
// Draw the second segment of the track
drawLine(
color = LightGreen,
start = Offset((canvasWidth * state.value), canvasHeight / 2),
end = Offset((canvasWidth), canvasHeight / 2),
strokeWidth = 4.dp.toPx(),
cap = StrokeCap.Round
)
}
},
)
|
The end result is a slider with a visually distinct track that dynamically updates as the slider value changes.
The Material3 Slider
provides a thumb
parameter, which allows you to define a custom composable for the thumb. The composable receives the slider’s current state (SliderState
) and can dynamically adjust its appearance based on the
slider’s position.
When customizing the slider, sometimes less is more. While there are plenty of creative ways to design a slider thumb, a simple, elegant approach can often be just as effective.
Here’s the implementation:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
Slider(
value = seekBarValue,
onValueChange = onSliderPositionChange,
thumb = { state ->
Box(
modifier = Modifier
.background(
color = AccentGreen,
shape = CircleShape
)
.size(24.dp)
)
}
)
|
This design will keep the focus on our wavy track while ensuring the thumb is easy to spot and interact with. The simplicity also makes it adaptable to various themes and use cases without overwhelming the overall slider design.

The Mathematics of Waves
Let’s break down the mathematics behind the wavy track in our custom slider. The key here is using a sinusoidal function to create a smooth, oscillating path. By understanding the math, we can tweak the wave’s appearance and behavior to our liking.

To create a wavy path, we use a sine wave, which is a periodic oscillation described by the sine function:
$$y(x) = A \cdot \sin(k \cdot x + \phi)$$Where:
- $A$ is the amplitude (height) of the wave.
- $k$ is the wave number, which is related to the wavelength.
- $\phi$ is the phase shift, which allows us to offset the wave.
- $x$ is the horizontal position.
In our case, we are using this formula to calculate the vertical position of each point on the track, based on its horizontal position $x$ (in pixels).
Let’s go step by step.
The amplitude controls the vertical height of the wave. In our code, we define it as:
1
|
val amplitude = 3.dp.toPx() // Wave height
|
This means the wave will oscillate up and down by 3.dp
in pixel units.
The wavelength is the length of one full cycle of the wave, i.e., the distance between two consecutive peaks (or troughs). In our code, it’s defined as:
1
|
val wavelength = canvasWidth / 10 // Length of one wave cycle
|
This means the wave will repeat every canvasWidth / 10
units, which will determine how many waves fit in the slider track.
For each horizontal position $x$, we calculate the corresponding vertical position $y$ using the sine function:
1
2
|
val angle = ((x / waveLength) * 2 * PI)
val y = (canvasHeight / 2) + amplitude * sin(angle).toFloat()
|
- The angle for the sine function is determined by the horizontal position $x$. The term $(x/waveLength)$ normalizes the position along the wave’s cycle, and multiplying by
$2π$ (the full period of a sine wave) ensures the wave repeats every full cycle.
- The vertical position $y$ is the middle of the canvas (height divided by 2) plus the sine value, scaled by the amplitude. This causes the wave to oscillate above and below the center of the track.
Crafting a Wavy Track
With the mathematical foundation in place, it’s time to craft our wavy slider track.

The core of our wavy track lies in the Path
object, where we translate the sine wave math from the previous chapter into drawable instructions. Here’s how we create the path:
1
2
3
4
5
6
7
8
9
10
|
val amplitude = 3.dp.toPx() // Wave height
val waveLength = canvasWidth / 10 // Length of one wave cycle
val path = Path().apply {
for (x in 0..(canvasWidth * state.value).toInt()) {
val angle = ((x / waveLength) * 2 * PI)
val y = (canvasHeight / 2) + amplitude * sin(angle).toFloat()
if (x == 0) moveTo(x.toFloat(), y) else lineTo(x.toFloat(), y)
}
}
|
The amplitude sets the height of the wave, while the wavelength determines how frequently it oscillates across the slider track. As the slider moves, the wave’s length dynamically adapts to the slider’s current value (state.value
), ensuring a seamless transition. The path is formed by beginning at an initial point with moveTo
and smoothly connecting each subsequent point using lineTo
, resulting in a continuous and flowing wave.
Now that we have the path, let’s integrate it into the slider track using a Canvas
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
|
Canvas(
modifier = Modifier
.fillMaxWidth()
.height(8.dp) // Height of the track
) {
val canvasWidth = size.width
val canvasHeight = size.height
// Draw the wavy segment of the track
drawPath(
path = path,
color = AccentGreen,
style = Stroke(width = 4.dp.toPx(), cap = StrokeCap.Round)
)
// Draw the straight segment of the track
drawLine(
color = LightGreen,
start = Offset((canvasWidth * state.value), canvasHeight / 2),
end = Offset((canvasWidth), canvasHeight / 2),
strokeWidth = 4.dp.toPx(),
cap = StrokeCap.Round
)
}
|
Animating the Wave
A wavy slider track is already visually striking, but adding animation can elevate the experience, creating a more dynamic and engaging interaction.

To animate the wave, we’ll use the rememberInfiniteTransition
API to create a smoothly varying offset. This offset will shift the wave horizontally, simulating the effect of a wave flowing endlessly along the slider track.
Here’s how we can implement it:
1
2
3
4
5
6
7
8
9
10
11
12
|
val infiniteTransition = rememberInfiniteTransition()
val waveOffset by infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = 2 * Math.PI.toFloat(), // One full wave cycle
animationSpec = infiniteRepeatable(
animation = tween(
durationMillis = 2000, // Adjust speed
easing = LinearEasing
),
repeatMode = RepeatMode.Restart
)
)
|
The waveOffset
value will be used in the mathematical calculation of the wave’s path to create the illusion of motion.
To integrate the animation into the wave path, we’ll modify the existing path logic to factor in the waveOffset
value:
1
2
3
4
5
6
7
|
val path = Path().apply {
for (x in 0..(canvasWidth * state.value).toInt()) {
val angle = ((x / waveLength) * 2 * Math.PI) + waveOffset
val y = (canvasHeight / 2) + amplitude * sin(angle).toFloat()
if (x == 0) moveTo(x.toFloat(), y) else lineTo(x.toFloat(), y)
}
}
|
The waveOffset
ensures that the wave appears to be moving while maintaining its shape.
Here’s the complete code for the animated wave:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
|
@Composable
fun PlaybackSlider(
modifier: Modifier = Modifier,
currentTime: Long,
totalDuration: Long,
onSliderPositionChange: (Float) -> Unit
) {
val waveOffset = remember { Animatable(0f) }
val seekBarValue = remember(currentTime) {
if (totalDuration > 0) {
(1f / totalDuration) * currentTime
} else 0f
}
LaunchedEffect(true) {
waveOffset.animateTo(
targetValue = 2 * PI.toFloat(), // One full cycle
animationSpec = infiniteRepeatable(
animation = tween(
durationMillis = 2000,
easing = LinearEasing
),
repeatMode = RepeatMode.Restart
)
)
}
Row(
modifier = modifier
.fillMaxWidth()
.wrapContentHeight(),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = getTimeString(currentTime),
style = MaterialTheme.typography.labelMedium,
color = LightGreen
)
Spacer(Modifier.width(8.dp))
Slider(
modifier = Modifier.weight(1f),
value = seekBarValue,
onValueChange = onSliderPositionChange,
track = { state ->
Canvas(
modifier = Modifier
.fillMaxWidth()
.height(6.dp)
) {
val canvasWidth = size.width
val canvasHeight = size.height
val amplitude = 3.dp.toPx() // Wave height
val wavelength = canvasWidth / 10 // Length of one wave cycle
val path = Path().apply {
for (x in 0..(canvasWidth * state.value).toInt()) {
val angle = ((x / wavelength) * 2 * PI) + waveOffset.value // Apply smooth offset
val y = (canvasHeight / 2) + amplitude * sin(angle).toFloat()
if (x == 0) moveTo(x.toFloat(), y) else lineTo(x.toFloat(), y)
}
}
drawPath(
path = path,
color = AccentGreen,
style = Stroke(
width = 4.dp.toPx(),
cap = StrokeCap.Round
)
)
drawLine(
LightGreen,
Offset((canvasWidth * state.value), canvasHeight / 2),
Offset((canvasWidth), canvasHeight / 2),
strokeWidth = 4.dp.toPx(),
cap = StrokeCap.Round
)
}
},
thumb = {
Box(
modifier = Modifier
.background(AccentGreen, shape = CircleShape)
.size(24.dp)
)
},
)
Spacer(Modifier.width(8.dp))
Text(
text = getTimeString(totalDuration),
style = MaterialTheme.typography.labelMedium,
color = LightGreen
)
}
}
|
The parameters of the animation can be adjusted to fine-tune the effect:
durationMillis
: Controls the speed of the wave. Decrease for faster motion or increase for slower motion.
amplitude
: Adjusts the wave’s height for more dramatic or subtle movement.
waveLength
: Changes the frequency of the wave cycles.
Final Thoughts and Next Steps
Congratulations! You’ve built a fully functional, visually engaging slider using Jetpack Compose, complete with a custom wavy track, animated motion, and a minimalist thumb.
Your slider is already a great starting point, but here are some ways to make it even more unique:
- Expose
amplitude
, waveLength
, and durationMillis
as parameters to allow runtime adjustments.
- Add subtle animations, such as scaling or color changes, to the thumb when interacting with it.
- Replace the sine wave with other patterns (e.g., square waves or custom paths) to suit your app’s purpose.