Skip to content

Shyzkanza/KWave

KWave

Build Maven Central License Kotlin Platforms

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.

KWave animated wave background

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.


Platforms

Android iOS JVM / Desktop

iOS is shipped as iosArm64 + iosSimulatorArm64. The JVM target powers a Compose Desktop sample and the fast unit tests.


Installation

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.

Version catalog (gradle/libs.versions.toml)

[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)
        }
    }
}

Plain Gradle

// 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 your dependencyResolutionManagement while KWave is pre-release; tagged releases resolve straight from mavenCentral().


Quick start

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 modifier you pass verbatim; it never forces fillMaxSize() internally. For a full-bleed background pass Modifier.fillMaxSize(); for a bounded banner pass e.g. Modifier.fillMaxWidth().height(220.dp).


Customization

Colors

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, and palette(listOf(c)) behaves like solid(c). A gradient(top, bottom) whose two colors are equal also routes through solid(), 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.

Shadow modes

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))

waveCount / crests / harmonic / spacing / variation / gradientEnd

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 of seed, so the same arguments always yield the exact same configuration. Leave seed at its default 0 unless you need a reproducible re-roll: a different layout, or pinning a screenshot test. It is the last parameter for that reason.


Advanced

Low-level layers: WaveLayerSpec

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)

Stateless overload: KWave(config, phase, time)

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(),
    )
}

Deliberate horizontal translation: pager / scroll

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 2430 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.


Sample

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:run

The sample is not published.


Roadmap

Planned for a future 1.x release (not yet implemented):

  • Vertical flip / top-anchor. Today the fill is always bottom-anchored (waves rise from the bottom of the canvas). A planned option will let the waves anchor to the top of the canvas and fill upward, for headers and inverted hero layouts.

Contributing

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).


License

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.

About

No description, website, or topics provided.

Resources

License

Code of conduct

Contributing

Security policy

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages