Back to Blog

Kotlin Desktop App from Scratch: Compose + Material3 | Lesson 01

Sandy LaneSandy Lane

Video: Kotlin Desktop App from Scratch: Compose + Material3 | Lesson 01 by Taught by Celeste AI - AI Coding Coach

Take the quiz on the full lesson page
Test what you've read · interactive walkthrough

Your First Kotlin Desktop App with Compose Multiplatform

Stack: Kotlin 2.1, Compose Multiplatform 1.7.3, Material 3, JVM 17+. A native desktop window driven by 35 lines of Kotlin. No Electron. No JavaFX. No FXML.

JetBrains' Compose Multiplatform takes the Compose UI toolkit Android developers know and runs it on the desktop JVM. You write @Composable functions; they render in a native window. The bundle size is reasonable, hot reload is fast, and the code is the same code you would write for an Android app.

This is Lesson 1 of Kotlin Desktop. By the end you will have a clickable counter on screen, plus the entire project skeleton everything else in the series builds on.

Prerequisites

You need:

  • JDK 17 or newerjava --version to check.
  • Gradle 8.x — usually shipped with the wrapper script you'll generate; nothing to install globally.
  • A Kotlin-aware editor — IntelliJ IDEA Community is the path of least resistance, but VS Code with the Kotlin extension or Neovim with nvim-jdtls works.

Project structure

A minimal Compose Desktop project has three files plus a wrapper:

demo-app/
├── settings.gradle.kts
├── build.gradle.kts
└── src/main/kotlin/
    └── Main.kt

That's it. Compose Desktop ships everything else (Material 3, the rendering engine, the window glue) as Maven dependencies; you don't carry any extra code.

settings.gradle.kts

rootProject.name = "demo-app"

One line. Gives the project a name that the Gradle build system can refer to.

build.gradle.kts

plugins {
  kotlin("jvm") version "2.1.0"
  id("org.jetbrains.compose") version "1.7.3"
  id("org.jetbrains.kotlin.plugin.compose") version "2.1.0"
}

repositories {
  mavenCentral()
  maven("https://maven.pkg.jetbrains.space/public/p/compose/dev")
  google()
}

dependencies {
  implementation(compose.desktop.currentOs)
  implementation(compose.material3)
}

compose.desktop {
  application {
    mainClass = "MainKt"
  }
}

Three plugins do the work.

kotlin("jvm") is the standard Kotlin Gradle plugin. It compiles .kt files to JVM bytecode.

org.jetbrains.compose is the Compose Desktop plugin. It pulls in the Compose runtime and provides the compose.desktop configuration block.

org.jetbrains.kotlin.plugin.compose is the Compose compiler plugin — it transforms @Composable functions at compile time into the reactive recomposition primitives the runtime needs. Without this plugin, your @Composable annotations are ignored and the app fails to build.

The dependencies are minimal: compose.desktop.currentOs for the platform-specific renderer, compose.material3 for the Material widgets.

mainClass = "MainKt" tells the runner which class has the main function. Kotlin generates MainKt from any top-level main in Main.kt.

Main.kt

import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application

@Composable
fun App() {
  var count by remember { mutableStateOf(0) }

  MaterialTheme(colorScheme = darkColorScheme()) {
    Surface(
      modifier = Modifier.fillMaxSize(),
      color = MaterialTheme.colorScheme.background
    ) {
      Column(
        modifier = Modifier.fillMaxSize(),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center
      ) {
        Text(
          text = "Hello, Compose Desktop!",
          fontSize = 28.sp,
          color = MaterialTheme.colorScheme.primary
        )
        Spacer(modifier = Modifier.height(16.dp))
        Text(
          text = "You clicked $count times",
          fontSize = 20.sp,
          color = MaterialTheme.colorScheme.onBackground
        )
        Spacer(modifier = Modifier.height(24.dp))
        Button(onClick = { count++ }) {
          Text("Click me!", fontSize = 18.sp)
        }
      }
    }
  }
}

fun main() = application {
  Window(
    onCloseRequest = ::exitApplication,
    title = "Hello Compose"
  ) {
    App()
  }
}

Three pieces. Let's walk them.

fun main() = application

fun main() = application {
  Window(onCloseRequest = ::exitApplication, title = "Hello Compose") {
    App()
  }
}

application is a top-level function from androidx.compose.ui.window. It opens the Compose Desktop event loop. Inside the lambda you create one or more Windows.

Window(...) creates a top-level OS window. onCloseRequest = ::exitApplication wires the close button to terminate the process. title is the OS window title. The trailing lambda is the window's content — a @Composable block that fills the window.

@Composable fun App()

Composable functions are the unit of UI in Compose. Annotated with @Composable, they describe what should be on screen given the current state. The framework runs them, captures the description, paints pixels, and re-runs them whenever observed state changes.

var count by remember { mutableStateOf(0) }

Two new things: remember and mutableStateOf.

mutableStateOf(0) creates a state holder — a special wrapper around a value that Compose tracks. When the value changes, any Composable that read it gets re-invoked.

remember { ... } survives recomposition. Without it, mutableStateOf(0) would be re-created on every recomposition and the counter would always be 0. With it, Compose remembers the same state holder across recompositions of the enclosing function.

The by keyword is Kotlin property delegation — count becomes a var you can read and write directly (no count.value needed). The delegate translates reads/writes into calls on the underlying MutableState<Int>.

In short: this line creates a counter that survives recomposition and lets you mutate it as if it were a normal var.

MaterialTheme + Surface + Column

MaterialTheme(colorScheme = darkColorScheme()) {
  Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
    Column(
      modifier = Modifier.fillMaxSize(),
      horizontalAlignment = Alignment.CenterHorizontally,
      verticalArrangement = Arrangement.Center
    ) {
      // children
    }
  }
}

Three layers.

MaterialTheme(colorScheme = darkColorScheme()) provides Material 3 theming for everything inside. Children can read MaterialTheme.colorScheme.primary etc. and get themed colours.

Surface is a Material container with a background colour. fillMaxSize() makes it cover the whole window.

Column lays children out vertically. horizontalAlignment = CenterHorizontally and verticalArrangement = Center centre the children both ways. Plain CSS-style layout, but in Kotlin.

Children

Text("Hello, Compose Desktop!", fontSize = 28.sp, color = MaterialTheme.colorScheme.primary)
Spacer(modifier = Modifier.height(16.dp))
Text("You clicked $count times", fontSize = 20.sp, ...)
Spacer(modifier = Modifier.height(24.dp))
Button(onClick = { count++ }) {
  Text("Click me!", fontSize = 18.sp)
}

Three Composables: Text, Spacer, Button. The interesting one is Button — clicking it runs count++. Because count is a state holder, the increment triggers Compose to recompose App(), which re-runs the Text lines, and the new value renders.

That's the entire reactive loop. State change → recomposition → new UI.

sp vs dp

Two unit suffixes appear repeatedly:

  • dp (density-independent pixels) for sizes, padding, layout. 16.dp is the same physical size on a 4K display as on a 1080p display.
  • sp (scale-independent pixels) for text. Like dp but also respects the user's font-size preference (especially relevant on Android, less so on desktop).

Convention: .sp for fontSize, .dp for everything else.

Modifier

Modifier is Compose's universal way to configure layout, sizing, padding, gestures, semantics, accessibility. Modifiers chain:

Modifier.fillMaxSize().padding(16.dp).background(Color.Red)

Each call returns a new Modifier. Order matters: padding(16).background(Red) paints the background outside the padding; background(Red).padding(16) paints it inside. Once you've internalised the chain order, modifiers are the most powerful tool in Compose.

Running it

gradle wrapper             # one-time, creates ./gradlew
./gradlew run

First run downloads dependencies (~30 seconds). After that, launches in 2-3 seconds. A native window appears with the heading, count, and button. Click — count increments.

For a packaged native binary:

./gradlew packageDmg       # macOS
./gradlew packageMsi       # Windows
./gradlew packageDeb       # Linux

The output goes to build/compose/binaries/. Ship that to users.

Common mistakes

Forgetting the Compose compiler plugin. Without id("org.jetbrains.kotlin.plugin.compose") in plugins, @Composable annotations are ignored and you get cryptic runtime errors.

Using mutableStateOf without remember. The state resets on every recomposition. Always wrap in remember (or use a hoisted state owner).

Mixing dp and sp arbitrarily. Use sp for fontSize, dp for everything else.

Putting heavy logic in the @Composable body. Composables run on every recomposition — many times per second. Expensive computations belong in remember(key) { ... } or in a LaunchedEffect.

Calling Window outside application { }. Window only works inside the application lambda — it relies on the runtime that application sets up.

What's next

Lesson 2: layouts and styling. Build a profile card with Card, Column, Row, custom shapes, and a Material 3 colour palette. The same Hello-Compose runtime, applied to a richer UI.

Recap

Three Gradle plugins (kotlin-jvm, compose, compose-compiler), two dependencies (compose.desktop.currentOs, compose.material3), one Main.kt with application { Window { App() } }. State via remember { mutableStateOf(...) }. Layouts via Column / Row. Sizes via Modifier.size(...). Theming via MaterialTheme.

Next lesson: layouts and styling. See you in the next one.

Ready? Take the quiz on the full lesson page →
Test what you've learned. Watch the lesson and try the interactive quiz on the same page.