
Animated ring of glowing spikes attaching to a UI element, rendering an external, non-invasive, shader-driven halo with layered, configurable spike motion, shape, and visual effects.
An animated ring of glowing spikes for any Compose Multiplatform composable.
| GLOW | HOLO | BLUR |
|---|---|---|
![]() |
![]() |
![]() |
| Circle | Rounded rectangle | Rectangle |
Nimbus draws a ring of animated spikes around a composable — a slow, breathing halo that grows out of the shape's own outline. Spikes fade in and out at zero height, drift or rotate, and pulse in size, so the ring never looks mechanical.
It renders outside the composable's bounds and changes neither its size nor the layout around it, so it can be attached to a button that already exists without touching anything else.
A backend is picked automatically and the result looks the same on all of them:
| Target | Backend |
|---|---|
| iOS, Desktop, Web | Skia SkSL runtime shader |
| Android 33+ | AGSL runtime shader |
| Android 29–32 | off-screen GLSL, zero-copy via HardwareBuffer
|
| Android 26–28 | off-screen GLSL via glReadPixels
|
| fallback | Canvas, geometry only |
The shader body is shared verbatim across all three dialects, so the backends cannot drift apart. While the effect is off the node is not redrawn at all, and while the app is backgrounded the frame loop suspends — a disabled Nimbus costs nothing.
| Platform | Support | Notes |
|---|---|---|
| Android | ✅ | minSdk 26 |
| iOS — device (arm64) | ✅ | |
| iOS — simulator (Apple Silicon) | ✅ | iosSimulatorArm64 |
| iOS — simulator (Intel) | ❌ | not supported by Compose itself, see below |
| Desktop (JVM) | ✅ | |
| Wasm | ✅ | |
| JS | ✅ |
[!IMPORTANT]
iosX64— the Intel simulator — is not available. This is not a Nimbus decision: Compose Multiplatform does not publish aniosX64variant at all, and never has. Any Compose Multiplatform library is in the same position.So if your project declares
iosX64(), it already cannot depend on Compose there. Drop the target — Apple Silicon simulators useiosSimulatorArm64— or keep Compose and Nimbus out of that source set.
Nimbus is on Maven Central, so no extra repository is needed.
kotlin {
sourceSets {
commonMain.dependencies {
implementation("io.github.onthecrow:nimbus:0.1.1")
}
}
}dependencies {
implementation("io.github.onthecrow:nimbus:0.1.1")
}Gradle module metadata resolves the right variant automatically, so a plain Android project depends on the same coordinate.
[versions]
nimbus = "0.1.1"
[libraries]
nimbus = { module = "io.github.onthecrow:nimbus", version.ref = "nimbus" }The badge above always shows the latest released version.
One modifier, applied to a composable you already have:
val shape = CircleShape
var active by remember { mutableStateOf(false) }
Button(
onClick = { active = !active },
shape = shape,
modifier = Modifier.nimbus(shape = shape, active = active),
) {
Text("Connect")
}Two things are worth knowing straight away:
Modifier.clip(), a snug Card, or a LazyColumn item boundary will cut it off. Leave
room around the composable — roughly maxPeakHeight × min(width, height) / 2 + collar on
every side.| Parameter | Default | What it does |
|---|---|---|
shape |
RectangleShape |
Outline the spikes are built around. Match the composable's own shape. |
active |
true |
Shows or hides the effect, animating over activationSpec instead of popping. |
layers |
two built-in layers | The spike rings, drawn back to front. |
sharpness |
0.5f |
0f wide rounded domes, 1f narrow drawn-out points. |
collar |
4.dp |
Band joining the spikes at their base. 0.dp leaves them separate. |
effect |
GLOW |
GLOW, BLUR or HOLO. |
effectIntensity |
1f |
0f leaves the bare spike geometry. |
countEasing |
FastOutSlowInEasing |
Easing while a layer's spike count sweeps. |
rotationEasing |
LinearEasing |
Easing within one revolution of a rotating layer. |
activationSpec |
tween(600) |
Animation used when active flips. |
Sizes are fractions of the reference size — min(width, height) / 2, the radius of the
largest circle fitting inside the composable. On a 120.dp circle that is 60.dp.
| Parameter | Default | What it does |
|---|---|---|
color |
— | Colour of this layer's spikes. |
minSpikesCount |
4 |
Lower bound of the count. Equal to max keeps it fixed. |
maxSpikesCount |
10 |
Upper bound. Capped at 20 by the shader's loop limit. |
maxPeakHeight |
0.4f |
How far a spike reaches past the edge, as a fraction of the reference size. |
alpha |
0.85f |
Opacity of the layer. |
spawnSpeed |
0.10f |
Sweeps per second between min and max count. 0f freezes it. |
sizeChangeSpeed |
1.2f |
Rad/s of each spike's own height pulsation. |
motionMode |
RIGID |
RIGID turns the ring as a whole, INDEPENDENT lets each spike drift. |
rotationSpeed |
0.2f |
Rad/s of the ring; the sign sets direction. RIGID only. |
wobbleAmount |
0.30f |
How far a spike strays from its slot. INDEPENDENT only. |
wobbleSpeed |
0.5f |
Rad/s of that drift. INDEPENDENT only. |
jitter |
0.12f |
Static irregularity, so the ring is not machine-perfect. |
widthFactor |
1.15f |
Spike width relative to the gap. Above 1f they merge into a ridge. |
Depth comes from stacking rings that turn at different speeds and directions:
Modifier.nimbus(
shape = shape,
active = active,
effect = NimbusEffect.GLOW,
layers = listOf(
NimbusLayer(
color = Color(0xFF4DD0E1),
minSpikesCount = 5,
maxSpikesCount = 9,
maxPeakHeight = 0.12f,
alpha = 0.80f,
rotationSpeed = 0.20f,
),
NimbusLayer(
color = Color(0xFF7C4DFF),
minSpikesCount = 4,
maxSpikesCount = 7,
maxPeakHeight = 0.20f,
alpha = 0.75f,
rotationSpeed = -0.15f,
),
),
)The sample is a tuning bench rather than a demo: every parameter of the public API is wired to a control, so you can dial the effect in on a real device instead of guessing values in code.
+ to add a layer and × to remove one, so you can build up a
multi-ring look and watch it develop.The button in the top-right corner generates a NimbusButton.kt with your current
settings baked in — a complete, compilable composable, not a config dump — and opens the
system share sheet so you can send it to yourself. Drop it into a project, add the
dependency, and call NimbusButton().
# Android
./gradlew :androidApp:installDebug
# Desktop (macOS, Windows, Linux)
./gradlew :desktopApp:runFor iOS, open iosApp/ in Xcode and run from there.
The desktop build is the quickest way to see the Skia backend: Compose renders through Skia
on Desktop, iOS and Web alike, so :desktopApp:run exercises the same shader path as iOS
without needing a device or Xcode.
Issues and pull requests are welcome. If you are changing how the effect looks, please say which backend you tested on — the Android GL fallbacks below API 33 are easy to break without noticing, since most devices never take that path.
Questions or ideas: @onthecrow on Telegram.
Copyright 2026 Dmitry Voronov
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
An animated ring of glowing spikes for any Compose Multiplatform composable.
| GLOW | HOLO | BLUR |
|---|---|---|
![]() |
![]() |
![]() |
| Circle | Rounded rectangle | Rectangle |
Nimbus draws a ring of animated spikes around a composable — a slow, breathing halo that grows out of the shape's own outline. Spikes fade in and out at zero height, drift or rotate, and pulse in size, so the ring never looks mechanical.
It renders outside the composable's bounds and changes neither its size nor the layout around it, so it can be attached to a button that already exists without touching anything else.
A backend is picked automatically and the result looks the same on all of them:
| Target | Backend |
|---|---|
| iOS, Desktop, Web | Skia SkSL runtime shader |
| Android 33+ | AGSL runtime shader |
| Android 29–32 | off-screen GLSL, zero-copy via HardwareBuffer
|
| Android 26–28 | off-screen GLSL via glReadPixels
|
| fallback | Canvas, geometry only |
The shader body is shared verbatim across all three dialects, so the backends cannot drift apart. While the effect is off the node is not redrawn at all, and while the app is backgrounded the frame loop suspends — a disabled Nimbus costs nothing.
| Platform | Support | Notes |
|---|---|---|
| Android | ✅ | minSdk 26 |
| iOS — device (arm64) | ✅ | |
| iOS — simulator (Apple Silicon) | ✅ | iosSimulatorArm64 |
| iOS — simulator (Intel) | ❌ | not supported by Compose itself, see below |
| Desktop (JVM) | ✅ | |
| Wasm | ✅ | |
| JS | ✅ |
[!IMPORTANT]
iosX64— the Intel simulator — is not available. This is not a Nimbus decision: Compose Multiplatform does not publish aniosX64variant at all, and never has. Any Compose Multiplatform library is in the same position.So if your project declares
iosX64(), it already cannot depend on Compose there. Drop the target — Apple Silicon simulators useiosSimulatorArm64— or keep Compose and Nimbus out of that source set.
Nimbus is on Maven Central, so no extra repository is needed.
kotlin {
sourceSets {
commonMain.dependencies {
implementation("io.github.onthecrow:nimbus:0.1.1")
}
}
}dependencies {
implementation("io.github.onthecrow:nimbus:0.1.1")
}Gradle module metadata resolves the right variant automatically, so a plain Android project depends on the same coordinate.
[versions]
nimbus = "0.1.1"
[libraries]
nimbus = { module = "io.github.onthecrow:nimbus", version.ref = "nimbus" }The badge above always shows the latest released version.
One modifier, applied to a composable you already have:
val shape = CircleShape
var active by remember { mutableStateOf(false) }
Button(
onClick = { active = !active },
shape = shape,
modifier = Modifier.nimbus(shape = shape, active = active),
) {
Text("Connect")
}Two things are worth knowing straight away:
Modifier.clip(), a snug Card, or a LazyColumn item boundary will cut it off. Leave
room around the composable — roughly maxPeakHeight × min(width, height) / 2 + collar on
every side.| Parameter | Default | What it does |
|---|---|---|
shape |
RectangleShape |
Outline the spikes are built around. Match the composable's own shape. |
active |
true |
Shows or hides the effect, animating over activationSpec instead of popping. |
layers |
two built-in layers | The spike rings, drawn back to front. |
sharpness |
0.5f |
0f wide rounded domes, 1f narrow drawn-out points. |
collar |
4.dp |
Band joining the spikes at their base. 0.dp leaves them separate. |
effect |
GLOW |
GLOW, BLUR or HOLO. |
effectIntensity |
1f |
0f leaves the bare spike geometry. |
countEasing |
FastOutSlowInEasing |
Easing while a layer's spike count sweeps. |
rotationEasing |
LinearEasing |
Easing within one revolution of a rotating layer. |
activationSpec |
tween(600) |
Animation used when active flips. |
Sizes are fractions of the reference size — min(width, height) / 2, the radius of the
largest circle fitting inside the composable. On a 120.dp circle that is 60.dp.
| Parameter | Default | What it does |
|---|---|---|
color |
— | Colour of this layer's spikes. |
minSpikesCount |
4 |
Lower bound of the count. Equal to max keeps it fixed. |
maxSpikesCount |
10 |
Upper bound. Capped at 20 by the shader's loop limit. |
maxPeakHeight |
0.4f |
How far a spike reaches past the edge, as a fraction of the reference size. |
alpha |
0.85f |
Opacity of the layer. |
spawnSpeed |
0.10f |
Sweeps per second between min and max count. 0f freezes it. |
sizeChangeSpeed |
1.2f |
Rad/s of each spike's own height pulsation. |
motionMode |
RIGID |
RIGID turns the ring as a whole, INDEPENDENT lets each spike drift. |
rotationSpeed |
0.2f |
Rad/s of the ring; the sign sets direction. RIGID only. |
wobbleAmount |
0.30f |
How far a spike strays from its slot. INDEPENDENT only. |
wobbleSpeed |
0.5f |
Rad/s of that drift. INDEPENDENT only. |
jitter |
0.12f |
Static irregularity, so the ring is not machine-perfect. |
widthFactor |
1.15f |
Spike width relative to the gap. Above 1f they merge into a ridge. |
Depth comes from stacking rings that turn at different speeds and directions:
Modifier.nimbus(
shape = shape,
active = active,
effect = NimbusEffect.GLOW,
layers = listOf(
NimbusLayer(
color = Color(0xFF4DD0E1),
minSpikesCount = 5,
maxSpikesCount = 9,
maxPeakHeight = 0.12f,
alpha = 0.80f,
rotationSpeed = 0.20f,
),
NimbusLayer(
color = Color(0xFF7C4DFF),
minSpikesCount = 4,
maxSpikesCount = 7,
maxPeakHeight = 0.20f,
alpha = 0.75f,
rotationSpeed = -0.15f,
),
),
)The sample is a tuning bench rather than a demo: every parameter of the public API is wired to a control, so you can dial the effect in on a real device instead of guessing values in code.
+ to add a layer and × to remove one, so you can build up a
multi-ring look and watch it develop.The button in the top-right corner generates a NimbusButton.kt with your current
settings baked in — a complete, compilable composable, not a config dump — and opens the
system share sheet so you can send it to yourself. Drop it into a project, add the
dependency, and call NimbusButton().
# Android
./gradlew :androidApp:installDebug
# Desktop (macOS, Windows, Linux)
./gradlew :desktopApp:runFor iOS, open iosApp/ in Xcode and run from there.
The desktop build is the quickest way to see the Skia backend: Compose renders through Skia
on Desktop, iOS and Web alike, so :desktopApp:run exercises the same shader path as iOS
without needing a device or Xcode.
Issues and pull requests are welcome. If you are changing how the effect looks, please say which backend you tested on — the Android GL fallbacks below API 33 are easy to break without noticing, since most devices never take that path.
Questions or ideas: @onthecrow on Telegram.
Copyright 2026 Dmitry Voronov
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.