Back to Blog

Custom Color Schemes, Typography & Shapes in Kotlin Desktop | Lesson 6

Sandy LaneSandy Lane

Video: Custom Color Schemes, Typography & Shapes in Kotlin Desktop | Lesson 6 by Taught by Celeste AI - AI Coding Coach

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

Custom Themes in Compose Desktop: Colors, Typography, Shapes

Three theming primitives — colorScheme, Typography, Shapes — wired into a single AppTheme Composable. The theme system that scales to any design.

The default MaterialTheme(colorScheme = darkColorScheme()) is fine for prototypes. For a real product, you want a brand palette, your own typography scale, and consistent corner shapes. Material 3 has all three as first-class concepts; you customise each, wrap them in your own AppTheme, and the whole UI inherits.

This lesson builds a Theme Dashboard — light/dark toggle, primary/secondary/tertiary colour cards, typography samples, and Material 3 buttons — all reading from a centralised theme.

What we are building

A 900×700 window with:

  • A title and a sun/moon icon button (light/dark toggle).
  • Three colour cards labeled Primary, Secondary, Tertiary, filled with the theme's accent colours.
  • A typography card showing samples at multiple type-scale levels.
  • A row of three button styles (filled, outlined, text).

Toggle dark/light — every widget switches palettes simultaneously.

Project layout

src/main/kotlin/
├── Main.kt           # window + theme toggle state
├── AppTheme.kt       # the wrapper Composable
├── Colors.kt         # palette + colorScheme objects
├── Typography.kt     # type scale
├── Shapes.kt         # corner radius scale
└── Dashboard.kt      # the demo UI

Six files. Each does one thing. The pattern survives unchanged at any project size.

Colors.kt: palette and schemes

val Purple80 = Color(0xFFCFBCFF)
val PurpleGrey80 = Color(0xFFCCC2DC)
val Pink80 = Color(0xFFEFB8C8)
val Purple40 = Color(0xFF6650A4)
val PurpleGrey40 = Color(0xFF625B71)
val Pink40 = Color(0xFF7D5260)

val DarkBackground = Color(0xFF1C1B1F)
val DarkSurface = Color(0xFF2B2930)
val LightBackground = Color(0xFFFFFBFE)
val LightSurface = Color(0xFFF3EDF7)

val DarkColorScheme = darkColorScheme(
  primary = Purple80,
  secondary = PurpleGrey80,
  tertiary = Pink80,
  background = DarkBackground,
  surface = DarkSurface,
)

val LightColorScheme = lightColorScheme(
  primary = Purple40,
  secondary = PurpleGrey40,
  tertiary = Pink40,
  background = LightBackground,
  surface = LightSurface,
)

Two layers. The named colours (Purple80, etc.) are the raw palette. The DarkColorScheme and LightColorScheme objects pour those colours into Material 3's named slots — primary, secondary, tertiary, background, surface.

Material 3 has many more colour slots (onPrimary, surfaceVariant, error, etc.) — darkColorScheme() and lightColorScheme() provide sensible defaults for slots you don't set. You override only the few that matter for your brand.

The "80/40" naming convention follows Material's tonal palette: 80 is a light tone (good as a foreground accent on dark backgrounds), 40 is a dark tone (good as a foreground accent on light backgrounds). You usually pair Color80 with the dark scheme and Color40 with the light scheme.

Typography.kt: the type scale

val AppTypography = Typography(
  headlineLarge = TextStyle(
    fontFamily = FontFamily.SansSerif,
    fontWeight = FontWeight.Bold,
    fontSize = 32.sp,
  ),
  titleMedium = TextStyle(
    fontFamily = FontFamily.SansSerif,
    fontWeight = FontWeight.SemiBold,
    fontSize = 18.sp,
  ),
  bodyLarge = TextStyle(
    fontFamily = FontFamily.SansSerif,
    fontWeight = FontWeight.Normal,
    fontSize = 16.sp,
    lineHeight = 24.sp,
  ),
  labelMedium = TextStyle(
    fontFamily = FontFamily.Monospace,
    fontWeight = FontWeight.Medium,
    fontSize = 14.sp,
  ),
)

Material 3 has 15 named text styles: displayLarge, displayMedium, displaySmall, headlineLarge/Medium/Small, titleLarge/Medium/Small, bodyLarge/Medium/Small, labelLarge/Medium/Small. We override four; the rest get framework defaults.

Anywhere in the app, Text("...", style = MaterialTheme.typography.titleMedium) pulls the configured style. Change titleMedium here, every "title" in the app updates.

For brand fonts (custom OTF/TTF), load via Font resources and pass to FontFamily(). We use the JVM's bundled SansSerif and Monospace for simplicity.

Shapes.kt: corner radius

val AppShapes = Shapes(
  small = RoundedCornerShape(8.dp),
  medium = RoundedCornerShape(16.dp),
  large = RoundedCornerShape(24.dp),
)

Three sizes. Cards default to medium; chips and small buttons default to small; bottom sheets and dialogs default to large. Material widgets honour the configured shape automatically.

For more elaborate shapes, look at CutCornerShape, GenericShape, or build your own.

AppTheme.kt: the wrapper

@Composable
fun AppTheme(
  darkTheme: Boolean = true,
  content: @Composable () -> Unit,
) {
  val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme

  MaterialTheme(
    colorScheme = colorScheme,
    typography = AppTypography,
    shapes = AppShapes,
    content = content,
  )
}

A thin wrapper that picks the colour scheme based on a darkTheme flag and wires the three theme primitives into MaterialTheme.

Anywhere in the app you write AppTheme(darkTheme = ...) { ... } and your content gets the full theme. Switching themes is one parameter change.

Main.kt: theme toggle

fun main() = application {
  val darkTheme = remember { mutableStateOf(true) }

  Window(
    onCloseRequest = ::exitApplication,
    title = "Theme Dashboard",
    state = rememberWindowState(width = 900.dp, height = 700.dp),
  ) {
    AppTheme(darkTheme = darkTheme.value) {
      Surface(modifier = Modifier.fillMaxSize()) {
        Dashboard(
          darkTheme = darkTheme.value,
          onToggleTheme = { darkTheme.value = !darkTheme.value },
        )
      }
    }
  }
}

The theme flag lives at the top level, which means swapping themes recomposes everything — exactly what you want. rememberWindowState(width, height) sets the initial window size.

Dashboard.kt: reading the theme

@Composable
fun Dashboard(darkTheme: Boolean, onToggleTheme: () -> Unit) {
  Column(
    modifier = Modifier.fillMaxSize().padding(24.dp),
    verticalArrangement = Arrangement.spacedBy(20.dp),
  ) {
    Row(
      modifier = Modifier.fillMaxWidth(),
      horizontalArrangement = Arrangement.SpaceBetween,
      verticalAlignment = Alignment.CenterVertically,
    ) {
      Text("Theme Dashboard", style = MaterialTheme.typography.headlineLarge)
      IconButton(onClick = onToggleTheme) {
        Icon(
          if (darkTheme) Icons.Default.LightMode else Icons.Default.DarkMode,
          contentDescription = "Toggle theme",
        )
      }
    }

    Row(
      modifier = Modifier.fillMaxWidth(),
      horizontalArrangement = Arrangement.spacedBy(16.dp),
    ) {
      ThemeCard("Primary", MaterialTheme.colorScheme.primary, Modifier.weight(1f))
      ThemeCard("Secondary", MaterialTheme.colorScheme.secondary, Modifier.weight(1f))
      ThemeCard("Tertiary", MaterialTheme.colorScheme.tertiary, Modifier.weight(1f))
    }

    Card(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.medium) {
      Column(
        modifier = Modifier.padding(20.dp),
        verticalArrangement = Arrangement.spacedBy(12.dp),
      ) {
        Text("Typography Samples", style = MaterialTheme.typography.titleMedium)
        HorizontalDivider()
        Text("Headline Large", style = MaterialTheme.typography.headlineLarge)
        Text("Body text shows the default reading style.", style = MaterialTheme.typography.bodyLarge)
        Text("monospace label for code", style = MaterialTheme.typography.labelMedium)
      }
    }

    Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
      Button(onClick = {}, shape = MaterialTheme.shapes.small) { Text("Primary") }
      OutlinedButton(onClick = {}, shape = MaterialTheme.shapes.small) { Text("Outlined") }
      TextButton(onClick = {}) { Text("Text") }
    }
  }
}

Notice every styling reference is MaterialTheme.something — never a hardcoded colour or size. That's the discipline.

Modifier.weight(1f) on each ThemeCard distributes available row width equally among the three. Convenient for "spread these N children evenly" layouts.

Arrangement.spacedBy(16.dp) on a Row or Column adds 16dp between adjacent children. Cleaner than putting Spacer between every pair.

ThemeCard sub-Composable

@Composable
fun ThemeCard(label: String, color: Color, modifier: Modifier) {
  Card(
    modifier = modifier,
    shape = MaterialTheme.shapes.medium,
    colors = CardDefaults.cardColors(containerColor = color),
  ) {
    Text(
      label,
      modifier = Modifier.padding(20.dp),
      style = MaterialTheme.typography.titleMedium,
      color = Color.White,
    )
  }
}

The card accepts a Modifier so the parent can supply Modifier.weight(1f). The colors parameter overrides the card's default container with the demo colour. The label uses the typography scale.

Toggling themes

The icon switches based on the current state — sun when in dark mode (so you can switch to light), moon when in light mode (so you can switch to dark). Standard convention.

onToggleTheme is wired to darkTheme.value = !darkTheme.value. State change triggers recomposition of the entire App tree; AppTheme re-evaluates with the new flag; every MaterialTheme.* reference re-reads with the new scheme.

Persisting the choice

This lesson doesn't persist the theme preference. To do it, combine with Lesson 5's persistence pattern:

data class AppConfig(val darkTheme: Boolean = true)

// load on startup, save when toggled

In Lesson 23 we'll formalise this.

Why theme through MaterialTheme.*

Could you put Purple80 in your widget code directly? Yes, and it would work — until you want to add a light theme. Then every hardcoded reference becomes a search-and-replace.

By going through MaterialTheme.colorScheme.primary, the value is resolved at runtime based on the active theme. One toggle changes everything.

Same logic applies to typography and shapes. Hardcoded fontSize = 18.sp survives until you want a "compact" mode; style = MaterialTheme.typography.titleMedium survives because you'd just adjust titleMedium in the typography object.

Common mistakes

Hardcoding colours instead of going through the theme. It works for the first theme; it breaks the moment you add a second.

Overriding too many of Material 3's colour slots. The defaults are carefully calibrated for accessibility and visual coherence. Override only the slots that need brand colours; leave the rest.

Forgetting content = content at the bottom of the wrapper. Without it, your AppTheme does nothing.

Using small/medium/large for everything in shapes. Three sizes are usually enough, but if you have a very specific corner radius you need (like 4dp or 32dp), define it explicitly and pass it inline rather than overloading the named tiers.

What's next

Lesson 7: dialogs and menus. AlertDialog, custom modal overlays, dropdown menus. Compose Desktop has both Material 3 dialogs and lower-level overlay primitives — we use both.

Recap

Three theming primitives: ColorScheme (via darkColorScheme/lightColorScheme), Typography, Shapes. Wrap them in your own AppTheme. Reference everywhere with MaterialTheme.colorScheme.*, MaterialTheme.typography.*, MaterialTheme.shapes.*. Toggle by re-rendering with a different flag.

Next lesson: dialogs and menus. 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.