
Animated, customizable layered sinusoidal wave hero backgrounds with per-layer breathing, sway and drift, depth-gradient fills, adjustable shadows, palette-driven coloring, and auto or stateless rendering modes.
Animated, customizable layered wave hero backgrounds for Compose Multiplatform.
KWave draws a full-bleed stack of vertically-breathing sinusoidal wave layers on a Canvas. Each
wave is filled with a subtle depth gradient, and the shadow and highlight hug the crest curve with
a soft falloff. The motion is organic, water-like: each layer's amplitude breathes (swells and
recedes) at its own rate, its crests sway slowly side to side, and the whole surface drifts gently
sideways with per-layer parallax. Each strand has its own off switch (drift = 0f,
WaveConfig.sway = 0f). It is theme-free. It reads no MaterialTheme; every color is supplied
through its own WaveColors API. It ships two composable entry points: a drop-in auto composable
that owns its own animation loop, and a stateless one that is a pure function of (phase, time)
for tests and external sync.
The GIFs are short looped previews (sped up, with a back-and-forth loop), so the motion can look a bit abrupt at the loop seam. Live, the waves breathe slowly and continuously. More palettes and portrait/landscape examples are in the gallery.
| Android | iOS | JVM / Desktop |
|---|---|---|
| ✅ | ✅ | ✅ |
iOS is shipped as iosArm64 + iosSimulatorArm64. The JVM target powers a Compose Desktop
sample and the fast unit tests.
KWave is published to Maven Central as red.rankorr:kwave. The version shown in the
snippets below is an example. The Maven Central badge at the top always reflects the
latest published version; use that.
[versions]
kwave = "0.2.0"
[libraries]
kwave = { module = "red.rankorr:kwave", version.ref = "kwave" }// build.gradle.kts (Kotlin Multiplatform module)
kotlin {
sourceSets {
commonMain.dependencies {
implementation(libs.kwave)
}
}
}// build.gradle.kts
dependencies {
implementation("red.rankorr:kwave:0.2.0")
}Snapshots resolve from
https://central.sonatype.com/repository/maven-snapshots/. Add that repository to yourdependencyResolutionManagementwhile KWave is pre-release; tagged releases resolve straight frommavenCentral().
The drop-in KWave owns its animation loop. Pass Modifier.fillMaxSize() for a full-screen
background and you are done:
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.ui.Modifier
import red.rankorr.kwave.KWave
@Composable
fun Hero() {
KWave(modifier = Modifier.fillMaxSize())
}That uses WaveConfig.Default, a neutral blue-grey preset. The drop-in runs its own loop, so you
do not advance any clock yourself. The waves breathe (swelling and receding), their crests sway
slowly, and the surface drifts gently sideways. Everything below customizes it.
Sizing: KWave honors the
modifieryou pass verbatim; it never forcesfillMaxSize()internally. For a full-bleed background passModifier.fillMaxSize(); for a bounded banner pass e.g.Modifier.fillMaxWidth().height(220.dp).
KWave is theme-free; you choose colors through WaveColors, built only through its factories.
Simple two-color gradient. Back layers lean toward top, front layers toward bottom:
import androidx.compose.ui.graphics.Color
import red.rankorr.kwave.WaveColors
import red.rankorr.kwave.WaveConfig
val ocean = WaveConfig.generate(
waveCount = 3,
colors = WaveColors.gradient(top = Color(0xFF1565C0), bottom = Color(0xFF0D1B2A)),
)
KWave(config = ocean, modifier = Modifier.fillMaxSize())Rainbow palette. The rainbow rides the wave fills: each layer is tinted by sampling the palette at its depth, so every layer carries a distinct hue. The background is not the full saturated palette; it is a muted two-stop wash darkened from the palette extremes, so the colorful waves stay the subject rather than competing with a loud sky:
val rainbow = WaveConfig.generate(
waveCount = 5,
colors = WaveColors.palette(
listOf(
Color(0xFFFF5252),
Color(0xFFFFB300),
Color(0xFF66BB6A),
Color(0xFF29B6F6),
Color(0xFFAB47BC),
),
),
)A single flat color is available too. So the same-color waves do not vanish into a same-color
background, solid() ramps the per-layer fill by depth (slightly darker at the back, lighter at
the front); auto per-layer alpha adds further separation on top:
val flat = WaveColors.solid(Color(0xFF263238))An empty
palette([])falls back to a neutral color, andpalette(listOf(c))behaves likesolid(c). Agradient(top, bottom)whose two colors are equal also routes throughsolid(), so a monochrome gradient stays visible the same way.
Decoupling the background from the waves. Each factory builds a coherent scene where the
backdrop and the wave palette derive from the same colors. When you want them independent, chain
withBackground(...): it replaces only the background, leaving the wave fills and highlight
untouched:
// Rainbow waves over a custom near-black sky.
val custom = WaveColors.palette(rainbow).withBackground(Color(0xFF101820))
// Waves only, no background at all: KWave sits on top of your own content
// (an image, another composable). The renderer skips the background pass entirely.
val wavesOnly = WaveColors.gradient(Color(0xFF1565C0), Color(0xFF0D47A1))
.withBackground(Color.Transparent)withBackground also accepts (top, bottom) for a gradient backdrop or a List<Color> for
multi-stop skies.
ShadowMode controls the diffuse cast shadow each wave projects on the content behind it — the
soft elevation that separates the layers. The default, Auto, adapts per layer to the local wave
color so one mode looks correct over light and dark palettes alike. (The crest light is part of
each wave's fill gradient, tinted by WaveColors.highlight, and is not affected by ShadowMode.)
import red.rankorr.kwave.ShadowMode
// Default: per-layer black/white by luminance (light wave -> dark shadow, dark wave -> back-glow).
WaveConfig.generate(colors = ocean.colors, shadow = ShadowMode.Auto)
// Shadow = the layer's own color darkened (stays in the palette's hue family).
WaveConfig.generate(colors = ocean.colors, shadow = ShadowMode.FromWave)
// Fills only: no cast shadow.
WaveConfig.generate(colors = ocean.colors, shadow = ShadowMode.None)
// Explicit color + alpha (coerced into [0, 1]) for every layer. The alpha drives the rendered
// shadow's total peak opacity (here a softer 0.3).
WaveConfig.generate(colors = ocean.colors, shadow = ShadowMode.Custom(Color.Black, alpha = 0.3f))WaveConfig.generate builds a coherent stack without hand-tuning each layer:
val config = WaveConfig.generate(
waveCount = 4, // number of layers, coerced to >= 1
crests = 1.5f, // relative crest density per layer (1 = baseline; higher = more, tighter)
harmonic = 0.25f, // crest roughness; 0 = clean rounded sine, higher = choppier/less regular
spacing = 1f, // vertical spread of the layers; < 1 overlaps them more, > 1 separates
amplitude = 0.04f, // base peak displacement as a fraction of height
variation = 0.4f, // per-layer pseudo-random jitter in [0, 1]; 0 = smooth/uniform
colors = ocean.colors,
shadow = ShadowMode.Auto,
gradientEnd = 0.78f, // vertical fraction at which the background gradient ends
// seed = 0, // advanced; leave at 0 unless you need a reproducible re-roll (see below)
)The generator auto-distributes each layer's horizontal phase offset, auto-assigns depth-based
alpha (back transparent → front opaque), adds per-layer breathing and sway, and samples each
layer's tint from colors. On top of the smooth back→front gradient, every per-layer property gets
a deterministic, seeded pseudo-random jitter scaled by variation, so the layers undulate out of
sync instead of moving as one rigid block. crests and harmonic together shape the crests:
crests is a relative density (1 = baseline, higher packs more and tighter crests) rather than
a literal crest count. Its twin, harmonic, is the crest roughness (0 is a clean rounded sine,
higher mixes in more of the second harmonic for choppier, less regular crests). spacing controls
how much the layers overlap vertically: a smaller value bunches them together, a larger one
separates them. gradientEnd sets where the background gradient ends, so you no longer need to
rebuild a second WaveConfig just to tune it. sway (also available directly on WaveConfig)
weights the slow side-to-side lean of the breathing crests: 1 is the nominal organic roll, 0f
turns it off and restores the pure vertical breathing of 0.1.x.
seed(advanced). The jitter is a pure function ofseed, so the same arguments always yield the exact same configuration. Leaveseedat its default0unless you need a reproducible re-roll: a different layout, or pinning a screenshot test. It is the last parameter for that reason.
For full control, build the WaveConfig from your own immutable list of WaveLayerSpec. Every
value is coerced into a valid range at construction (a negative amplitude becomes 0, a
baseFrac above 1 is clamped, etc.), so invalid input can never reach the renderer.
import androidx.compose.ui.graphics.Color
import red.rankorr.kwave.WaveColors
import red.rankorr.kwave.WaveConfig
import red.rankorr.kwave.WaveLayerSpec
import kotlinx.collections.immutable.persistentListOf
val config = WaveConfig(
layers = persistentListOf(
// Back layer: pure sine (harmonic = 0), semi-transparent.
WaveLayerSpec(baseFrac = 0.45f, amplitude = 0.035f, speed = 0.7f, crests = 0.8f, harmonic = 0f),
// Front layer: a touch of 2nd-harmonic for a less regular crest, opaque.
WaveLayerSpec(baseFrac = 0.62f, amplitude = 0.030f, speed = 1.0f, crests = 0.9f, harmonic = 0.25f),
),
colors = WaveColors.gradient(Color(0xFF455A64), Color(0xFF263238)),
)WaveLayerSpec is a regular @Immutable class (no data class copy()), but exposes withTint
and withAlpha for the two most common targeted tweaks:
val tinted = layer.withTint(Color(0xFF80DEEA)).withAlpha(0.6f)A pure, deterministic function of (phase, time) with no internal animation state. Here phase is
the horizontal phase of every layer (constant for in-place motion, advanced slowly for drift, or a
value you drive for deliberate horizontal translation) and time advances the per-layer amplitude
breathing and crest sway. Drive it yourself for screenshot tests or to advance time however you
like:
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.withFrameNanos
@Composable
fun ControlledWave() {
val elapsed = remember { mutableFloatStateOf(0f) }
LaunchedEffect(Unit) {
var last = 0L
while (true) withFrameNanos { now ->
if (last != 0L) elapsed.floatValue += (now - last) / 1_000_000_000f
last = now
}
}
KWave(
config = WaveConfig.Default,
// Hold phase constant (or advance it slowly for drift); `time` drives breathing + sway.
phase = 0f,
time = elapsed.floatValue,
modifier = Modifier.fillMaxSize(),
)
}The ambient drift is very slow (a full phase cycle takes about two minutes). When you want a
deliberate horizontal translation (e.g. a hero that follows a pager), feed that signal through
phaseShift. The drop-in KWave reads it on every frame, so a pager offset or scroll position
flows straight into the wave's horizontal phase without restarting the loop:
val pagerState = rememberPagerState { pageCount }
KWave(
modifier = Modifier.fillMaxSize(),
// Each page deliberately nudges the waves sideways; the ambient motion keeps running underneath.
phaseShift = (pagerState.currentPage + pagerState.currentPageOffsetFraction) * 0.5f,
)Other knobs on the drop-in overload:
speed sets the breathing/sway-tempo multiplier (how fast the layers move). Default 1.phaseShift is a live external phase signal for deliberate horizontal translation. Default 0.isPlaying = false freezes the animation on the current frame and fully suspends the internal
loop (zero frames, zero rendering work while frozen).respectReducedMotion (default true): when the system reduce-motion setting is on, KWave
renders a single static frame instead of starting the loop.drift sets the ambient horizontal travel in radians of phase per second, parallaxed per layer.
Default 0.05 (a full phase cycle ≈ 2 minutes); 0f removes the travel. Breathing layers still
sway gently — set WaveConfig.sway = 0f too for the strict in-place breathing of 0.1.x.maxFps caps how often the animation updates; <= 0 (default) updates on every display frame.
The motion is slow, so 24–30 is usually indistinguishable from the device rate and saves
battery, especially on 120 Hz displays.speed and drift are integrated per frame, so you can change them live (a slider, an animated
transition) and the motion changes tempo smoothly instead of jumping.
The drop-in overload is lifecycle-aware (it pauses below STARTED and resumes without a time jump)
and randomizes its initial phase per instance, so several KWaves on one screen do not breathe in
lockstep. All frame-driven state is read in the draw phase, so the animation re-draws without ever
recomposing, and the time integrators keep frame-level precision even after days on screen.
A Compose Desktop sample app doubles as a live visual test harness. It has sliders for waveCount,
crests, harmonic (a "Roughness" slider next to "Crests"), spacing, amplitude, variation,
speed, gradientEnd, a "Randomize layout" button that bumps the seed, plus a shadow-mode
selector and a gradient/rainbow color switch:
./gradlew :sample:runThe sample is not published.
Planned for a future 1.x release (not yet implemented):
Contributions are welcome. See CONTRIBUTING.md for the build, test, detekt, and
apiCheck workflow. The public API surface is tracked by the binary-compatibility-validator; any
intentional change to it must update the committed api/ dump (./gradlew apiDump).
Copyright 2026 Jessy Bonnotte (Shyzkanza)
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.
See LICENSE for the full text.
Animated, customizable layered wave hero backgrounds for Compose Multiplatform.
KWave draws a full-bleed stack of vertically-breathing sinusoidal wave layers on a Canvas. Each
wave is filled with a subtle depth gradient, and the shadow and highlight hug the crest curve with
a soft falloff. The motion is organic, water-like: each layer's amplitude breathes (swells and
recedes) at its own rate, its crests sway slowly side to side, and the whole surface drifts gently
sideways with per-layer parallax. Each strand has its own off switch (drift = 0f,
WaveConfig.sway = 0f). It is theme-free. It reads no MaterialTheme; every color is supplied
through its own WaveColors API. It ships two composable entry points: a drop-in auto composable
that owns its own animation loop, and a stateless one that is a pure function of (phase, time)
for tests and external sync.
The GIFs are short looped previews (sped up, with a back-and-forth loop), so the motion can look a bit abrupt at the loop seam. Live, the waves breathe slowly and continuously. More palettes and portrait/landscape examples are in the gallery.
| Android | iOS | JVM / Desktop |
|---|---|---|
| ✅ | ✅ | ✅ |
iOS is shipped as iosArm64 + iosSimulatorArm64. The JVM target powers a Compose Desktop
sample and the fast unit tests.
KWave is published to Maven Central as red.rankorr:kwave. The version shown in the
snippets below is an example. The Maven Central badge at the top always reflects the
latest published version; use that.
[versions]
kwave = "0.2.0"
[libraries]
kwave = { module = "red.rankorr:kwave", version.ref = "kwave" }// build.gradle.kts (Kotlin Multiplatform module)
kotlin {
sourceSets {
commonMain.dependencies {
implementation(libs.kwave)
}
}
}// build.gradle.kts
dependencies {
implementation("red.rankorr:kwave:0.2.0")
}Snapshots resolve from
https://central.sonatype.com/repository/maven-snapshots/. Add that repository to yourdependencyResolutionManagementwhile KWave is pre-release; tagged releases resolve straight frommavenCentral().
The drop-in KWave owns its animation loop. Pass Modifier.fillMaxSize() for a full-screen
background and you are done:
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.ui.Modifier
import red.rankorr.kwave.KWave
@Composable
fun Hero() {
KWave(modifier = Modifier.fillMaxSize())
}That uses WaveConfig.Default, a neutral blue-grey preset. The drop-in runs its own loop, so you
do not advance any clock yourself. The waves breathe (swelling and receding), their crests sway
slowly, and the surface drifts gently sideways. Everything below customizes it.
Sizing: KWave honors the
modifieryou pass verbatim; it never forcesfillMaxSize()internally. For a full-bleed background passModifier.fillMaxSize(); for a bounded banner pass e.g.Modifier.fillMaxWidth().height(220.dp).
KWave is theme-free; you choose colors through WaveColors, built only through its factories.
Simple two-color gradient. Back layers lean toward top, front layers toward bottom:
import androidx.compose.ui.graphics.Color
import red.rankorr.kwave.WaveColors
import red.rankorr.kwave.WaveConfig
val ocean = WaveConfig.generate(
waveCount = 3,
colors = WaveColors.gradient(top = Color(0xFF1565C0), bottom = Color(0xFF0D1B2A)),
)
KWave(config = ocean, modifier = Modifier.fillMaxSize())Rainbow palette. The rainbow rides the wave fills: each layer is tinted by sampling the palette at its depth, so every layer carries a distinct hue. The background is not the full saturated palette; it is a muted two-stop wash darkened from the palette extremes, so the colorful waves stay the subject rather than competing with a loud sky:
val rainbow = WaveConfig.generate(
waveCount = 5,
colors = WaveColors.palette(
listOf(
Color(0xFFFF5252),
Color(0xFFFFB300),
Color(0xFF66BB6A),
Color(0xFF29B6F6),
Color(0xFFAB47BC),
),
),
)A single flat color is available too. So the same-color waves do not vanish into a same-color
background, solid() ramps the per-layer fill by depth (slightly darker at the back, lighter at
the front); auto per-layer alpha adds further separation on top:
val flat = WaveColors.solid(Color(0xFF263238))An empty
palette([])falls back to a neutral color, andpalette(listOf(c))behaves likesolid(c). Agradient(top, bottom)whose two colors are equal also routes throughsolid(), so a monochrome gradient stays visible the same way.
Decoupling the background from the waves. Each factory builds a coherent scene where the
backdrop and the wave palette derive from the same colors. When you want them independent, chain
withBackground(...): it replaces only the background, leaving the wave fills and highlight
untouched:
// Rainbow waves over a custom near-black sky.
val custom = WaveColors.palette(rainbow).withBackground(Color(0xFF101820))
// Waves only, no background at all: KWave sits on top of your own content
// (an image, another composable). The renderer skips the background pass entirely.
val wavesOnly = WaveColors.gradient(Color(0xFF1565C0), Color(0xFF0D47A1))
.withBackground(Color.Transparent)withBackground also accepts (top, bottom) for a gradient backdrop or a List<Color> for
multi-stop skies.
ShadowMode controls the diffuse cast shadow each wave projects on the content behind it — the
soft elevation that separates the layers. The default, Auto, adapts per layer to the local wave
color so one mode looks correct over light and dark palettes alike. (The crest light is part of
each wave's fill gradient, tinted by WaveColors.highlight, and is not affected by ShadowMode.)
import red.rankorr.kwave.ShadowMode
// Default: per-layer black/white by luminance (light wave -> dark shadow, dark wave -> back-glow).
WaveConfig.generate(colors = ocean.colors, shadow = ShadowMode.Auto)
// Shadow = the layer's own color darkened (stays in the palette's hue family).
WaveConfig.generate(colors = ocean.colors, shadow = ShadowMode.FromWave)
// Fills only: no cast shadow.
WaveConfig.generate(colors = ocean.colors, shadow = ShadowMode.None)
// Explicit color + alpha (coerced into [0, 1]) for every layer. The alpha drives the rendered
// shadow's total peak opacity (here a softer 0.3).
WaveConfig.generate(colors = ocean.colors, shadow = ShadowMode.Custom(Color.Black, alpha = 0.3f))WaveConfig.generate builds a coherent stack without hand-tuning each layer:
val config = WaveConfig.generate(
waveCount = 4, // number of layers, coerced to >= 1
crests = 1.5f, // relative crest density per layer (1 = baseline; higher = more, tighter)
harmonic = 0.25f, // crest roughness; 0 = clean rounded sine, higher = choppier/less regular
spacing = 1f, // vertical spread of the layers; < 1 overlaps them more, > 1 separates
amplitude = 0.04f, // base peak displacement as a fraction of height
variation = 0.4f, // per-layer pseudo-random jitter in [0, 1]; 0 = smooth/uniform
colors = ocean.colors,
shadow = ShadowMode.Auto,
gradientEnd = 0.78f, // vertical fraction at which the background gradient ends
// seed = 0, // advanced; leave at 0 unless you need a reproducible re-roll (see below)
)The generator auto-distributes each layer's horizontal phase offset, auto-assigns depth-based
alpha (back transparent → front opaque), adds per-layer breathing and sway, and samples each
layer's tint from colors. On top of the smooth back→front gradient, every per-layer property gets
a deterministic, seeded pseudo-random jitter scaled by variation, so the layers undulate out of
sync instead of moving as one rigid block. crests and harmonic together shape the crests:
crests is a relative density (1 = baseline, higher packs more and tighter crests) rather than
a literal crest count. Its twin, harmonic, is the crest roughness (0 is a clean rounded sine,
higher mixes in more of the second harmonic for choppier, less regular crests). spacing controls
how much the layers overlap vertically: a smaller value bunches them together, a larger one
separates them. gradientEnd sets where the background gradient ends, so you no longer need to
rebuild a second WaveConfig just to tune it. sway (also available directly on WaveConfig)
weights the slow side-to-side lean of the breathing crests: 1 is the nominal organic roll, 0f
turns it off and restores the pure vertical breathing of 0.1.x.
seed(advanced). The jitter is a pure function ofseed, so the same arguments always yield the exact same configuration. Leaveseedat its default0unless you need a reproducible re-roll: a different layout, or pinning a screenshot test. It is the last parameter for that reason.
For full control, build the WaveConfig from your own immutable list of WaveLayerSpec. Every
value is coerced into a valid range at construction (a negative amplitude becomes 0, a
baseFrac above 1 is clamped, etc.), so invalid input can never reach the renderer.
import androidx.compose.ui.graphics.Color
import red.rankorr.kwave.WaveColors
import red.rankorr.kwave.WaveConfig
import red.rankorr.kwave.WaveLayerSpec
import kotlinx.collections.immutable.persistentListOf
val config = WaveConfig(
layers = persistentListOf(
// Back layer: pure sine (harmonic = 0), semi-transparent.
WaveLayerSpec(baseFrac = 0.45f, amplitude = 0.035f, speed = 0.7f, crests = 0.8f, harmonic = 0f),
// Front layer: a touch of 2nd-harmonic for a less regular crest, opaque.
WaveLayerSpec(baseFrac = 0.62f, amplitude = 0.030f, speed = 1.0f, crests = 0.9f, harmonic = 0.25f),
),
colors = WaveColors.gradient(Color(0xFF455A64), Color(0xFF263238)),
)WaveLayerSpec is a regular @Immutable class (no data class copy()), but exposes withTint
and withAlpha for the two most common targeted tweaks:
val tinted = layer.withTint(Color(0xFF80DEEA)).withAlpha(0.6f)A pure, deterministic function of (phase, time) with no internal animation state. Here phase is
the horizontal phase of every layer (constant for in-place motion, advanced slowly for drift, or a
value you drive for deliberate horizontal translation) and time advances the per-layer amplitude
breathing and crest sway. Drive it yourself for screenshot tests or to advance time however you
like:
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.withFrameNanos
@Composable
fun ControlledWave() {
val elapsed = remember { mutableFloatStateOf(0f) }
LaunchedEffect(Unit) {
var last = 0L
while (true) withFrameNanos { now ->
if (last != 0L) elapsed.floatValue += (now - last) / 1_000_000_000f
last = now
}
}
KWave(
config = WaveConfig.Default,
// Hold phase constant (or advance it slowly for drift); `time` drives breathing + sway.
phase = 0f,
time = elapsed.floatValue,
modifier = Modifier.fillMaxSize(),
)
}The ambient drift is very slow (a full phase cycle takes about two minutes). When you want a
deliberate horizontal translation (e.g. a hero that follows a pager), feed that signal through
phaseShift. The drop-in KWave reads it on every frame, so a pager offset or scroll position
flows straight into the wave's horizontal phase without restarting the loop:
val pagerState = rememberPagerState { pageCount }
KWave(
modifier = Modifier.fillMaxSize(),
// Each page deliberately nudges the waves sideways; the ambient motion keeps running underneath.
phaseShift = (pagerState.currentPage + pagerState.currentPageOffsetFraction) * 0.5f,
)Other knobs on the drop-in overload:
speed sets the breathing/sway-tempo multiplier (how fast the layers move). Default 1.phaseShift is a live external phase signal for deliberate horizontal translation. Default 0.isPlaying = false freezes the animation on the current frame and fully suspends the internal
loop (zero frames, zero rendering work while frozen).respectReducedMotion (default true): when the system reduce-motion setting is on, KWave
renders a single static frame instead of starting the loop.drift sets the ambient horizontal travel in radians of phase per second, parallaxed per layer.
Default 0.05 (a full phase cycle ≈ 2 minutes); 0f removes the travel. Breathing layers still
sway gently — set WaveConfig.sway = 0f too for the strict in-place breathing of 0.1.x.maxFps caps how often the animation updates; <= 0 (default) updates on every display frame.
The motion is slow, so 24–30 is usually indistinguishable from the device rate and saves
battery, especially on 120 Hz displays.speed and drift are integrated per frame, so you can change them live (a slider, an animated
transition) and the motion changes tempo smoothly instead of jumping.
The drop-in overload is lifecycle-aware (it pauses below STARTED and resumes without a time jump)
and randomizes its initial phase per instance, so several KWaves on one screen do not breathe in
lockstep. All frame-driven state is read in the draw phase, so the animation re-draws without ever
recomposing, and the time integrators keep frame-level precision even after days on screen.
A Compose Desktop sample app doubles as a live visual test harness. It has sliders for waveCount,
crests, harmonic (a "Roughness" slider next to "Crests"), spacing, amplitude, variation,
speed, gradientEnd, a "Randomize layout" button that bumps the seed, plus a shadow-mode
selector and a gradient/rainbow color switch:
./gradlew :sample:runThe sample is not published.
Planned for a future 1.x release (not yet implemented):
Contributions are welcome. See CONTRIBUTING.md for the build, test, detekt, and
apiCheck workflow. The public API surface is tracked by the binary-compatibility-validator; any
intentional change to it must update the committed api/ dump (./gradlew apiDump).
Copyright 2026 Jessy Bonnotte (Shyzkanza)
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.
See LICENSE for the full text.