Amol Pawar

ABI in Android

What Is ABI in Android? Why It Matters for APK Size & Performance

If you build Android apps, you’ve probably seen the term ABI in Android at least once. It shows up in Gradle settings, Play Console warnings, and NDK documentation.

But what does it actually mean? And why does it affect your APK size and app performance?

Let’s break it down.

What Is ABI in Android?

ABI stands for Application Binary Interface.

In simple words, ABI in Android defines how your app’s compiled native code interacts with the device’s processor and operating system.

Think of it as a contract between:

  • Your compiled native code (.so files)
  • The Android operating system
  • The device’s CPU architecture

If the ABI doesn’t match the device’s CPU, your app won’t run.

Why Does ABI Exist in Android?

Android devices use different CPU architectures. The most common ones are:

  • arm64-v8a (64-bit ARM)
  • armeabi-v7a (32-bit ARM)
  • x86
  • x86_64

Each architecture understands machine code differently. So if your app includes native C or C++ code using the Android NDK, you must compile it separately for each ABI you want to support.

That’s where ABI in Android becomes important.

What Exactly Does an ABI Define?

An ABI specifies:

  • CPU instruction set (ARM, x86, etc.)
  • Register usage
  • Memory alignment
  • Data type sizes
  • How function calls work
  • How binaries are formatted

When you compile native code, the compiler uses ABI rules to generate machine code that matches the target architecture.

If you build for arm64-v8a, the generated .so file won’t work on an x86 device.

What Is a Native Library in Android?

If your project uses C or C++ (via the NDK), it generates files like this:

libnative-lib.so

These are placed inside your APK under:

lib/arm64-v8a/
lib/armeabi-v7a/
lib/x86/

Each folder corresponds to a specific ABI in Android.

The system loads the correct library at runtime based on the device’s architecture.

Why ABI in Android Matters for APK Size

This is where many developers make mistakes.

If you include all ABIs in a single APK, your app contains multiple versions of the same native library.

For example:

  • 5 MB for arm64
  • 4 MB for armeabi-v7a
  • 6 MB for x86

Now your APK suddenly includes 15 MB of native code, even though a device only needs one version.

That increases:

  • Download size
  • Install time
  • Storage usage

Solution: Split APKs by ABI

You can configure Gradle to generate separate APKs per ABI.

Here’s an example:

Kotlin
// build.gradle (Module level)
android {
    splits {
        abi {
            enable true
            reset() // Clear the default list
            include "arm64-v8a", "armeabi-v7a", "x86", "x86_64"
            universalApk false // Don't generate a fat universal APK
        }
    }
}
  • enable true → Turns on ABI splitting
  • include → Specifies which ABIs to build
  • universalApk false → Prevents creating a large APK with all ABIs

Now each device downloads only the version it needs.

This reduces APK size significantly.

What About Android App Bundles (AAB)?

If you’re using Android App Bundles (which is required for Play Store apps), Google Play automatically delivers the correct native libraries per device.

This is called ABI split delivery.

In this case, you don’t need manual split configuration for Play Store distribution.

However, understanding ABI in Android still matters when:

  • Testing locally
  • Distributing outside Play Store
  • Debugging native crashes
  • Optimizing build size

How ABI in Android Affects Performance

Performance impact comes from two main areas:

1. 32-bit vs 64-bit

Modern devices use arm64-v8a. Running a 64-bit native library provides:

  • Better memory handling
  • More CPU registers
  • Improved performance for heavy computation
  • Better compatibility with modern Android versions

Google Play requires 64-bit support for apps using native code.

If you ship only 32-bit libraries, your app may run in compatibility mode on 64-bit devices. That’s not ideal.

2. CPU-Specific Optimization

When you compile for a specific ABI in Android, the compiler generates instructions optimized for that architecture.

Example:

  • ARM CPUs use ARM instruction sets
  • x86 devices use Intel instruction sets

Native code compiled for ARM won’t run efficiently on x86 without translation.

Better ABI targeting = better runtime performance.

How to Specify ABI in Android (NDK Example)

If you use CMake, you can define supported ABIs like this:

Kotlin
android {
    defaultConfig {
        ndk {
            abiFilters "arm64-v8a", "armeabi-v7a"
        }
    }
}
  • abiFilters restricts which ABIs are built
  • Prevents generating unnecessary .so files
  • Reduces build time
  • Reduces final APK size

Simple but powerful.

How to Check Device ABI

You can check the device ABI programmatically:

Kotlin
val abiList = Build.SUPPORTED_ABIS
for (abi in abiList) {
    Log.d("ABI_CHECK", abi)
}

This returns a list like:

arm64-v8a
armeabi-v7a

The first ABI is the preferred one.

Common Mistakes Developers Make

Including Too Many ABIs

If your analytics show 95% ARM users, shipping x86 may not be worth it unless you need emulator support.

Not Supporting 64-bit

Google Play requires 64-bit native support for apps using the NDK.

Using Universal APKs in Production

Universal APKs contain all ABIs. That’s convenient for testing, but inefficient for production distribution.

Best Practices for ABI in Android

Here’s a practical checklist:

  • Always support arm64-v8a
  • Include armeabi-v7a if you still support older devices
  • Avoid x86 unless required
  • Use Android App Bundles for Play distribution
  • Use abiFilters to control builds
  • Monitor APK size regularly
  • Test native crashes per architecture

When Should You Care About ABI in Android?

You should care if:

  • You use the Android NDK
  • You integrate native SDKs (camera, ML, gaming engines)
  • You want to reduce APK size
  • You optimize performance
  • You publish on Google Play

If your app is pure Kotlin or Java without native libraries, ABI doesn’t directly affect you.

Real-World Example

Let’s say you’re building a photo editing app with native image processing in C++.

If you:

  • Ship only 32-bit libraries
  • Include all ABIs in one APK
  • Don’t optimize build filters

Your APK may grow from 25 MB to 60 MB.

But if you:

  • Use ABI splits
  • Support only ARM architectures
  • Use App Bundles

You can reduce the download size significantly while improving performance on modern devices.

That’s the practical impact of understanding ABI in Android.

Conclusion

ABI in Android is not just a technical term buried in documentation. It directly affects:

  • APK size
  • App performance
  • Play Store compliance
  • User experience

If your app includes native code, ABI decisions matter a lot.

Keep your builds clean. Support 64-bit. Ship only what users need.

And most importantly, understand what’s inside your APK.

That’s how you build lean, fast, production-ready Android apps.

sitLess App

sitLess App: A Smart Ergonomic Sitting Reminder for Better Focus, Posture, and Productivity

Most of us sit longer than we should.

Work meetings. Coding sessions. Online classes. Gaming. Six to ten hours pass before we even notice. Over time, that constant sitting affects posture, drains energy, and increases physical strain.

sitLess is built to solve exactly that.

It’s a simple Android app that gives you timed reminders to stand up, stretch, and reset. No clutter. No complicated dashboards. Just smart reminders that help you protect your body while staying productive.

sitLess helps you reduce sedentary time by sending automated break reminders throughout your workday. It’s simple, focused, and designed to improve both posture and productivity.

If you spend most of your day at a desk, this app can make a real difference.

Why Prolonged Sitting Is a Problem

Prolonged sitting affects far more than your comfort.

Research consistently links extended sitting with:

  • Lower back pain
  • Neck and shoulder stiffness
  • Poor posture
  • Reduced blood circulation
  • Mental fatigue
  • Decreased productivity

Even if you exercise regularly, long, uninterrupted sitting sessions can still negatively impact your body.

That’s why experts recommend standing or moving every 30 to 60 minutes.

The challenge? Most people forget.

A break reminder app solves this by building consistent movement directly into your daily routine.

What Is sitLess?

sitLess is a lightweight sitting reminder app for Android designed to help users develop healthier desk habits.

Instead of relying on memory, the app creates an automatic cycle:

Sit → Break → Repeat

Once activated, sitLess sends reminders at your chosen intervals, helping you move consistently throughout the day without disrupting your workflow.

Unlike complex productivity tools, sitLess focuses on one goal: reducing sedentary time in a simple, sustainable way.

How sitLess Supports Ergonomic Health

Good ergonomics isn’t just about buying a better chair. Movement plays a critical role in protecting your body during long desk sessions.

Here’s how sitLess supports healthier desk habits:

1. Helps Reduce Back Pain from Sitting

Lower back discomfort is one of the most common issues among desk workers.

By prompting you to stand and reset your posture regularly, sitLess helps reduce prolonged pressure on the spine. Over time, these small adjustments can minimize strain buildup.

2. Encourages Better Posture

Extended sitting often leads to rounded shoulders and a forward-leaning neck.

Break reminders interrupt that static position, giving you the opportunity to realign your posture before returning to work. Consistency makes the difference.

3. Improves Circulation and Energy

Sitting slows blood flow, particularly in the legs.

Short movement breaks stimulate circulation and help maintain steady energy levels, reducing the likelihood of the afternoon slump.

4. Enhances Focus and Productivity

It may seem counterintuitive, but short breaks can significantly boost productivity.

When you move regularly:

  • Your brain resets
  • Eye strain decreases
  • Mental clarity improves
  • Decision-making sharpens

This structured break pattern is similar to the Pomodoro Technique, which uses timed intervals to sustain focus and performance.

The difference is that sitLess prioritizes physical health and ergonomics — not just time management.

As a result, a sitting reminder app becomes more than a productivity system. It serves as both a wellness tool and a performance enhancer.

Key Features of sitLess

sitLess keeps things simple while giving you full control over your routine.

Custom Sitting Timer

Choose how long you want to sit before receiving a reminder.

Whether you prefer 25-minute focus sessions or 45-minute deep work blocks, the app adapts to your schedule.

Custom Break Duration

Breaks aren’t one-size-fits-all.

Some days call for a quick stretch, while others may require a longer walk. sitLess lets you set break durations that fit your needs.

Automatic Repeat Cycle

Once started, the sit–break cycle runs automatically.

There’s no need to manually restart timers, making consistency effortless.

Clean, Minimal Interface

Many productivity apps overwhelm users with unnecessary features.

sitLess offers a clean, distraction-free interface focused solely on break reminders, making it easy to use every day.

Who Should Use a Sitting Reminder App?

sitLess is ideal for:

  • Office workers
  • Remote employees
  • Students
  • Developers
  • Designers
  • Writers
  • Gamers
  • Anyone with a sedentary lifestyle

If you sit for more than six hours a day, structured movement can significantly improve both comfort and focus.

How to Use sitLess for Best Results

To maximize benefits:

  • Set your sitting timer to 30 minutes
  • Choose a 5-minute break
  • Stand fully during breaks
  • Stretch your back and shoulders
  • Walk when possible
  • Repeat throughout your workday.

Use this schedule as a starting point and adjust it to fit your workflow.

The goal isn’t perfection — it’s consistency. Even short, regular breaks can make long desk hours more manageable and sustainable.

Why sitLess Is Different from Other Break Reminder Apps

Many apps combine task tracking, analytics, and habit streaks.

However, sitLess stays focused.

It is not overloaded with features. Instead, it concentrates on reducing sedentary time and supporting ergonomic health.

That simplicity increases long-term consistency.

Frequently Asked Questions

How often should I take breaks from sitting?

Most experts recommend standing or moving every 30 to 60 minutes.

Can a sitting reminder app improve productivity?

Yes. Short breaks help reset mental focus and reduce fatigue, leading to better work performance.

Does sitLess treat back pain?

sitLess is not a medical tool. However, regular movement can help reduce discomfort caused by prolonged sitting.

Is sitLess easy to use?

Yes. The app features a clean interface and simple timer customization.

Conclusion

If you work long hours at a desk, reducing sedentary time is essential.

sitLess makes that process simple.

By combining automated reminders, customizable timers, and ergonomic support, this sitting reminder app helps you protect your body while improving focus at work.

You don’t need a complicated system.

You just need consistent movement.

Download sitLess on Google Play

Debug Symbol File

What Is a Debug Symbol File and Why Does Google Play Console Require It for Android Apps?

If you’ve ever uploaded an Android App Bundle to the Google Play Console and seen a warning about a missing Debug Symbol File, you’re not alone.

A lot of developers hit this message and wonder:

  • What exactly is a Debug Symbol File?
  • Why does Google Play Console need it?
  • Is it mandatory?
  • How do I generate and upload it?

In this comprehensive guide, we’ll demystify debug symbol files, explain why Google Play Console needs them, and show you exactly how to generate and upload them. 

Let’s dive in..!

What Is a Debug Symbol File?

A Debug Symbol File is a file that maps compiled machine code back to readable source code.

When you build an Android app, your original code (Kotlin, Java, C++, etc.) gets compiled into low-level machine instructions. During this process, meaningful names like:

fun calculateTotalPrice(items: List<Item>): Double

may get stripped, optimized, or converted into memory addresses.

These processes make your code unreadable to humans — but they also make crash reports impossible to understand.

Without symbols, a crash report might look like this:

#00 pc 000000000004a123  /lib/arm64/libnative-lib.so
#01 pc 000000000003b789 /lib/arm64/libnative-lib.so

That tells you almost nothing.

With a Debug Symbol File, the same crash becomes:

#00 calculateTotalPrice() at PaymentProcessor.kt:42
#01 checkout() at CartManager.kt:88

Now you know exactly what went wrong and where.

That’s the difference.

Why Does Google Play Console Require a Debug Symbol File?

The Google Play Console asks for a Debug Symbol File when your app includes:

  • Native code (C or C++)
  • NDK libraries
  • Game engines (like Unity or Unreal)
  • Any .so native shared libraries

The Core Reason: Better Crash Reporting

Google Play collects crash data from real users. But without symbols, it can’t decode native crash stack traces.

When you upload a Debug Symbol File:

  • Google can deobfuscate native stack traces
  • Crash reports become human-readable
  • You can fix bugs faster
  • Your app stability improves

In short, it’s about observability and reliability.

Is It Mandatory?

Technically, your app can still be published without it.

But if you skip the Debug Symbol File:

  • Native crashes will be unreadable
  • You’ll lose valuable debugging insights
  • You’ll struggle to fix production issues

For any serious production app, it’s strongly recommended.

When Do You Actually Need It?

You need a Debug Symbol File if:

  • Your app uses Android NDK
  • You include native libraries (.so files)
  • You’re building games
  • You’re using certain SDKs that bundle native code

If your app is pure Kotlin or Java with no native layer, you usually won’t see this requirement.

What Happens Without a Debug Symbol File?

Let’s say your app crashes inside native code.

Without symbols:

Fatal signal 11 (SIGSEGV)
pc 00000000000af3b4

You have no idea which function caused it.

With symbols:

Segmentation fault in renderFrame()
File: Renderer.cpp
Line: 214

Now you can:

  • Reproduce the issue
  • Fix the exact line
  • Release a patch
  • Improve user ratings

This is why Google emphasizes it.

What Is Inside a Debug Symbol File?

A Debug Symbol File contains:

  • Function names
  • Variable names
  • Line numbers
  • Memory address mappings

For Android native apps, it usually includes:

  • .so files with symbols
  • Or a zipped folder generated from the NDK build

It does not expose your full source code publicly. It only helps map crash data.

How to Generate a Debug Symbol File (Step-by-Step)

If You’re Using Android Gradle with NDK

Add this to your build.gradle:

Kotlin
android {
    buildTypes {
        release {
            ndk {
                debugSymbolLevel 'FULL'
            }
        }
    }
}

Here,

  • debugSymbolLevel 'FULL' tells Gradle to keep full debug symbols.
  • During build, symbol files are generated.
  • They’re stored inside the build output directory.

After building your release bundle:

app/build/outputs/native-debug-symbols/release/native-debug-symbols.zip

You’ll find a .zip file.

That ZIP file is your Debug Symbol File.

Upload it to Google Play Console under:

App bundle explorer → Native debug symbols

Or during release upload if prompted.

Btw, what’s inside native-debug-symbols.zip?

It contains folders per ABI (Application Binary Interface), for example:

arm64-v8a/
armeabi-v7a/
x86/
x86_64/

Each folder contains your .so files with full debug symbols included.

Example:

arm64-v8a/libyourlibrary.so

If You’re Using CMake

Ensure you build with debug symbols enabled:

set(CMAKE_BUILD_TYPE Release)
set(CMAKE_CXX_FLAGS_RELEASE "-g")

The -g flag tells the compiler to include debug information.

Without it, symbols are stripped.

What About R8 or ProGuard?

That’s slightly different.

If your app uses code shrinking (R8/ProGuard), you also generate a mapping file:

mapping.txt

This is used to deobfuscate Java/Kotlin stack traces.

So in summary:

  • Debug Symbol File → Native crashes
  • mapping.txt → Obfuscated Java/Kotlin crashes

Both improve crash readability.

Security Concerns: Is It Safe to Upload?

Yes.

Google Play:

  • Uses the Debug Symbol File internally
  • Does not distribute it publicly
  • Only applies it to crash deobfuscation

It does not expose your source code to users.

How Debug Symbol Files Improve App Quality

Uploading a Debug Symbol File helps you:

  1. Diagnose production crashes accurately
  2. Reduce mean time to resolution (MTTR)
  3. Improve stability metrics
  4. Increase Play Store ratings
  5. Meet professional engineering standards

From a product perspective, better crash data equals better user retention.

Best Practices for Managing Debug Symbol Files

Here’s what experienced Android teams do:

  • Store symbol files in secure CI/CD artifacts
  • Version them alongside releases
  • Automate uploads during deployment
  • Keep backups for older versions

If you lose the symbol file for a release, you cannot retroactively decode its native crashes.

So treat it like an important build artifact.

Common Developer Questions

Does every Android app need a Debug Symbol File?

No. Only apps using native code (NDK or .so libraries).

Can I regenerate symbols later?

Only if you still have the exact same build artifacts and configuration. Otherwise, no.

Is this required for App Bundles?

Yes, especially when distributing .aab files with native components.

Quick Summary

A Debug Symbol File:

  • Maps machine code back to readable source code
  • Makes native crash reports understandable
  • Is required by Google Play Console for apps using native libraries
  • Helps you fix production crashes faster
  • Improves app quality and reliability

If your app includes NDK or .so files, uploading it is not optional if you care about debugging effectively.

Conclusion

The Debug Symbol File isn’t just a technical checkbox in Google Play Console.

It’s a practical tool that turns meaningless crash data into actionable insights.

If you’re building serious Android apps, especially with native components, treat symbol management as part of your release process. 

Automate it. Store it. Upload it.

BoxWithConstraints in Jetpack Compose

Mastering BoxWithConstraints in Jetpack Compose: Build Truly Responsive UIs

Modern Android apps run on phones, tablets, foldables, Chromebooks, and even desktop environments. If your layout only looks good on one screen size, users will notice.

That’s where BoxWithConstraints in Jetpack Compose becomes powerful.

In this guide, you’ll learn what it is, when to use it, how it works internally, and how to build truly responsive layouts with practical examples. 

What Is BoxWithConstraints in Jetpack Compose?

BoxWithConstraints in Jetpack Compose is a layout composable that gives you access to the size constraints of its parent during composition.

It lets you know how much space is available so you can change your UI dynamically.

Instead of guessing screen size or using hardcoded breakpoints, you can read:

  • maxWidth
  • maxHeight
  • minWidth
  • minHeight

And build your layout accordingly.

Why It Matters for Responsive UI

Responsive design is no longer optional.

Your app may run on:

  • Compact phones
  • Large tablets
  • Foldables in multi-window mode
  • Desktop mode

If you rely only on Modifier.fillMaxWidth() or fixed sizes, your UI may stretch or break.

BoxWithConstraints in Jetpack Compose helps you:

  • Adapt layout based on width
  • Switch between column and row layouts
  • Show or hide content conditionally
  • Change typography and spacing dynamically

This is real responsiveness, not just resizing.

How BoxWithConstraints Works

Here’s the basic structure:

Kotlin
@Composable
fun ResponsiveExample() {
    BoxWithConstraints {
        if (maxWidth < 600.dp) {
            Text("Compact Screen")
        } else {
            Text("Large Screen")
        }
    }
}

Inside BoxWithConstraints, you can directly access maxWidth.

The important thing to understand:

  • The values are Dp
  • They represent the constraints passed from the parent
  • The composable recomposes if constraints change

So your UI reacts automatically.

Example 1: Switching Between Column and Row Layout

This is a common real-world case.

On small screens → stack items vertically
On large screens → place items side by side

Kotlin
@Composable
fun ProfileSection() {
    BoxWithConstraints(
        modifier = Modifier.fillMaxSize()
    ) {
        val isCompact = maxWidth < 600.dp

    if (isCompact) {
            Column(
                modifier = Modifier.padding(16.dp)
            ) {
                Avatar()
                Spacer(modifier = Modifier.height(16.dp))
                UserInfo()
            }
        } else {
            Row(
                modifier = Modifier.padding(24.dp)
            ) {
                Avatar()
                Spacer(modifier = Modifier.width(24.dp))
                UserInfo()
            }
        }
    }
}
  • We check if width is less than 600dp.
  • If true → vertical layout.
  • If false → horizontal layout.
  • Spacing is adjusted accordingly.

This approach is clean and easy to scale.

Example 2: Dynamic Card Grid

Let’s build a responsive grid without using a predefined grid layout.

Kotlin
@Composable
fun ResponsiveGrid() {
    BoxWithConstraints(
        modifier = Modifier.fillMaxWidth()
    ) {
        val columns = when {
            maxWidth < 600.dp -> 1
            maxWidth < 840.dp -> 2
            else -> 3
        }

    LazyVerticalGrid(
            columns = GridCells.Fixed(columns),
            contentPadding = PaddingValues(16.dp)
        ) {
            items(20) { index ->
                Card(
                    modifier = Modifier
                        .padding(8.dp)
                        .fillMaxWidth()
                ) {
                    Text(
                        text = "Item $index",
                        modifier = Modifier.padding(16.dp)
                    )
                }
            }
        }
    }
}
  • We calculate column count dynamically.
  • Grid adapts to available width.
  • No device-specific logic.
  • No hardcoded “tablet mode.”

This is flexible and future-proof.

Accessing Constraints in Pixels

Sometimes you need pixel-level calculations.

Inside BoxWithConstraints in Jetpack Compose, you can convert Dp to pixels:

Kotlin
@Composable
fun WidthInPixelsExample() {
    BoxWithConstraints {
        val widthInPx = with(LocalDensity.current) {
            maxWidth.toPx()
        }

        Text("Width in pixels: $widthInPx")
    }
}

Use this carefully. Most of the time, Dp is enough.

When Should You Use BoxWithConstraints?

Use it when:

  • Layout changes based on available width
  • You need responsive breakpoints
  • Parent constraints matter for child composition
  • You’re building adaptive layouts

Avoid using it:

  • For simple static UI
  • When Modifier constraints are enough
  • When you only need screen size (use LocalConfiguration instead)

Think of BoxWithConstraints in Jetpack Compose as a precision tool, not a default choice.

Common Mistakes Developers Make

1. Confusing Screen Size with Available Space

BoxWithConstraints gives you parent constraints, not the entire screen size.

In split-screen mode, constraints may be smaller than the device width.

This is good. It makes your UI adaptive.

2. Overusing Nested BoxWithConstraints

Nesting multiple constraint readers increases complexity and recomposition cost.

Keep it simple.

3. Hardcoding Too Many Breakpoints

Instead of:

Kotlin
maxWidth < 400.dp
maxWidth < 500.dp
maxWidth < 600.dp

Stick to meaningful layout breakpoints like:

  • Compact
  • Medium
  • Expanded

This keeps logic maintainable.

Performance Considerations

Is BoxWithConstraints in Jetpack Compose expensive?

Not really. But:

  • It introduces recomposition when constraints change.
  • Complex logic inside it can slow composition.

Best practice:

Keep heavy calculations outside or memoize using remember.

Example:

Kotlin
val isCompact = remember(maxWidth) {
    maxWidth < 600.dp
}

This ensures efficient re-composition.

Real-World Pattern: Adaptive Master-Detail Layout

Classic example:

Phone → single column
Tablet → list + details side by side

Kotlin
@Composable
fun MasterDetailLayout() {
    BoxWithConstraints(
        modifier = Modifier.fillMaxSize()
    ) {
        val isTablet = maxWidth >= 840.dp

         if (isTablet) {
            Row {
                Box(modifier = Modifier.weight(1f)) {
                    MasterList()
                }
                Box(modifier = Modifier.weight(2f)) {
                    DetailPane()
                }
            }
        } else {
            MasterList()
        }
    }
}

This pattern is widely used in email apps, dashboards, and productivity tools.

Box vs BoxWithConstraints

You might wonder:

Why not just use Box?

Here’s the difference:

If you don’t need constraint info, stick with Box.

How It Aligns with Modern Android Best Practices

Google encourages:

  • Adaptive layouts
  • Multi-device support
  • Foldable readiness

BoxWithConstraints in Jetpack Compose supports all of this naturally.

It works well alongside:

  • Window size classes
  • Material 3 adaptive design
  • Large screen guidelines

You’re building future-ready UI when you use it correctly.

Quick FAQ

What is BoxWithConstraints in Jetpack Compose?

It is a layout composable that exposes parent layout constraints like maxWidth and maxHeight, allowing dynamic and responsive UI decisions during composition.

When should I use BoxWithConstraints?

Use it when your layout must change depending on available space, such as switching from column to row or adjusting grid columns.

Does BoxWithConstraints affect performance?

It can trigger recomposition when constraints change, but it is generally efficient when used correctly.

Is BoxWithConstraints better than LocalConfiguration?

They serve different purposes.

  • LocalConfiguration gives device configuration.
  • BoxWithConstraints gives parent layout constraints.

Conclusion

Mastering BoxWithConstraints in Jetpack Compose changes how you think about UI design.

Instead of designing for fixed screens, you design for available space.

That shift makes your apps:

  • More adaptive
  • More professional
  • More future-proof

Start simple. Add breakpoints thoughtfully. Test in multi-window mode. Resize your emulator. Observe how your UI behaves.

Responsive design is not about bigger screens. It’s about flexible thinking.

And BoxWithConstraints in Jetpack Compose is one of your best tools to make that happen.

Design Tokens

Design Tokens in Material Design 3 with Jetpack Compose

If you’ve been diving into modern Android development, you’ve probably heard the buzz about Material Design 3 (also known as Material You) and Jetpack Compose. Today, we’re going to explore one of the most powerful yet underappreciated features that ties them together: Design Tokens.

Understanding Design Tokens in Material 3 and Jetpack Compose will transform how you build consistent, beautiful, and maintainable Android apps. 

Let’s dive in..!

What Are Design Tokens?

Before we jump into the Material 3 specifics, let’s get on the same page about what design tokens actually are.

Think of design tokens as the DNA of your app’s design system. They’re named values that store design decisions like colors, typography, spacing, and shapes. Instead of hardcoding Color(0xFF6200EE) everywhere in your app, you’d use a token like MaterialTheme.colorScheme.primary.

Btw why this matters..?

Actually, when you decide to rebrand your app or support dark mode, you only need to change the token values in one place, not hunt down hundreds of hardcoded values scattered across your codebase.

Why Material Design 3 Changed Everything

Material Design 3 represents a massive evolution in how we think about design systems. Unlike Material Design 2, which had a more rigid structure, Material 3 introduces a flexible, personalized approach that adapts to user preferences.

Design Tokens in Material 3 and Jetpack Compose work together to make this personalization possible. Material 3 includes over 40 color tokens, dynamic color generation from wallpapers, and a comprehensive token system for typography, shapes, and elevation.

Understanding the Material Design 3 Token Structure

Material Design 3 organizes tokens into structured layers:

1. Reference Tokens

Raw values like colors or sizes.

Example:

  • Blue 500
  • 16sp
  • 8dp

2. System Tokens

Semantic values used by the UI system.

Example:

  • primary
  • onPrimary
  • surface

3. Component Tokens

Values applied to specific UI components.

Example:

  • Button container color
  • TextField label color

Jetpack Compose primarily exposes system tokens through MaterialTheme, which internally map to component behavior.

Material Design 3 in Jetpack Compose

Jetpack Compose provides the MaterialTheme composable (from the material3 library) that exposes design tokens:

  • colorScheme
  • typography
  • shapes

Let’s explore each with Kotlin examples.

The Core Components of Design Tokens in Material 3

Let’s break down the main categories of design tokens you’ll work with:

1. Color Tokens

Material 3’s color system is brilliant. Instead of just “primary” and “secondary,” you get a full palette that automatically handles light and dark modes, accessibility, and color harmonies.

Kotlin
@Composable
fun ColorTokenExample() {
    // Access color tokens through MaterialTheme
    val primaryColor = MaterialTheme.colorScheme.primary
    val onPrimary = MaterialTheme.colorScheme.onPrimary
    val surface = MaterialTheme.colorScheme.surface
    val surfaceVariant = MaterialTheme.colorScheme.surfaceVariant
    
    Card(
        colors = CardDefaults.cardColors(
            containerColor = surface,
            contentColor = MaterialTheme.colorScheme.onSurface
        )
    ) {
        Text(
            text = "This uses design tokens!",
            color = MaterialTheme.colorScheme.onSurface
        )
    }
}

Here, we’re accessing color tokens through MaterialTheme.colorScheme. The MaterialTheme composable provides access to Material 3’s design tokens. These tokens automatically adjust based on whether the user is in light or dark mode. The onPrimary token ensures text on your primary color is always readable.

2. Typography Tokens

Typography tokens define your text styles consistently across your app. Material Design 3 provides a complete type scale with tokens for everything from large display text to tiny labels.

Kotlin
@Composable
fun TypographyTokenExample() {
    Column(
        modifier = Modifier.padding(16.dp)
    ) {
        // Display large - for prominent text
        Text(
            text = "Welcome Back!",
            style = MaterialTheme.typography.displayLarge
        )
        
        // Headline medium - for section headers
        Text(
            text = "Your Dashboard",
            style = MaterialTheme.typography.headlineMedium
        )
        
        // Body large - for main content
        Text(
            text = "Here's a summary of your activity today.",
            style = MaterialTheme.typography.bodyLarge
        )
        
        // Label small - for captions or metadata
        Text(
            text = "Last updated: 2 hours ago",
            style = MaterialTheme.typography.labelSmall
        )
    }
}

Each typography token (displayLarge, headlineMedium, bodyLarge, labelSmall) defines font size, weight, line height, and letter spacing. By using these Material 3 tokens instead of hardcoding text styles, your app maintains perfect typographic hierarchy.

3. Shape Tokens

Shapes define the corner radii and other geometric properties of your components. Material Design 3 uses different shape tokens for different component types.

Kotlin
@Composable
fun ShapeTokenExample() {
    Row(
        modifier = Modifier.padding(16.dp),
        horizontalArrangement = Arrangement.spacedBy(8.dp)
    ) {
        // Extra small - for chips and small elements
        Surface(
            shape = MaterialTheme.shapes.extraSmall,
            color = MaterialTheme.colorScheme.primaryContainer,
            modifier = Modifier.size(60.dp)
        ) {
            Box(contentAlignment = Alignment.Center) {
                Text("XS")
            }
        }
        
        // Medium - for cards
        Surface(
            shape = MaterialTheme.shapes.medium,
            color = MaterialTheme.colorScheme.secondaryContainer,
            modifier = Modifier.size(60.dp)
        ) {
            Box(contentAlignment = Alignment.Center) {
                Text("M")
            }
        }
        
        // Large - for dialogs and sheets
        Surface(
            shape = MaterialTheme.shapes.large,
            color = MaterialTheme.colorScheme.tertiaryContainer,
            modifier = Modifier.size(60.dp)
        ) {
            Box(contentAlignment = Alignment.Center) {
                Text("L")
            }
        }
    }
}

Shape tokens (extraSmall, medium, large) ensure consistent corner radii throughout your app. Material 3 uses different shapes for different component types, creating visual cohesion and helping users understand component hierarchy.

Setting Up Design Tokens in Your Jetpack Compose Project

Now let’s get practical. Here’s how to implement Design Tokens in Material 3 and Jetpack Compose in your project.

Add Material 3 Dependency

First, ensure you have the Material 3 library in your build.gradle.kts file:

Kotlin
dependencies {
    implementation("androidx.compose.material3:material3:1.2.0")
    implementation("androidx.compose.ui:ui:1.6.0")
}

Create Your Color Scheme

Material Design 3 makes it easy to generate a complete color scheme. You can use the Material Theme Builder tool or define colors manually.

Kotlin
import androidx.compose.material3.lightColorScheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.ui.graphics.Color

// Define your seed colors
private val md_theme_light_primary = Color(0xFF6750A4)
private val md_theme_light_onPrimary = Color(0xFFFFFFFF)
private val md_theme_light_primaryContainer = Color(0xFFEADDFF)
private val md_theme_light_onPrimaryContainer = Color(0xFF21005D)
private val md_theme_light_secondary = Color(0xFF625B71)
private val md_theme_light_onSecondary = Color(0xFFFFFFFF)
private val md_theme_light_secondaryContainer = Color(0xFFE8DEF8)
private val md_theme_light_onSecondaryContainer = Color(0xFF1D192B)
private val md_theme_light_surface = Color(0xFFFFFBFE)
private val md_theme_light_onSurface = Color(0xFF1C1B1F)
private val md_theme_dark_primary = Color(0xFFD0BCFF)
private val md_theme_dark_onPrimary = Color(0xFF381E72)
private val md_theme_dark_primaryContainer = Color(0xFF4F378B)
private val md_theme_dark_onPrimaryContainer = Color(0xFFEADDFF)
private val md_theme_dark_secondary = Color(0xFFCCC2DC)
private val md_theme_dark_onSecondary = Color(0xFF332D41)
private val md_theme_dark_secondaryContainer = Color(0xFF4A4458)
private val md_theme_dark_onSecondaryContainer = Color(0xFFE8DEF8)
private val md_theme_dark_surface = Color(0xFF1C1B1F)
private val md_theme_dark_onSurface = Color(0xFFE6E1E5)

val LightColorScheme = lightColorScheme(
    primary = md_theme_light_primary,
    onPrimary = md_theme_light_onPrimary,
    primaryContainer = md_theme_light_primaryContainer,
    onPrimaryContainer = md_theme_light_onPrimaryContainer,
    secondary = md_theme_light_secondary,
    onSecondary = md_theme_light_onSecondary,
    secondaryContainer = md_theme_light_secondaryContainer,
    onSecondaryContainer = md_theme_light_onSecondaryContainer,
    surface = md_theme_light_surface,
    onSurface = md_theme_light_onSurface
)

val DarkColorScheme = darkColorScheme(
    primary = md_theme_dark_primary,
    onPrimary = md_theme_dark_onPrimary,
    primaryContainer = md_theme_dark_primaryContainer,
    onPrimaryContainer = md_theme_dark_onPrimaryContainer,
    secondary = md_theme_dark_secondary,
    onSecondary = md_theme_dark_onSecondary,
    secondaryContainer = md_theme_dark_secondaryContainer,
    onSecondaryContainer = md_theme_dark_onSecondaryContainer,
    surface = md_theme_dark_surface,
    onSurface = md_theme_dark_onSurface
)

We’re defining two color schemes — one for light mode and one for dark mode. This follows the Material Design 3 color system specification. Each color has a specific purpose. 

Notice the “on” prefix..? Those ensure text and icons are readable on their corresponding background colors.

Create Your Custom Theme

Now let’s wrap everything in a theme composable. This is where we configure the MaterialTheme composable with our Material 3 design tokens:

Kotlin
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Typography
import androidx.compose.material3.Shapes
import androidx.compose.runtime.Composable
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp

// Custom typography following Material Design 3 type scale
val AppTypography = Typography(
    displayLarge = TextStyle(
        fontFamily = FontFamily.Default,
        fontWeight = FontWeight.Bold,
        fontSize = 57.sp,
        lineHeight = 64.sp,
        letterSpacing = (-0.25).sp
    ),
    headlineMedium = TextStyle(
        fontFamily = FontFamily.Default,
        fontWeight = FontWeight.SemiBold,
        fontSize = 28.sp,
        lineHeight = 36.sp,
        letterSpacing = 0.sp
    ),
    bodyLarge = TextStyle(
        fontFamily = FontFamily.Default,
        fontWeight = FontWeight.Normal,
        fontSize = 16.sp,
        lineHeight = 24.sp,
        letterSpacing = 0.5.sp
    ),
    labelSmall = TextStyle(
        fontFamily = FontFamily.Default,
        fontWeight = FontWeight.Medium,
        fontSize = 11.sp,
        lineHeight = 16.sp,
        letterSpacing = 0.5.sp
    )
)

@Composable
fun MyAppTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    content: @Composable () -> Unit
) {
    val colorScheme = if (darkTheme) {
        DarkColorScheme
    } else {
        LightColorScheme
    }
    
    MaterialTheme(
        colorScheme = colorScheme,
        typography = AppTypography,
        shapes = Shapes(), // Uses default Material 3 shapes
        content = content
    )
}

The MyAppTheme composable automatically detects if the system is in dark mode and switches between your light and dark color schemes. We pass our design tokens to the MaterialTheme composable, which makes them available throughout your app. We’re defining custom typography based on Material Design 3’s type scale while using Material 3’s default shapes.

Apply Your Theme

Wrap your app’s root composable with your theme:

Kotlin
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            MyAppTheme {
                // Surface provides a background using the surface color token
                Surface(
                    modifier = Modifier.fillMaxSize(),
                    color = MaterialTheme.colorScheme.background
                ) {
                    // Your app content goes here
                    AppContent()
                }
            }
        }
    }
}

By wrapping everything in MyAppTheme, all composables inside can access your Material 3 design tokens through MaterialTheme. The Surface composable uses the background color token automatically.

Advanced: Dynamic Color and Material You

One of the coolest features of Design Tokens in Material 3 and Jetpack Compose is dynamic color. On Android 12+, your app can generate its color scheme from the user’s wallpaper..!

This is the signature feature of Material You (Material Design 3’s brand name), creating truly personalized user experiences.

Kotlin
import android.os.Build
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext

@Composable
fun MyAppTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    dynamicColor: Boolean = true, // Enable dynamic color
    content: @Composable () -> Unit
) {
    val colorScheme = when {
        // Use dynamic colors on Android 12+
        dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
            val context = LocalContext.current
            if (darkTheme) dynamicDarkColorScheme(context)
            else dynamicLightColorScheme(context)
        }
        // Fall back to custom colors
        darkTheme -> DarkColorScheme
        else -> LightColorScheme
    }
    
    MaterialTheme(
        colorScheme = colorScheme,
        typography = AppTypography,
        content = content
    )
}

On devices running Android 12 or higher, dynamicLightColorScheme() and dynamicDarkColorScheme() generate a complete Material 3 color scheme based on the user’s wallpaper. This creates a truly personalized experience without any extra work on your part! Your design tokens automatically adapt to the generated colors.

Creating Custom Design Tokens

Sometimes you need tokens beyond what Material 3 provides. Here’s how to extend the system while maintaining consistency with Material Design 3 principles:

Kotlin
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp

// Define custom spacing tokens
data class Spacing(
    val none: Dp = 0.dp,
    val extraSmall: Dp = 4.dp,
    val small: Dp = 8.dp,
    val medium: Dp = 16.dp,
    val large: Dp = 24.dp,
    val extraLarge: Dp = 32.dp,
    val huge: Dp = 48.dp
)

// Create a CompositionLocal
val LocalSpacing = staticCompositionLocalOf { Spacing() }

// Extension property for easy access
val MaterialTheme.spacing: Spacing
    @Composable
    get() = LocalSpacing.current

// Usage in your theme
@Composable
fun MyAppTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    content: @Composable () -> Unit
) {
    val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme
    
    MaterialTheme(
        colorScheme = colorScheme,
        typography = AppTypography
    ) {
        // Provide custom spacing tokens
        CompositionLocalProvider(LocalSpacing provides Spacing()) {
            content()
        }
    }
}

// Now you can use custom spacing tokens!
@Composable
fun CustomSpacingExample() {
    Column(
        modifier = Modifier.padding(MaterialTheme.spacing.medium)
    ) {
        Text(
            text = "First item",
            modifier = Modifier.padding(bottom = MaterialTheme.spacing.small)
        )
        Text(
            text = "Second item",
            modifier = Modifier.padding(bottom = MaterialTheme.spacing.large)
        )
    }
}

We created custom spacing tokens using CompositionLocal, which allows us to provide values that can be accessed by any composable in the tree. The extension property makes accessing these tokens feel natural, just like accessing built-in Material 3 tokens. This approach maintains consistency with how Material Design 3 organizes its design system.

Best Practices for Design Tokens

Working with Design Tokens in Material 3 and Jetpack Compose effectively requires following some key principles:

Always Use Tokens, Never Hardcode

Bad:

Kotlin
Text(
    text = "Hello",
    color = Color(0xFF6750A4), // Hardcoded color
    fontSize = 16.sp // Hardcoded size
)

Good:

Kotlin
Text(
    text = "Hello",
    color = MaterialTheme.colorScheme.primary,
    style = MaterialTheme.typography.bodyLarge
)

Use Semantic Token Names

When creating custom tokens, use names that describe the purpose, not the appearance. This follows Material Design 3’s semantic naming philosophy:

Bad: val blueButton = Color(0xFF0000FF)

Good: val buttonPrimary = MaterialTheme.colorScheme.primary

Leverage “On” Color Tokens

Material 3 provides “on” tokens that ensure proper contrast:

Kotlin
@Composable
fun AccessibleButton() {
    Button(
        onClick = { },
        colors = ButtonDefaults.buttonColors(
            containerColor = MaterialTheme.colorScheme.primary,
            contentColor = MaterialTheme.colorScheme.onPrimary // Always readable!
        )
    ) {
        Text("Click Me")
    }
}

The onPrimary token adjusts automatically to maintain proper contrast ratio for accessibility, whether you’re in light mode, dark mode, or using dynamic colors. This is a core principle of Material Design 3’s accessibility-first approach.

Real-World Example: Building a Themed Card Component

Let’s put everything together with a practical example that showcases Design Tokens in Material 3 and Jetpack Compose:

Kotlin
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp

@Composable
fun ProductCard(
    title: String,
    description: String,
    price: String,
    onFavoriteClick: () -> Unit,
    modifier: Modifier = Modifier
) {
    Card(
        modifier = modifier.fillMaxWidth(),
        // Using Material 3 shape token
        shape = MaterialTheme.shapes.medium,
        // Using Material 3 color tokens
        colors = CardDefaults.cardColors(
            containerColor = MaterialTheme.colorScheme.surfaceVariant,
            contentColor = MaterialTheme.colorScheme.onSurfaceVariant
        ),
        elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
    ) {
        Column(
            modifier = Modifier.padding(16.dp)
        ) {
            Row(
                modifier = Modifier.fillMaxWidth(),
                horizontalArrangement = Arrangement.SpaceBetween,
                verticalAlignment = Alignment.CenterVertically
            ) {
                // Using Material 3 typography token
                Text(
                    text = title,
                    style = MaterialTheme.typography.headlineSmall,
                    color = MaterialTheme.colorScheme.onSurfaceVariant
                )
                
                IconButton(onClick = onFavoriteClick) {
                    Icon(
                        imageVector = Icons.Default.Favorite,
                        contentDescription = "Add to favorites",
                        tint = MaterialTheme.colorScheme.primary
                    )
                }
            }
            
            Spacer(modifier = Modifier.height(8.dp))
            
            // Using Material 3 typography token for body text
            Text(
                text = description,
                style = MaterialTheme.typography.bodyMedium,
                color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f)
            )
            
            Spacer(modifier = Modifier.height(12.dp))
            
            // Using Material 3 typography token for price
            Text(
                text = price,
                style = MaterialTheme.typography.titleLarge,
                color = MaterialTheme.colorScheme.primary
            )
        }
    }
}

// Using the component
@Composable
fun ProductScreen() {
    MyAppTheme {
        Surface(
            modifier = Modifier.fillMaxSize(),
            color = MaterialTheme.colorScheme.background
        ) {
            Column(
                modifier = Modifier
                    .fillMaxSize()
                    .padding(16.dp)
            ) {
                ProductCard(
                    title = "Wireless Headphones",
                    description = "Premium noise-cancelling headphones with 30-hour battery life.",
                    price = "$299.99",
                    onFavoriteClick = { /* Handle favorite */ }
                )
            }
        }
    }
}

This ProductCard component uses Material 3 design tokens exclusively. It automatically adapts to light/dark mode, respects dynamic colors from Material You, maintains proper typography hierarchy, and ensures all text is readable against its background. That’s the power of Material Design 3’s token-based system!

Testing Your Design Tokens

Want to make sure your Material 3 tokens work in all scenarios? Create a preview showcase:

Kotlin
import androidx.compose.ui.tooling.preview.Preview

@Preview(name = "Light Mode", showBackground = true)
@Composable
fun ProductCardLightPreview() {
    MyAppTheme(darkTheme = false) {
        ProductCard(
            title = "Wireless Headphones",
            description = "Premium noise-cancelling headphones with 30-hour battery life.",
            price = "$299.99",
            onFavoriteClick = { }
        )
    }
}

@Preview(name = "Dark Mode", showBackground = true)
@Composable
fun ProductCardDarkPreview() {
    MyAppTheme(darkTheme = true) {
        ProductCard(
            title = "Wireless Headphones",
            description = "Premium noise-cancelling headphones with 30-hour battery life.",
            price = "$299.99",
            onFavoriteClick = { }
        )
    }
}

Pro tip: Android Studio shows these previews side-by-side, letting you verify that your Material 3 design tokens create a cohesive experience in both light and dark modes.

Common Mistakes to Avoid

Mistake 1: Mixing Hardcoded and Token Values

Don’t do this:

Kotlin
Text(
    text = "Title",
    fontSize = 24.sp, // Hardcoded
    color = MaterialTheme.colorScheme.primary // Token
)

Instead:

Kotlin
Text(
    text = "Title",
    style = MaterialTheme.typography.headlineMedium,
    color = MaterialTheme.colorScheme.primary
)

Mistake 2: Forgetting About Accessibility

Always use “on” color tokens for text on colored backgrounds. Material Design 3 emphasizes accessibility:

Kotlin
// This might have poor contrast
Button(
    colors = ButtonDefaults.buttonColors(
        containerColor = MaterialTheme.colorScheme.tertiary,
        contentColor = Color.Gray // Bad!
    )
) { Text("Submit") }

// This ensures proper contrast following Material 3 guidelines
Button(
    colors = ButtonDefaults.buttonColors(
        containerColor = MaterialTheme.colorScheme.tertiary,
        contentColor = MaterialTheme.colorScheme.onTertiary // Good!
    )
) { Text("Submit") }

Mistake 3: Not Testing in Both Modes

Always preview your composables in both light and dark modes to ensure your Material 3 token usage works correctly.

Why Design Tokens Improve Long-Term Maintainability

Design tokens:

  • Reduce UI bugs
  • Speed up redesigns
  • Improve accessibility
  • Keep your codebase cleaner
  • Align perfectly with Material Design 3 principles

This is why using design tokens with Material Design 3 in Jetpack Compose is strongly recommended.

Conclusion

Understanding and implementing Design Tokens in Material 3 and Jetpack Compose transforms your development workflow. You get:

  • Consistency: Every component uses the same Material Design 3 language
  • Maintainability: Change your entire theme by updating token values
  • Accessibility: Automatic contrast ratios and readability
  • Personalization: Dynamic colors that adapt to user preferences through Material You
  • Scalability: Easy to extend with custom tokens while maintaining Material 3 principles

The examples we’ve covered today give you a solid foundation to build beautiful, consistent Android apps following Material Design 3 guidelines. Start by implementing basic color and typography tokens, then gradually expand to custom tokens as your needs grow.

Remember, the key to mastering Design Tokens in Material 3 and Jetpack Compose is practice. Start refactoring your existing projects to use Material 3 tokens, and you’ll quickly see the benefits of this systematic approach.

Custom App Theme in Jetpack Compose

How to Build a Consistent Custom App Theme in Jetpack Compose (Material 3)

Creating a polished Android app starts with one crucial decision: your app’s visual identity. If you’ve been wondering how to make your Jetpack Compose app look consistent and professional across every screen, you’re in the right place.

In this guide, I’ll walk you through building a Custom App Theme in Jetpack Compose using Material 3. Whether you’re building your first app or refining an existing one, you’ll learn how to create a theming system that’s both flexible and maintainable.

Why Material 3 Makes Custom Theming Easier

Material 3 (also called Material You) isn’t just another design system update. It’s Google’s most flexible theming framework yet, and it plays beautifully with Jetpack Compose.

Here’s what makes it special:

Dynamic color support — Your app can adapt to the user’s wallpaper colors (on Android 12+) 

Improved design tokens — More granular control over colors, typography, and shapes Better accessibility — Built-in contrast and readability improvements

The best part..? 

Once you set up your Custom App Theme in Jetpack Compose, Material 3 handles the heavy lifting of maintaining consistency throughout your app.

Understanding the Theme Building Blocks

Before we dive into code, let’s understand what makes up a theme in Jetpack Compose. Think of it like building a house — you need a solid foundation.

Your theme consists of three main pillars:

  1. Color Scheme — All the colors your app uses
  2. Typography — Font families, sizes, and weights
  3. Shapes — Corner radiuses and component shapes

When these three work together harmoniously, your app feels intentional and polished.

Setting Up Your Project Dependencies

First things first — make sure you have the right dependencies in your build.gradle.kts file:

Kotlin
dependencies {
    implementation("androidx.compose.material3:material3:1.2.0")
    implementation("androidx.compose.ui:ui:1.6.0")
    implementation("androidx.compose.ui:ui-tooling-preview:1.6.0")
}

These dependencies give you access to Material 3 components and theming capabilities. 

Always check for the latest stable versions on the official documentation.

Creating Your Custom Color Scheme

Colors are the personality of your app. Let’s create a custom color palette that reflects your brand.

Define Your Color Palette

Create a new Kotlin file called Color.kt in your UI theme package:

Kotlin
package com.yourapp.ui.theme

import androidx.compose.ui.graphics.Color

// Light Theme Colors
val PrimaryLight = Color(0xFF6750A4)
val OnPrimaryLight = Color(0xFFFFFFFF)
val PrimaryContainerLight = Color(0xFFEADDFF)
val OnPrimaryContainerLight = Color(0xFF21005D)
val SecondaryLight = Color(0xFF625B71)
val OnSecondaryLight = Color(0xFFFFFFFF)
val SecondaryContainerLight = Color(0xFFE8DEF8)
val OnSecondaryContainerLight = Color(0xFF1D192B)
val TertiaryLight = Color(0xFF7D5260)
val OnTertiaryLight = Color(0xFFFFFFFF)
val TertiaryContainerLight = Color(0xFFFFD8E4)
val OnTertiaryContainerLight = Color(0xFF31111D)
val ErrorLight = Color(0xFFB3261E)
val OnErrorLight = Color(0xFFFFFFFF)
val ErrorContainerLight = Color(0xFFF9DEDC)
val OnErrorContainerLight = Color(0xFF410E0B)
val BackgroundLight = Color(0xFFFFFBFE)
val OnBackgroundLight = Color(0xFF1C1B1F)
val SurfaceLight = Color(0xFFFFFBFE)
val OnSurfaceLight = Color(0xFF1C1B1F)

// Dark Theme Colors
val PrimaryDark = Color(0xFFD0BCFF)
val OnPrimaryDark = Color(0xFF381E72)
val PrimaryContainerDark = Color(0xFF4F378B)
val OnPrimaryContainerDark = Color(0xFFEADDFF)
val SecondaryDark = Color(0xFFCCC2DC)
val OnSecondaryDark = Color(0xFF332D41)
val SecondaryContainerDark = Color(0xFF4A4458)
val OnSecondaryContainerDark = Color(0xFFE8DEF8)
val TertiaryDark = Color(0xFFEFB8C8)
val OnTertiaryDark = Color(0xFF492532)
val TertiaryContainerDark = Color(0xFF633B48)
val OnTertiaryContainerDark = Color(0xFFFFD8E4)
val ErrorDark = Color(0xFFF2B8B5)
val OnErrorDark = Color(0xFF601410)
val ErrorContainerDark = Color(0xFF8C1D18)
val OnErrorContainerDark = Color(0xFFF9DEDC)
val BackgroundDark = Color(0xFF1C1B1F)
val OnBackgroundDark = Color(0xFFE6E1E5)
val SurfaceDark = Color(0xFF1C1B1F)
val OnSurfaceDark = Color(0xFFE6E1E5)

Here,

Each color has a specific role in Material 3. The naming convention follows a pattern:

  • Primary – Your brand’s main color
  • OnPrimary – Text/icon color that appears on top of primary
  • PrimaryContainer – A lighter shade for containers
  • OnPrimaryContainer – Text/icons on primary containers

This naming system ensures your Custom App Theme in Jetpack Compose maintains proper contrast and readability automatically.

Build Your Color Schemes

Now let’s create the actual color scheme objects. Add this to a new file called Theme.kt:

Kotlin
package com.yourapp.ui.theme

import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme

private val LightColorScheme = lightColorScheme(
    primary = PrimaryLight,
    onPrimary = OnPrimaryLight,
    primaryContainer = PrimaryContainerLight,
    onPrimaryContainer = OnPrimaryContainerLight,
    
    secondary = SecondaryLight,
    onSecondary = OnSecondaryLight,
    secondaryContainer = SecondaryContainerLight,
    onSecondaryContainer = OnSecondaryContainerLight,
    
    tertiary = TertiaryLight,
    onTertiary = OnTertiaryLight,
    tertiaryContainer = TertiaryContainerLight,
    onTertiaryContainer = OnTertiaryContainerLight,
    
    error = ErrorLight,
    onError = OnErrorLight,
    errorContainer = ErrorContainerLight,
    onErrorContainer = OnErrorContainerLight,
    
    background = BackgroundLight,
    onBackground = OnBackgroundLight,
    surface = SurfaceLight,
    onSurface = OnSurfaceLight,
)

private val DarkColorScheme = darkColorScheme(
    primary = PrimaryDark,
    onPrimary = OnPrimaryDark,
    primaryContainer = PrimaryContainerDark,
    onPrimaryContainer = OnPrimaryContainerDark,
    
    secondary = SecondaryDark,
    onSecondary = OnSecondaryDark,
    secondaryContainer = SecondaryContainerDark,
    onSecondaryContainer = OnSecondaryContainerDark,
    
    tertiary = TertiaryDark,
    onTertiary = OnTertiaryDark,
    tertiaryContainer = TertiaryContainerDark,
    onTertiaryContainer = OnTertiaryContainerDark,
    
    error = ErrorDark,
    onError = OnErrorDark,
    errorContainer = ErrorContainerDark,
    onErrorContainer = OnErrorContainerDark,
    
    background = BackgroundDark,
    onBackground = OnBackgroundDark,
    surface = SurfaceDark,
    onSurface = OnSurfaceDark,
)

These functions (lightColorScheme and darkColorScheme) are provided by Material 3 to ensure your colors meet accessibility standards.

Customizing Typography

Typography shapes how users read and understand your content. Let’s create a type scale that’s both beautiful and functional.

Defining Custom Fonts

Create a Type.kt file:

Kotlin
package com.yourapp.ui.theme

import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp

// Define your custom font families
// Make sure to add your font files to res/font/
val Montserrat = FontFamily(
    Font(R.font.montserrat_regular, FontWeight.Normal),
    Font(R.font.montserrat_medium, FontWeight.Medium),
    Font(R.font.montserrat_semibold, FontWeight.SemiBold),
    Font(R.font.montserrat_bold, FontWeight.Bold)
)

val Roboto = FontFamily(
    Font(R.font.roboto_regular, FontWeight.Normal),
    Font(R.font.roboto_medium, FontWeight.Medium),
    Font(R.font.roboto_bold, FontWeight.Bold)
)


// Custom Typography
val AppTypography = Typography(
    // Display styles - for large, impactful text
    displayLarge = TextStyle(
        fontFamily = Montserrat,
        fontWeight = FontWeight.Bold,
        fontSize = 57.sp,
        lineHeight = 64.sp,
        letterSpacing = (-0.25).sp
    ),
    displayMedium = TextStyle(
        fontFamily = Montserrat,
        fontWeight = FontWeight.Bold,
        fontSize = 45.sp,
        lineHeight = 52.sp,
        letterSpacing = 0.sp
    ),
    displaySmall = TextStyle(
        fontFamily = Montserrat,
        fontWeight = FontWeight.Bold,
        fontSize = 36.sp,
        lineHeight = 44.sp,
        letterSpacing = 0.sp
    ),
    
    // Headline styles - for section headers
    headlineLarge = TextStyle(
        fontFamily = Montserrat,
        fontWeight = FontWeight.SemiBold,
        fontSize = 32.sp,
        lineHeight = 40.sp,
        letterSpacing = 0.sp
    ),
    headlineMedium = TextStyle(
        fontFamily = Montserrat,
        fontWeight = FontWeight.SemiBold,
        fontSize = 28.sp,
        lineHeight = 36.sp,
        letterSpacing = 0.sp
    ),
    headlineSmall = TextStyle(
        fontFamily = Montserrat,
        fontWeight = FontWeight.SemiBold,
        fontSize = 24.sp,
        lineHeight = 32.sp,
        letterSpacing = 0.sp
    ),
    
    // Title styles - for card titles and important text
    titleLarge = TextStyle(
        fontFamily = Montserrat,
        fontWeight = FontWeight.Medium,
        fontSize = 22.sp,
        lineHeight = 28.sp,
        letterSpacing = 0.sp
    ),
    titleMedium = TextStyle(
        fontFamily = Montserrat,
        fontWeight = FontWeight.Medium,
        fontSize = 16.sp,
        lineHeight = 24.sp,
        letterSpacing = 0.15.sp
    ),
    titleSmall = TextStyle(
        fontFamily = Montserrat,
        fontWeight = FontWeight.Medium,
        fontSize = 14.sp,
        lineHeight = 20.sp,
        letterSpacing = 0.1.sp
    ),
    
    // Body styles - for main content
    bodyLarge = TextStyle(
        fontFamily = Roboto,
        fontWeight = FontWeight.Normal,
        fontSize = 16.sp,
        lineHeight = 24.sp,
        letterSpacing = 0.5.sp
    ),
    bodyMedium = TextStyle(
        fontFamily = Roboto,
        fontWeight = FontWeight.Normal,
        fontSize = 14.sp,
        lineHeight = 20.sp,
        letterSpacing = 0.25.sp
    ),
    bodySmall = TextStyle(
        fontFamily = Roboto,
        fontWeight = FontWeight.Normal,
        fontSize = 12.sp,
        lineHeight = 16.sp,
        letterSpacing = 0.4.sp
    ),
    
    // Label styles - for buttons and small UI elements
    labelLarge = TextStyle(
        fontFamily = Montserrat,
        fontWeight = FontWeight.Medium,
        fontSize = 14.sp,
        lineHeight = 20.sp,
        letterSpacing = 0.1.sp
    ),
    labelMedium = TextStyle(
        fontFamily = Montserrat,
        fontWeight = FontWeight.Medium,
        fontSize = 12.sp,
        lineHeight = 16.sp,
        letterSpacing = 0.5.sp
    ),
    labelSmall = TextStyle(
        fontFamily = Montserrat,
        fontWeight = FontWeight.Medium,
        fontSize = 11.sp,
        lineHeight = 16.sp,
        letterSpacing = 0.5.sp
    )
)

Understanding the type scale:

Material 3 provides 15 different text styles organized into five categories. This gives you flexibility while maintaining consistency in your Custom App Theme in Jetpack Compose:

  • Display — Hero text, splash screens
  • Headline — Page titles, section headers
  • Title — Card titles, dialog headers
  • Body — Paragraphs, main content
  • Label — Buttons, tabs, small UI elements

Defining Custom Shapes

Shapes add personality to your UI. From rounded corners to sharp edges, shapes influence how modern or traditional your app feels.

Create a Shape.kt file:

Kotlin
package com.yourapp.ui.theme

import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Shapes
import androidx.compose.ui.unit.dp

val AppShapes = Shapes(
    // Extra small - chips, small buttons
    extraSmall = RoundedCornerShape(4.dp),
    
    // Small - buttons, text fields
    small = RoundedCornerShape(8.dp),
    
    // Medium - cards, dialogs
    medium = RoundedCornerShape(12.dp),
    
    // Large - bottom sheets, large cards
    large = RoundedCornerShape(16.dp),
    
    // Extra large - special components
    extraLarge = RoundedCornerShape(28.dp)
)

Shape usage tips:

  • Use extraSmall for chips and toggles
  • Use small for buttons and input fields
  • Use medium for cards and elevated surfaces
  • Use large for bottom sheets and modals
  • Use extraLarge for floating action buttons

You can also create asymmetric shapes or custom shapes using GenericShape for more creative designs.

Bringing It All Together: Your Theme Composable

Now comes the exciting part — assembling everything into your main theme composable. Update your Theme.kt file:

Kotlin
package com.yourapp.ui.theme

import android.app.Activity
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat

@Composable
fun AppTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    dynamicColor: Boolean = true, // Dynamic color available on Android 12+
    content: @Composable () -> Unit
) {
    val colorScheme = when {
        // Dynamic color (Material You) - uses user's wallpaper colors
        dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
            val context = LocalContext.current
            if (darkTheme) dynamicDarkColorScheme(context) 
            else dynamicLightColorScheme(context)
        }
        // Dark theme
        darkTheme -> DarkColorScheme
        // Light theme
        else -> LightColorScheme
    }
    
    val view = LocalView.current
    if (!view.isInEditMode) {
        SideEffect {
            val window = (view.context as Activity).window
            window.statusBarColor = colorScheme.primary.toArgb()
            WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
        }
    }
    MaterialTheme(
        colorScheme = colorScheme,
        typography = AppTypography,
        shapes = AppShapes,
        content = content
    )
}
  1. Dark theme detection — Automatically detects if the user prefers dark mode
  2. Dynamic color support — On Android 12+, colors adapt to the user’s wallpaper
  3. Status bar styling — Ensures the status bar matches your theme
  4. Fallback colors — Uses your custom colors on older Android versions

This is the heart of your Custom App Theme in Jetpack Compose. Every screen that uses this theme will automatically have consistent colors, typography, and shapes.

Using Your Theme in the App

Now let’s see how to apply your theme to your app. In your MainActivity.kt:

Kotlin
package com.yourapp

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.yourapp.ui.theme.AppTheme

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            AppTheme {
                Surface(
                    modifier = Modifier.fillMaxSize(),
                    color = MaterialTheme.colorScheme.background
                ) {
                    HomeScreen()
                }
            }
        }
    }
}

@Composable
fun HomeScreen() {
    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(16.dp),
        verticalArrangement = Arrangement.spacedBy(16.dp)
    ) {
        // Using themed text styles
        Text(
            text = "Welcome to My App",
            style = MaterialTheme.typography.headlineLarge,
            color = MaterialTheme.colorScheme.primary
        )
        
        Text(
            text = "This is a subtitle showing our custom typography",
            style = MaterialTheme.typography.titleMedium,
            color = MaterialTheme.colorScheme.onSurface
        )
        
        Text(
            text = "Body text looks great with our custom font family. " +
                  "Notice how everything feels cohesive and professional.",
            style = MaterialTheme.typography.bodyLarge,
            color = MaterialTheme.colorScheme.onSurfaceVariant
        )
        
        // Using themed button with custom shapes
        Button(
            onClick = { /* Handle click */ },
            shape = MaterialTheme.shapes.medium
        ) {
            Text("Primary Button")
        }
        
        // Using themed card
        Card(
            modifier = Modifier.fillMaxWidth(),
            shape = MaterialTheme.shapes.large,
            colors = CardDefaults.cardColors(
                containerColor = MaterialTheme.colorScheme.primaryContainer
            )
        ) {
            Column(modifier = Modifier.padding(16.dp)) {
                Text(
                    text = "Card Title",
                    style = MaterialTheme.typography.titleLarge,
                    color = MaterialTheme.colorScheme.onPrimaryContainer
                )
                Spacer(modifier = Modifier.height(8.dp))
                Text(
                    text = "This card uses our theme colors and shapes automatically.",
                    style = MaterialTheme.typography.bodyMedium,
                    color = MaterialTheme.colorScheme.onPrimaryContainer
                )
            }
        }
    }
}

@Preview(showBackground = true)
@Composable
fun HomeScreenPreview() {
    AppTheme {
        HomeScreen()
    }
}

Key takeaways from this example:

  • Wrap your content in AppTheme { } to apply your custom theme
  • Access colors via MaterialTheme.colorScheme.primary (not hardcoded values!)
  • Access typography via MaterialTheme.typography.headlineLarge
  • Access shapes via MaterialTheme.shapes.medium

This approach ensures your Custom App Theme in Jetpack Compose is applied consistently throughout your app.

Creating Theme-Aware Components

Let’s build a custom component that respects your theme. This is where the real power of theming shines:

Kotlin
@Composable
fun ThemedInfoCard(
    title: String,
    description: String,
    modifier: Modifier = Modifier
) {
    Card(
        modifier = modifier.fillMaxWidth(),
        shape = MaterialTheme.shapes.large,
        colors = CardDefaults.cardColors(
            containerColor = MaterialTheme.colorScheme.secondaryContainer
        ),
        elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
    ) {
        Column(
            modifier = Modifier.padding(16.dp),
            verticalArrangement = Arrangement.spacedBy(8.dp)
        ) {
            Text(
                text = title,
                style = MaterialTheme.typography.titleLarge,
                color = MaterialTheme.colorScheme.onSecondaryContainer
            )
            
            Divider(
                color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f),
                thickness = 1.dp
            )
            
            Text(
                text = description,
                style = MaterialTheme.typography.bodyMedium,
                color = MaterialTheme.colorScheme.onSecondaryContainer
            )
        }
    }
}

// Usage
@Composable
fun ExampleScreen() {
    Column(modifier = Modifier.padding(16.dp)) {
        ThemedInfoCard(
            title = "Themed Component",
            description = "This card automatically adapts to light/dark theme changes!"
        )
    }
}

This component will automatically look perfect in both light and dark modes because it uses theme values instead of hardcoded colors.

Advanced: Adding Custom Theme Values

Sometimes you need values beyond what Material 3 provides. Here’s how to extend your theme:

Kotlin
// Create custom theme values
data class CustomColors(
    val success: Color,
    val onSuccess: Color,
    val warning: Color,
    val onWarning: Color,
    val info: Color,
    val onInfo: Color
)

// Light theme custom colors
val LightCustomColors = CustomColors(
    success = Color(0xFF4CAF50),
    onSuccess = Color(0xFFFFFFFF),
    warning = Color(0xFFFF9800),
    onWarning = Color(0xFF000000),
    info = Color(0xFF2196F3),
    onInfo = Color(0xFFFFFFFF)
)

// Dark theme custom colors
val DarkCustomColors = CustomColors(
    success = Color(0xFF81C784),
    onSuccess = Color(0xFF000000),
    warning = Color(0xFFFFB74D),
    onWarning = Color(0xFF000000),
    info = Color(0xFF64B5F6),
    onInfo = Color(0xFF000000)
)

// Create a CompositionLocal
val LocalCustomColors = staticCompositionLocalOf { LightCustomColors }

// Extend your theme
@Composable
fun AppTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    dynamicColor: Boolean = true,
    content: @Composable () -> Unit
) {
    val customColors = if (darkTheme) DarkCustomColors else LightCustomColors
    
    // ... existing color scheme code ...
    
    CompositionLocalProvider(LocalCustomColors provides customColors) {
        MaterialTheme(
            colorScheme = colorScheme,
            typography = AppTypography,
            shapes = AppShapes,
            content = content
        )
    }
}

// Access custom colors
@Composable
fun CustomThemedComponent() {
    val customColors = LocalCustomColors.current
    
    Button(
        onClick = { },
        colors = ButtonDefaults.buttonColors(
            containerColor = customColors.success
        )
    ) {
        Text("Success Action", color = customColors.onSuccess)
    }
}

This technique lets you add semantic colors like success, warning, and info to your Custom App Theme in Jetpack Compose.

Best Practices for Theme Consistency

Here are proven strategies to keep your theme consistent as your app grows:

1. Never Hardcode Colors

Bad:

Kotlin
Text(
    text = "Hello",
    color = Color(0xFF6750A4) // Hardcoded!
)

Good:

Kotlin
Text(
    text = "Hello",
    color = MaterialTheme.colorScheme.primary
)

2. Create Reusable Components

Instead of repeating styling code, create themed components:

Kotlin
@Composable
fun AppButton(
    text: String,
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
    isPrimary: Boolean = true
) {
    Button(
        onClick = onClick,
        modifier = modifier,
        colors = ButtonDefaults.buttonColors(
            containerColor = if (isPrimary) 
                MaterialTheme.colorScheme.primary 
            else 
                MaterialTheme.colorScheme.secondary
        ),
        shape = MaterialTheme.shapes.medium
    ) {
        Text(
            text = text,
            style = MaterialTheme.typography.labelLarge
        )
    }
}

3. Use Semantic Naming

Choose color roles based on meaning, not appearance:

Kotlin
// Bad naming
val BlueColor = Color(0xFF2196F3)

// Good naming
val LinkColor = MaterialTheme.colorScheme.primary
val SuccessColor = customColors.success

4. Test Both Themes

Always preview your screens in both light and dark modes:

Kotlin
@Preview(name = "Light Mode", showBackground = true)
@Preview(name = "Dark Mode", uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
fun ScreenPreview() {
    AppTheme {
        YourScreen()
    }
}

5. Keep Typography Consistent

Use the defined typography scale instead of creating custom text styles on the fly:

Kotlin
// Avoid this
Text(
    text = "Title",
    fontSize = 24.sp,
    fontWeight = FontWeight.Bold
)

// Do this
Text(
    text = "Title",
    style = MaterialTheme.typography.headlineSmall
)

Debugging Theme Issues

Sometimes things don’t look right. Here’s how to troubleshoot:

Preview Your Theme Values

Create a debug screen to visualize your theme:

Kotlin
@Composable
fun ThemeDebugScreen() {
    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(16.dp)
            .verticalScroll(rememberScrollState())
    ) {
        Text("Color Scheme", style = MaterialTheme.typography.headlineMedium)
        Spacer(modifier = Modifier.height(8.dp))
        
        ColorBox("Primary", MaterialTheme.colorScheme.primary)
        ColorBox("Secondary", MaterialTheme.colorScheme.secondary)
        ColorBox("Tertiary", MaterialTheme.colorScheme.tertiary)
        // ... add more colors
        
        Spacer(modifier = Modifier.height(16.dp))
        Text("Typography", style = MaterialTheme.typography.headlineMedium)
        
        Text("Display Large", style = MaterialTheme.typography.displayLarge)
        Text("Headline Medium", style = MaterialTheme.typography.headlineMedium)
        Text("Body Large", style = MaterialTheme.typography.bodyLarge)
        // ... add more styles
    }
}

@Composable
fun ColorBox(name: String, color: Color) {
    Row(
        modifier = Modifier
            .fillMaxWidth()
            .padding(vertical = 4.dp),
        horizontalArrangement = Arrangement.SpaceBetween
    ) {
        Text(name)
        Box(
            modifier = Modifier
                .size(40.dp)
                .background(color, MaterialTheme.shapes.small)
        )
    }
}

This screen helps you visually verify all your theme values at a glance.

Supporting Dynamic Color (Material You)

Material You allows users to personalize their experience. Here’s how to give users control:

Kotlin
// In your ViewModel or state management
class ThemeViewModel : ViewModel() {
    private val _useDynamicColor = MutableStateFlow(true)
    val useDynamicColor: StateFlow<Boolean> = _useDynamicColor.asStateFlow()
    
    fun toggleDynamicColor() {
        _useDynamicColor.value = !_useDynamicColor.value
    }
}

// In your settings screen
@Composable
fun SettingsScreen(viewModel: ThemeViewModel = viewModel()) {
    val useDynamicColor by viewModel.useDynamicColor.collectAsState()
    
    Row(
        modifier = Modifier
            .fillMaxWidth()
            .padding(16.dp),
        horizontalArrangement = Arrangement.SpaceBetween,
        verticalAlignment = Alignment.CenterVertically
    ) {
        Text("Use Material You colors")
        Switch(
            checked = useDynamicColor,
            onCheckedChange = { viewModel.toggleDynamicColor() }
        )
    }
}

Users on Android 12+ can then choose between your custom colors and colors that match their wallpaper.

Common Mistakes to Avoid

Mistake 1: Mixing Hardcoded and Theme Values

Don’t mix approaches in the same project. Pick theme values and stick with them:

Kotlin
// Inconsistent - Don't do this
Column {
    Text(text = "Title", color = MaterialTheme.colorScheme.primary)
    Text(text = "Subtitle", color = Color.Gray) // Hardcoded!
}

// Consistent - Do this
Column {
    Text(text = "Title", color = MaterialTheme.colorScheme.primary)
    Text(text = "Subtitle", color = MaterialTheme.colorScheme.onSurfaceVariant)
}

Mistake 2: Ignoring Accessibility

Always ensure sufficient contrast between text and backgrounds:

Kotlin
// Check contrast ratios
val backgroundColor = MaterialTheme.colorScheme.primary
val textColor = MaterialTheme.colorScheme.onPrimary // Guaranteed good contrast

Material 3 handles this automatically if you use the correct color pairs (primary/onPrimary, surface/onSurface, etc.).

Mistake 3: Over-Customizing

Not every component needs custom styling. Sometimes the default Material 3 styling is perfect:

Kotlin
// Often unnecessary
Button(
    onClick = { },
    colors = ButtonDefaults.buttonColors(/* custom colors */),
    shape = RoundedCornerShape(/* custom shape */),
    elevation = ButtonDefaults.elevatedButtonElevation(/* custom elevation */)
) { }

// Usually sufficient
Button(onClick = { }) {
    Text("Click Me")
}

Migration from Material 2 to Material 3

If you’re upgrading an existing app, here’s a quick migration guide:

Color Migration

Material 2 → Material 3:

  • primaryprimary (similar)
  • primaryVariantprimaryContainer
  • secondarysecondary (similar)
  • secondaryVariantsecondaryContainer
  • backgroundbackground (same)
  • surfacesurface (same)

Typography Migration

Material 3 has more granular typography styles. Map your old styles:

  • h1displayLarge
  • h2displayMedium
  • h3displaySmall
  • h4headlineLarge
  • h5headlineMedium
  • h6headlineSmall
  • subtitle1titleLarge
  • subtitle2titleMedium
  • body1bodyLarge
  • body2bodyMedium

Conclusion

Building a Custom App Theme in Jetpack Compose with Material 3 might seem complex at first, but it’s an investment that pays dividends. You get:

  • Consistency — Every screen automatically follows your design system
  • Maintainability — Change one value, update the entire app
  • Flexibility — Support light/dark themes and Material You effortlessly
  • Professionalism — Your app looks polished and well-crafted
  • Accessibility — Built-in contrast and readability standards

The key is starting with a solid foundation: well-defined colors, typography, and shapes. Once your theme is set up, building new screens becomes faster because you’re working with a consistent design language.

Remember, your Custom App Theme in Jetpack Compose isn’t set in stone. As your app evolves and your brand matures, you can refine your theme values. The beauty of this system is that those updates propagate throughout your entire app automatically.

CompositionLocal in Jetpack Compose

CompositionLocal in Jetpack Compose: How to Avoid Prop Drilling

When building apps with Jetpack Compose, you’ll often pass data down through multiple layers of composables. At first, this feels fine. But as your UI grows, you may find yourself passing the same parameter through five or six functions just to reach a deeply nested child.

That pattern is called prop drilling.

It works, but it clutters your APIs and makes your code harder to maintain.

This is where CompositionLocal in Jetpack Compose becomes incredibly useful. In this guide, we’ll learn what it is, when to use it, how it works under the hood, and how to avoid common mistakes.

What Is Prop Drilling in Jetpack Compose?

Prop drilling happens when you pass data through multiple composables, even though intermediate composables don’t use that data.

For example:

Kotlin
@Composable
fun ParentScreen() {
    val userName = "Amol"
    LevelOne(userName)
}

@Composable
fun LevelOne(userName: String) {
    LevelTwo(userName)
}

@Composable
fun LevelTwo(userName: String) {
    Greeting(userName)
}

@Composable
fun Greeting(userName: String) {
    Text(text = "Hello, $userName")
}

Only Greeting actually needs userName. But we pass it through LevelOne and LevelTwo anyway.

In small apps, this is fine. In large apps, it becomes noisy and harder to refactor.

What Is CompositionLocal in Jetpack Compose?

CompositionLocal in Jetpack Compose is a way to implicitly pass data down the composable tree without manually threading it through every function.

It allows you to define a value once and access it anywhere inside a specific part of the composition.

Think of it as a scoped global value. It’s not truly global, but it’s available to any composable inside its scope.

Jetpack Compose already uses it internally. For example:

  • MaterialTheme
  • LocalContext
  • LocalDensity
  • LocalLayoutDirection

These are all built using CompositionLocal.

When Should You Use CompositionLocal?

Use CompositionLocal in Jetpack Compose when:

  • The data is cross-cutting (theme, configuration, permissions, localization).
  • Many composables need access to it.
  • Passing it as a parameter would create unnecessary noise.

Avoid using it for:

  • Screen-specific business logic
  • Frequently changing state
  • ViewModel data that belongs to a specific screen

In short, use it for shared environmental values, not regular state.

How to Create a CompositionLocal

There are two main ways to create one:

  1. compositionLocalOf
  2. staticCompositionLocalOf

Let’s start with the common one.

Example: Creating a CompositionLocal

Suppose we want to provide a custom app theme color.

Define the CompositionLocal

Kotlin
val LocalAppPrimaryColor = compositionLocalOf { Color.Blue }

What this does:

  • Creates a CompositionLocal.
  • Provides a default value (Color.Blue).
  • If no value is provided, the default will be used.

Provide a Value

We use CompositionLocalProvider to supply a value.

Kotlin
@Composable
fun MyApp() {
    CompositionLocalProvider(
        LocalAppPrimaryColor provides Color.Green
    ) {
        HomeScreen()
    }
}

Here,

  • Inside MyApp, we provide Color.Green.
  • Any composable inside HomeScreen() can now access it.
  • Outside this block, the default value applies.

So basically, 

What’s the provides keyword?

It’s an infix function that creates a ProvidedValue pairing your CompositionLocal with an actual value. Think of it as saying: “For this scope, LocalAppPrimaryColor provides Color.Green.”

You can even provide multiple values at once:

Kotlin
@Composable
fun MyApp() {
    val theme = AppTheme(/* ... */)
    val user = User(id = "123", name = "Anaya")
    
    CompositionLocalProvider(
        LocalAppTheme provides theme,
        LocalUser provides user
    ) {
        MainScreen()
    }
}

Consume the Value

Now we access it using .current.

Kotlin
@Composable
fun HomeScreen() {

    val primaryColor = LocalAppPrimaryColor.current
    
    Text(
        text = "Welcome",
        color = primaryColor
    )
}

That’s it.

No parameter passing. 

No prop drilling.

How CompositionLocal in Jetpack Compose Works Internally

Understanding this improves your architectural decisions.

When you use CompositionLocal in Jetpack Compose, the value becomes part of the composition tree. Compose tracks reads of .current. If the value changes, only the composables that read it will recompose.

This makes it efficient.

It’s not like a global variable. It’s scoped and lifecycle-aware.

Using staticCompositionLocalOf

Use this when the value rarely or never changes. It’s more optimized but less flexible:

Kotlin
val LocalAppConfiguration = staticCompositionLocalOf {
    AppConfiguration(apiUrl = "https://api.softaai.com")
}

When to use static? Only when the value is truly static for the entire composition, like build configuration or app constants.

compositionLocalOf Vs staticCompositionLocalOf

This is important.

compositionLocalOf

  • Tracks reads.
  • Causes recomposition when value changes.
  • Best for values that may change.

Example: dynamic theme.

staticCompositionLocalOf

  • Does NOT track reads.
  • Better performance.
  • Use when value will never change.

Example: app configuration object that stays constant.

Example:

Kotlin
val LocalAppConfig = staticCompositionLocalOf<AppConfig> {
    error("No AppConfig provided")
}

Use this only when you are sure the value won’t change.

Real-World Example: Building a Theme System

Let’s build a complete example that shows the power of CompositionLocal in Jetpack Compose. We’ll create a theme system with light and dark modes:

Define a data class

Kotlin
// Step 1: Define our theme data
data class AppTheme(
    val colors: AppColors,
    val typography: AppTypography,
    val isDark: Boolean
)

data class AppColors(
    val primary: Color,
    val background: Color,
    val surface: Color,
    val text: Color
)

data class AppTypography(
    val heading: TextStyle,
    val body: TextStyle
)

Create CompositionLocal

Kotlin
// Step 2: Create CompositionLocal
val LocalAppTheme = compositionLocalOf {
    AppTheme(
        colors = AppColors(
            primary = Color.Blue,
            background = Color.White,
            surface = Color.LightGray,
            text = Color.Black
        ),
        typography = AppTypography(
            heading = TextStyle(fontSize = 24.sp, fontWeight = FontWeight.Bold),
            body = TextStyle(fontSize = 16.sp)
        ),
        isDark = false
    )
}

Provide the value

Kotlin
// Step 3: Create theme instances
object AppThemes {
    val Light = AppTheme(
        colors = AppColors(
            primary = Color(0xFF2196F3),
            background = Color.White,
            surface = Color(0xFFF5F5F5),
            text = Color.Black
        ),
        typography = AppTypography(
            heading = TextStyle(fontSize = 24.sp, fontWeight = FontWeight.Bold),
            body = TextStyle(fontSize = 16.sp)
        ),
        isDark = false
    )
    
    val Dark = AppTheme(
        colors = AppColors(
            primary = Color(0xFF90CAF9),
            background = Color(0xFF121212),
            surface = Color(0xFF1E1E1E),
            text = Color.White
        ),
        typography = AppTypography(
            heading = TextStyle(fontSize = 24.sp, fontWeight = FontWeight.Bold),
            body = TextStyle(fontSize = 16.sp)
        ),
        isDark = true
    )
}

// Step 4: Provide theme at app level
@Composable
fun MyApp() {
    var isDarkMode by remember { mutableStateOf(false) }
    val currentTheme = if (isDarkMode) AppThemes.Dark else AppThemes.Light
    
    CompositionLocalProvider(LocalAppTheme provides currentTheme) {
        Scaffold(
            topBar = {
                TopAppBar(
                    title = { Text("My App") },
                    actions = {
                        IconButton(onClick = { isDarkMode = !isDarkMode }) {
                            Icon(
                                imageVector = if (isDarkMode) 
                                    Icons.Default.LightMode 
                                else 
                                    Icons.Default.DarkMode,
                                contentDescription = "Toggle theme"
                            )
                        }
                    }
                )
            }
        ) { padding ->
            MainContent(modifier = Modifier.padding(padding))
        }
    }
}

Consume it anywhere

Kotlin
// Step 5: Use theme throughout the app
@Composable
fun MainContent(modifier: Modifier = Modifier) {
    val theme = LocalAppTheme.current
    
    Column(
        modifier = modifier
            .fillMaxSize()
            .background(theme.colors.background)
            .padding(16.dp)
    ) {
        Text(
            text = "Welcome!",
            style = theme.typography.heading,
            color = theme.colors.text
        )
        
        Spacer(modifier = Modifier.height(16.dp))
        
        // This composable also has access to the theme
        ProfileCard()
    }
}

@Composable
fun ProfileCard() {
    val theme = LocalAppTheme.current
    
    Card(
        modifier = Modifier.fillMaxWidth(),
        colors = CardDefaults.cardColors(
            containerColor = theme.colors.surface
        )
    ) {
        Column(modifier = Modifier.padding(16.dp)) {
            Text(
                text = "User Profile",
                style = theme.typography.heading,
                color = theme.colors.primary
            )
            Text(
                text = "This card automatically updates with the theme!",
                style = theme.typography.body,
                color = theme.colors.text
            )
        }
    }
}

Notice how ProfileCard doesn’t need to receive the theme as a parameter. It simply accesses LocalAppTheme.current and gets the value. When you toggle between light and dark mode, all composables that read from LocalAppTheme automatically recompose with the new values.

That’s the power of CompositionLocal in Jetpack Compose.

Best Practices for Using CompositionLocal

1. Use It Sparingly

CompositionLocal is powerful, but don’t overuse it. It’s perfect for:

  • Application-wide themes
  • User authentication state
  • Locale/language settings
  • Navigation controllers
  • Dependency injection

It’s NOT ideal for:

  • Component-specific state
  • Data that changes frequently at the component level
  • Communication between sibling composables

2. Always Provide Default Values

Always include a sensible default in your compositionLocalOf lambda:

Kotlin
val LocalUser = compositionLocalOf {
    User(id = "123", name = "Anaya", isAuthenticated = false)
}

This prevents crashes if someone forgets to provide a value and makes your code more robust.

3. Make CompositionLocals Top-Level Properties

Define them at the file level, not inside composables:

Kotlin
// Good - Top level
val LocalAnalytics = compositionLocalOf { AnalyticsTracker() }

@Composable
fun MyScreen() {
    // Bad - Inside composable
    val LocalSomething = compositionLocalOf { /* ... */ }
}

4. Use Descriptive Names with “Local” Prefix

This convention makes it immediately clear that you’re dealing with a CompositionLocal:

Kotlin
val LocalAppTheme = compositionLocalOf { /* ... */ }  // Clear
val theme = compositionLocalOf { /* ... */ }           // Confusing

5. Document Your CompositionLocals

Add KDoc comments to explain what the CompositionLocal provides and when to use it:

Kotlin
/**
 * Provides the current app theme (colors, typography, spacing).
 * This value updates when the user switches between light and dark mode.
 */
val LocalAppTheme = compositionLocalOf {
    AppTheme(/* ... */)
}

Common Pitfalls and How to Avoid Them

Even sometimes experienced developers make mistakes with CompositionLocal in Jetpack Compose. Here are the most common issues:

Pitfall 1: Reading CompositionLocal in Non-Composable Context

Kotlin
// Wrong - Can't use .current outside a composable
class MyViewModel {
    val theme = LocalAppTheme.current  // Compilation error!
}

// Correct - Pass it as a parameter if needed
@Composable
fun MyScreen(viewModel: MyViewModel) {
    val theme = LocalAppTheme.current
    viewModel.updateTheme(theme)
}

Pitfall 2: Creating New Instances on Every Recomposition

Kotlin
@Composable
fun MyApp() {
    // Bad - Creates new theme on every recomposition
    CompositionLocalProvider(
        LocalAppTheme provides AppTheme(/* ... */)
    ) {
        Content()
    }
}

@Composable
fun MyApp() {
    // Good - Remember the theme
    val theme = remember {
        AppTheme(/* ... */)
    }
    
    CompositionLocalProvider(LocalAppTheme provides theme) {
        Content()
    }
}

Pitfall 3: Using CompositionLocal for Frequent Updates

Kotlin
// Not ideal - Mouse position changes too frequently
val LocalMousePosition = compositionLocalOf { Offset.Zero }

// Better - Use State or pass as parameter
@Composable
fun TrackingCanvas() {
    var mousePosition by remember { mutableStateOf(Offset.Zero) }
    // Use mousePosition directly
}

Pitfall 4: Forgetting to Provide a Value

If you forget to provide a value, you’ll get the default. This might be okay, or it might be a bug:

Kotlin
val LocalUser = compositionLocalOf<User?> { null }

@Composable
fun MyApp() {
    // Forgot to provide a user!
    MainScreen()
}

@Composable
fun MainScreen() {
    val user = LocalUser.current  // Will be null
    Text("Hello, ${user?.name}")  // Displays "Hello, null"
}

Testing Composables with CompositionLocal

When testing composables that rely on a CompositionLocal, you should provide a value using CompositionLocalProvider if the composable depends on that value and no suitable default exists. This allows you to override environment values and test different scenarios.

Kotlin
@Test
fun testThemedButtonUsesCorrectColor() {
    composeTestRule.setContent {
        // Provide a test theme
        val testTheme = AppTheme(
            colors = AppColors(
                primary = Color.Red,
                background = Color.White,
                surface = Color.Gray,
                text = Color.Black
            ),
            typography = AppTypography(/* ... */),
            isDark = false
        )
        
        CompositionLocalProvider(LocalAppTheme provides testTheme) {
            ThemedButton(text = "Click me", onClick = {})
        }
    }
    
    composeTestRule.onNodeWithText("Click me")
        .assertExists()
        .assertHasColor(Color.Red) // Verify theme is actually applied
}

This approach lets you test your composables with different CompositionLocal values, ensuring they work correctly in all scenarios.

CompositionLocal vs. Other State Management Solutions

You might wonder when to use CompositionLocal in Jetpack Compose versus other state management approaches. Here’s a quick guide:

Use CompositionLocal when:

  • Data is needed by many composables across the tree
  • The data represents ambient context (theme, locale, user)
  • You want to avoid prop drilling
  • The data changes infrequently

Use State/ViewModel when:

  • Data is specific to a screen or feature
  • You need business logic tied to the data
  • The data changes frequently
  • You need to survive configuration changes

Use Passed Parameters when:

  • Only a few composables need the data
  • The relationship is direct parent-child
  • You want explicit data flow

Often, the best solution combines these approaches. For example, you might use CompositionLocal for the theme, ViewModels for business logic, and parameters for component-specific props.

FAQ’s

What is CompositionLocal in Jetpack Compose?

CompositionLocal in Jetpack Compose is a mechanism to implicitly pass data down the composable tree without manually passing parameters through every function.

How does CompositionLocal avoid prop drilling?

It provides scoped values that child composables can access directly, eliminating the need to pass the same parameter through multiple intermediate composables.

When should you use CompositionLocal?

Use it for shared, cross-cutting concerns such as themes, configuration, context, or localization. Avoid using it for regular screen state.

Conclusion

Prop drilling isn’t always wrong. But when your composable tree gets deep, it becomes frustrating.

CompositionLocal in Jetpack Compose gives you a clean, structured way to share data across your UI without cluttering every function signature.

Use it thoughtfully.

Keep your dependencies clear.

And treat it as a tool for environmental data, not a replacement for proper state management.

When applied correctly, it makes your Compose code cleaner, more scalable, and easier to reason about.

If you’re building modern Android apps with Kotlin and Jetpack Compose, mastering CompositionLocal is not optional. It’s part of writing professional-level Compose code.

Material 3 colorScheme

Material 3 colorScheme Explained: How Dynamic Color Really Works

Have you ever wondered how Android apps magically match your wallpaper colors? Or how Material Design creates those perfectly harmonious color palettes that just work

That’s the magic of Material 3’s dynamic color system, and today, we’re diving deep into how it all comes together using Kotlin and Jetpack Compose.

By the end of this guide, you’ll understand exactly how the Material 3 colorScheme works, how to implement it in your Android apps with Kotlin, and how to harness the power of dynamic theming to create stunning user interfaces that feel personal and cohesive.

Let’s get started..!

What Is Material 3 ColorScheme?

The Material 3 colorScheme is Google’s revolutionary approach to app theming that goes way beyond simple primary and secondary colors. Think of it as a complete color system that automatically generates a harmonious palette of colors designed to work together beautifully.

Here’s what makes it special:

Dynamic Color Generation: Instead of manually picking dozens of color shades, the Material 3 colorScheme generates an entire palette from a single seed color. This means you get consistent, accessible, and visually appealing colors without the guesswork.

Adaptive Theming: The system automatically adjusts for light and dark modes, ensuring your app looks great in any setting.

Wallpaper Integration: On Android 12 and above, your app can automatically extract colors from the user’s wallpaper, creating a truly personalized experience.

Accessibility Built-In: Every color in the Material 3 colorScheme meets WCAG (Web Content Accessibility Guidelines) accessibility standards when used correctly, so you don’t have to worry about contrast ratios.

Understanding Dynamic Color: The Foundation

Dynamic color is the heart of Material 3 on Android. But what exactly is it?

Imagine your phone’s wallpaper is a beautiful sunset with warm orange and purple tones. With dynamic color, your apps can extract those colors and build their entire theme around them. It’s personalization taken to the next level.

How Dynamic Color Works on Android

The process is actually quite elegant:

  1. Color Extraction: The system analyzes your wallpaper using the Monet color system
  2. Palette Generation: Using color science algorithms, it creates a full tonal palette
  3. Role Assignment: Colors are assigned to specific UI roles (more on this shortly)
  4. Adaptation: The scheme automatically adapts for light and dark themes

The beauty of the Material 3 colorScheme is that all this complexity is handled for you in Jetpack Compose. You just need to understand how to use it.

The Color Roles: Your New Best Friends

Here’s where Material 3 gets really interesting. Instead of thinking in terms of “primary,” “secondary,” and “tertiary” colors alone, the Material 3 colorScheme introduces color roles.

Think of color roles as jobs that colors perform in your UI. Let’s break down the main players:

Primary Colors

Primary: This is your brand color, the star of the show. It appears on prominent buttons and active states.

OnPrimary: Text and icons that sit on top of the primary color. The Material 3 colorScheme ensures this has enough contrast to be readable.

PrimaryContainer: A lighter (or darker in dark mode) version used for less prominent components.

OnPrimaryContainer: Text that appears on primary containers.

Secondary Colors

Secondary: Provides visual variety and highlights less prominent components.

OnSecondary: You guessed it — text on secondary colors.

SecondaryContainer: For chips, cards, and other contained elements.

OnSecondaryContainer: Text on those containers.

Tertiary Colors

Tertiary: Adds even more variety, often used for accents and special highlights.

OnTertiary, TertiaryContainer, OnTertiaryContainer: Following the same pattern.

Surface and Background Colors

Surface: The background color for cards, sheets, and menus.

OnSurface: Text and icons on surfaces.

SurfaceVariant: Alternative surface with subtle differences.

OnSurfaceVariant: Text with lower emphasis.

Background: The main background of your app.

OnBackground: Primary text on the background.

Error Colors

Error: For error states and destructive actions.

OnError: Text on error colors.

ErrorContainer: Background for error messages.

OnErrorContainer: Text in error containers.

Special Roles

Outline: Borders and dividers.

OutlineVariant: Subtle borders.

Scrim: Semi-transparent overlays.

InverseSurface, InverseOnSurface, InversePrimary: For high-contrast elements like tooltips.

SurfaceTint: Used for elevation overlays in Material 3.

Phew! That’s a lot of colors, right..? 

But here’s the magic: the Material 3 colorScheme generates all of these automatically, ensuring they work harmoniously together.

In short, a typical Material 3 colorScheme includes:

  • primary – main brand color
  • onPrimary – content placed on primary
  • secondary – supporting color
  • tertiary – optional accent color
  • background – app background
  • surface – cards and sheets
  • error – error states
  • onSurface – text/icons on surfaces

Each color has a paired onColor to guarantee readability.

This pairing is key to accessibility.

Setting Up Your Kotlin Project

Before we dive into code, let’s make sure your Android project is ready for Material 3.

Adding Dependencies

First, add the necessary dependencies to your build.gradle.kts (Module level):

Kotlin
dependencies {
    // Jetpack Compose BOM (Bill of Materials)
    implementation(platform("androidx.compose:compose-bom:2024.02.00"))
    
    // Material 3
    implementation("androidx.compose.material3:material3")
    
    // Other Compose dependencies
    implementation("androidx.compose.ui:ui")
    implementation("androidx.compose.ui:ui-tooling-preview")
    implementation("androidx.activity:activity-compose:1.8.2")
    
    // Core KTX
    implementation("androidx.core:core-ktx:1.12.0")
    
    // Lifecycle
    implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
}

What each dependency does:

  • compose-bom: Manages Compose versions automatically
  • material3: The Material 3 components and colorScheme system
  • ui and ui-tooling-preview: Core Compose UI and preview support
  • activity-compose: Integration with Android activities

Update Your Theme File

Create a new file called Theme.kt in your ui.theme package:

Kotlin
package com.softaai.myapp.ui.theme

import android.app.Activity
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat

@Composable
fun MyAppTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    dynamicColor: Boolean = true,
    content: @Composable () -> Unit
) {
    // We'll fill this in shortly!
}

This is your foundation. Now let’s build on it!

Implementing Material 3 ColorScheme in Kotlin

Let’s start with the simplest implementation and work our way up to more advanced features.

Basic Static ColorScheme

Here’s how to create a basic Material 3 colorScheme with custom colors:

Kotlin
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.ui.graphics.Color

// Light theme colors
private val LightColorScheme = lightColorScheme(
    primary = Color(0xFF6750A4),
    onPrimary = Color(0xFFFFFFFF),
    primaryContainer = Color(0xFFEADDFF),
    onPrimaryContainer = Color(0xFF21005D),
    secondary = Color(0xFF625B71),
    onSecondary = Color(0xFFFFFFFF),
    secondaryContainer = Color(0xFFE8DEF8),
    onSecondaryContainer = Color(0xFF1D192B),
    tertiary = Color(0xFF7D5260),
    onTertiary = Color(0xFFFFFFFF),
    tertiaryContainer = Color(0xFFFFD8E4),
    onTertiaryContainer = Color(0xFF31111D),
    error = Color(0xFFB3261E),
    onError = Color(0xFFFFFFFF),
    errorContainer = Color(0xFFF9DEDC),
    onErrorContainer = Color(0xFF410E0B),
    background = Color(0xFFFFFBFE),
    onBackground = Color(0xFF1C1B1F),
    surface = Color(0xFFFFFBFE),
    onSurface = Color(0xFF1C1B1F),
    surfaceVariant = Color(0xFFE7E0EC),
    onSurfaceVariant = Color(0xFF49454F),
    outline = Color(0xFF79747E),
    outlineVariant = Color(0xFFCAC4D0),
    scrim = Color(0xFF000000),
    inverseSurface = Color(0xFF313033),
    inverseOnSurface = Color(0xFFF4EFF4),
    inversePrimary = Color(0xFFD0BCFF),
)

// Dark theme colors
private val DarkColorScheme = darkColorScheme(
    primary = Color(0xFFD0BCFF),
    onPrimary = Color(0xFF381E72),
    primaryContainer = Color(0xFF4F378B),
    onPrimaryContainer = Color(0xFFEADDFF),
    secondary = Color(0xFFCCC2DC),
    onSecondary = Color(0xFF332D41),
    secondaryContainer = Color(0xFF4A4458),
    onSecondaryContainer = Color(0xFFE8DEF8),
    tertiary = Color(0xFFEFB8C8),
    onTertiary = Color(0xFF492532),
    tertiaryContainer = Color(0xFF633B48),
    onTertiaryContainer = Color(0xFFFFD8E4),
    error = Color(0xFFF2B8B5),
    onError = Color(0xFF601410),
    errorContainer = Color(0xFF8C1D18),
    onErrorContainer = Color(0xFFF9DEDC),
    background = Color(0xFF1C1B1F),
    onBackground = Color(0xFFE6E1E5),
    surface = Color(0xFF1C1B1F),
    onSurface = Color(0xFFE6E1E5),
    surfaceVariant = Color(0xFF49454F),
    onSurfaceVariant = Color(0xFFCAC4D0),
    outline = Color(0xFF938F99),
    outlineVariant = Color(0xFF49454F),
    scrim = Color(0xFF000000),
    inverseSurface = Color(0xFFE6E1E5),
    inverseOnSurface = Color(0xFF313033),
    inversePrimary = Color(0xFF6750A4),
)

Now let’s use these in your theme:

Kotlin
@Composable
fun MyAppTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    content: @Composable () -> Unit
) {
    val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme
    
    MaterialTheme(
        colorScheme = colorScheme,
        typography = Typography,
        content = content
    )
}

Here,

  • We defined two complete Material 3 colorScheme objects (light and dark)
  • The theme composable selects the appropriate scheme based on system settings
  • MaterialTheme applies the colorScheme to your entire app
  • Every color role is explicitly defined for maximum control

This is the manual approach. But there’s a much easier way..!

Dynamic Color: The Android 12+ Magic

Here’s where things get really exciting. On Android 12 (API 31) and above, you can use dynamic color to automatically match the user’s wallpaper.

Implementing Dynamic Color

Kotlin
@Composable
fun MyAppTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    dynamicColor: Boolean = true, // Enable/disable dynamic color
    content: @Composable () -> Unit
) {
    val context = LocalContext.current
    
    // Determine which colorScheme to use
    val colorScheme = when {
        // Use dynamic color if available (Android 12+)
        dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
            if (darkTheme) dynamicDarkColorScheme(context)
            else dynamicLightColorScheme(context)
        }
        // Fall back to custom dark colors
        darkTheme -> DarkColorScheme
        // Fall back to custom light colors
        else -> LightColorScheme
    }
    
    // Update the system bars to match theme
    val view = LocalView.current
    if (!view.isInEditMode) {
        SideEffect {
            val window = (view.context as Activity).window
            window.statusBarColor = colorScheme.surface.toArgb()
            WindowCompat.getInsetsController(window, view)
                .isAppearanceLightStatusBars = !darkTheme
        }
    }
    
    MaterialTheme(
        colorScheme = colorScheme,
        typography = Typography,
        content = content
    )
}

Let’s break down what’s happening:

  1. Version Check: We check if the device runs Android 12+ (API 31+)
  2. Dynamic ColorScheme: If supported, we use dynamicDarkColorScheme() or dynamicLightColorScheme()
  3. Fallback: On older devices, we fall back to our custom color schemes
  4. System Bars: We update the status bar color to match our theme
  5. Edge-to-Edge: The window insets controller adjusts the status bar appearance

That’s it..! 

Your app now supports dynamic theming with the Material 3 colorScheme.

Using the Theme in Your MainActivity

Don’t forget to wrap your content in the theme:

Kotlin
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            MyAppTheme {
                // Your app content here
                Surface(
                    modifier = Modifier.fillMaxSize(),
                    color = MaterialTheme.colorScheme.background
                ) {
                    ProfileScreen()
                }
            }
        }
    }
}

Important points:

  • MyAppTheme wraps all your composables
  • Surface uses colorScheme.background for the base color
  • Everything inside automatically has access to the Material 3 colorScheme

Accessing Colors in Your Composables

Now that you have your Material 3 colorScheme set up, how do you actually use these colors in your UI?

The Easy Way: Built-in Components

Material 3 components automatically use the appropriate colors from your colorScheme:

Kotlin
@Composable
fun AutomaticColorExample() {

    Column(
        modifier = Modifier.padding(16.dp),
        verticalArrangement = Arrangement.spacedBy(8.dp)
    ) {
        // Primary button - automatically uses primary color
        Button(onClick = { /* Do something */ }) {
            Text("Primary Button")
        }
        
        // Tonal button - uses primaryContainer
        FilledTonalButton(onClick = { /* Do something */ }) {
            Text("Tonal Button")
        }
        
        // Outlined button - uses outline color
        OutlinedButton(onClick = { /* Do something */ }) {
            Text("Outlined Button")
        }
        
        // Card - automatically uses surface color
        Card {
            Text(
                text = "This card uses surface colors",
                modifier = Modifier.padding(16.dp)
            )
        }
    }
}

No manual color assignment needed..! The Material 3 colorScheme handles it automatically.

The Manual Way: Direct Access

Sometimes you need direct access to specific colors:

Kotlin
@Composable
fun ManualColorExample() {
    // Access the current Material 3 colorScheme
    val colorScheme = MaterialTheme.colorScheme
    
    Box(
        modifier = Modifier
            .fillMaxWidth()
            .background(colorScheme.primaryContainer)
            .padding(16.dp)
    ) {
        Text(
            text = "Custom colored container",
            color = colorScheme.onPrimaryContainer,
            style = MaterialTheme.typography.headlineSmall
        )
    }
}

Key points:

  • Use MaterialTheme.colorScheme to access all color roles
  • Always pair surfaces with their corresponding “on” colors
  • The Material 3 colorScheme ensures all combinations are accessible

Building Real UI with Material 3 ColorScheme

Let’s create some practical examples that showcase the Material 3 colorScheme in action.

Custom Button with ColorScheme

Kotlin
@Composable
fun ThemedButton(
    text: String,
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
    isPrimary: Boolean = true
) {
    val colorScheme = MaterialTheme.colorScheme
    
    // Choose colors based on button type
    val backgroundColor = if (isPrimary) {
        colorScheme.primary
    } else {
        colorScheme.secondary
    }
    
    val contentColor = if (isPrimary) {
        colorScheme.onPrimary
    } else {
        colorScheme.onSecondary
    }
    
    Button(
        onClick = onClick,
        modifier = modifier,
        colors = ButtonDefaults.buttonColors(
            containerColor = backgroundColor,
            contentColor = contentColor
        )
    ) {
        Text(text)
    }
}

// Usage
@Composable
fun ButtonExample() {
    Column(
        modifier = Modifier.padding(16.dp),
        verticalArrangement = Arrangement.spacedBy(8.dp)
    ) {
        ThemedButton(
            text = "Primary Action",
            onClick = { /* Handle click */ },
            isPrimary = true
        )
        
        ThemedButton(
            text = "Secondary Action",
            onClick = { /* Handle click */ },
            isPrimary = false
        )
    }
}

Here,

  • We’re accessing the Material 3 colorScheme directly
  • Colors automatically adjust for light/dark mode and dynamic color
  • Accessibility is maintained through proper color role usage

Status Card with Conditional Colors

Kotlin
@Composable
fun StatusCard(
    title: String,
    message: String,
    isError: Boolean = false,
    modifier: Modifier = Modifier
) {
    val colorScheme = MaterialTheme.colorScheme
    
    // Choose colors based on status
    val containerColor = if (isError) {
        colorScheme.errorContainer
    } else {
        colorScheme.primaryContainer
    }
    
    val contentColor = if (isError) {
        colorScheme.onErrorContainer
    } else {
        colorScheme.onPrimaryContainer
    }
    
    val iconColor = if (isError) {
        colorScheme.error
    } else {
        colorScheme.primary
    }
    
    Card(
        modifier = modifier.fillMaxWidth(),
        colors = CardDefaults.cardColors(
            containerColor = containerColor
        )
    ) {
        Row(
            modifier = Modifier.padding(16.dp),
            horizontalArrangement = Arrangement.spacedBy(12.dp),
            verticalAlignment = Alignment.CenterVertically
        ) {
            Icon(
                imageVector = if (isError) {
                    Icons.Default.Error
                } else {
                    Icons.Default.CheckCircle
                },
                contentDescription = null,
                tint = iconColor,
                modifier = Modifier.size(32.dp)
            )
            
            Column {
                Text(
                    text = title,
                    style = MaterialTheme.typography.titleMedium,
                    color = contentColor,
                    fontWeight = FontWeight.Bold
                )
                Spacer(modifier = Modifier.height(4.dp))
                Text(
                    text = message,
                    style = MaterialTheme.typography.bodyMedium,
                    color = contentColor
                )
            }
        }
    }
}

// Usage
@Composable
fun StatusCardExample() {
    Column(
        modifier = Modifier.padding(16.dp),
        verticalArrangement = Arrangement.spacedBy(12.dp)
    ) {
        StatusCard(
            title = "Success",
            message = "Your profile has been updated successfully",
            isError = false
        )
        
        StatusCard(
            title = "Error",
            message = "Failed to save changes. Please try again.",
            isError = true
        )
    }
}

This example shows how the Material 3 colorScheme adapts to different UI states seamlessly.

Complete Profile Screen

Let’s build a realistic profile screen using the Material 3 colorScheme:

Kotlin
@Composable
fun ProfileScreen() {
    val colorScheme = MaterialTheme.colorScheme
    
    Scaffold(
        topBar = {
            TopAppBar(
                title = { Text("Profile") },
                colors = TopAppBarDefaults.topAppBarColors(
                    containerColor = colorScheme.surface,
                    titleContentColor = colorScheme.onSurface
                )
            )
        }
    ) { paddingValues ->
        Column(
            modifier = Modifier
                .fillMaxSize()
                .background(colorScheme.background)
                .padding(paddingValues)
                .padding(16.dp),
            verticalArrangement = Arrangement.spacedBy(16.dp)
        ) {
            // Profile Header Card
            Card(
                modifier = Modifier.fillMaxWidth(),
                colors = CardDefaults.cardColors(
                    containerColor = colorScheme.primaryContainer
                )
            ) {
                Column(
                    modifier = Modifier
                        .fillMaxWidth()
                        .padding(20.dp),
                    horizontalAlignment = Alignment.CenterHorizontally
                ) {
                    // Profile Image
                    Surface(
                        modifier = Modifier.size(100.dp),
                        shape = CircleShape,
                        color = colorScheme.primary
                    ) {
                        Icon(
                            imageVector = Icons.Default.Person,
                            contentDescription = "Profile Picture",
                            modifier = Modifier
                                .fillMaxSize()
                                .padding(20.dp),
                            tint = colorScheme.onPrimary
                        )
                    }
                    
                    Spacer(modifier = Modifier.height(16.dp))
                    
                    Text(
                        text = "Amol Pawar",
                        style = MaterialTheme.typography.headlineMedium,
                        color = colorScheme.onPrimaryContainer,
                        fontWeight = FontWeight.Bold
                    )
                    
                    Text(
                        text = "[email protected]",
                        style = MaterialTheme.typography.bodyMedium,
                        color = colorScheme.onPrimaryContainer.copy(alpha = 0.8f)
                    )
                }
            }
            
            // Settings Section
            Card(
                modifier = Modifier.fillMaxWidth(),
                colors = CardDefaults.cardColors(
                    containerColor = colorScheme.surface
                )
            ) {
                Column {
                    SettingsItem(
                        icon = Icons.Default.Notifications,
                        title = "Notifications",
                        onClick = { /* Handle click */ }
                    )
                    
                    HorizontalDivider(color = colorScheme.outlineVariant)
                    
                    SettingsItem(
                        icon = Icons.Default.Security,
                        title = "Privacy & Security",
                        onClick = { /* Handle click */ }
                    )
                    
                    HorizontalDivider(color = colorScheme.outlineVariant)
                    
                    SettingsItem(
                        icon = Icons.Default.Help,
                        title = "Help & Support",
                        onClick = { /* Handle click */ }
                    )
                }
            }
            
            // Action Buttons
            Button(
                onClick = { /* Edit profile */ },
                modifier = Modifier.fillMaxWidth(),
                colors = ButtonDefaults.buttonColors(
                    containerColor = colorScheme.primary,
                    contentColor = colorScheme.onPrimary
                )
            ) {
                Text("Edit Profile")
            }
            
            OutlinedButton(
                onClick = { /* Sign out */ },
                modifier = Modifier.fillMaxWidth(),
                colors = ButtonDefaults.outlinedButtonColors(
                    contentColor = colorScheme.error
                ),
                border = BorderStroke(1.dp, colorScheme.error)
            ) {
                Text("Sign Out")
            }
        }
    }
}

@Composable
fun SettingsItem(
    icon: ImageVector,
    title: String,
    onClick: () -> Unit
) {
    val colorScheme = MaterialTheme.colorScheme
    
    Surface(
        onClick = onClick,
        color = Color.Transparent
    ) {
        Row(
            modifier = Modifier
                .fillMaxWidth()
                .padding(16.dp),
            horizontalArrangement = Arrangement.SpaceBetween,
            verticalAlignment = Alignment.CenterVertically
        ) {
            Row(
                horizontalArrangement = Arrangement.spacedBy(16.dp),
                verticalAlignment = Alignment.CenterVertically
            ) {
                Icon(
                    imageVector = icon,
                    contentDescription = null,
                    tint = colorScheme.primary
                )
                Text(
                    text = title,
                    style = MaterialTheme.typography.bodyLarge,
                    color = colorScheme.onSurface
                )
            }
            
            Icon(
                imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
                contentDescription = "Navigate",
                tint = colorScheme.onSurfaceVariant
            )
        }
    }
}
  • Every color comes from the Material 3 colorScheme
  • Semantic color names ensure accessibility
  • Works perfectly in light and dark modes
  • Adapts to dynamic colors automatically
  • Zero hard-coded color values

Advanced ColorScheme Techniques

Creating Color Variants

Sometimes you need variations of your Material 3 colorScheme colors:

Kotlin
@Composable
fun ColorVariantExample() {
    val colorScheme = MaterialTheme.colorScheme
    
    // Create lighter or darker variants using alpha
    val primaryLight = colorScheme.primary.copy(alpha = 0.1f)
    val primaryDark = colorScheme.primary.copy(alpha = 0.9f)
    
    Column(
        modifier = Modifier.padding(16.dp),
        verticalArrangement = Arrangement.spacedBy(8.dp)
    ) {
        // Light variant
        Surface(
            modifier = Modifier
                .fillMaxWidth()
                .height(50.dp),
            color = primaryLight
        ) {
            Box(contentAlignment = Alignment.Center) {
                Text(
                    "Light Variant",
                    color = colorScheme.onSurface
                )
            }
        }
        
        // Original color
        Surface(
            modifier = Modifier
                .fillMaxWidth()
                .height(50.dp),
            color = colorScheme.primary
        ) {
            Box(contentAlignment = Alignment.Center) {
                Text(
                    "Original Primary",
                    color = colorScheme.onPrimary
                )
            }
        }
        
        // Dark variant
        Surface(
            modifier = Modifier
                .fillMaxWidth()
                .height(50.dp),
            color = primaryDark
        ) {
            Box(contentAlignment = Alignment.Center) {
                Text(
                    "Dark Variant",
                    color = colorScheme.onPrimary
                )
            }
        }
    }
}

Composing with Surface Tint

Material 3 introduces surface tint for elevation. Here’s how to use it:

Kotlin
@Composable
fun ElevatedCardExample() {
    val colorScheme = MaterialTheme.colorScheme
    
    Column(
        modifier = Modifier.padding(16.dp),
        verticalArrangement = Arrangement.spacedBy(12.dp)
    ) {
        // Card with different elevation levels
        listOf(0.dp, 2.dp, 4.dp, 6.dp).forEach { elevation ->
            Card(
                modifier = Modifier.fillMaxWidth(),
                elevation = CardDefaults.cardElevation(
                    defaultElevation = elevation
                ),
                colors = CardDefaults.cardColors(
                    containerColor = colorScheme.surface
                )
            ) {
                Text(
                    text = "Elevation: $elevation",
                    modifier = Modifier.padding(16.dp),
                    color = colorScheme.onSurface
                )
            }
        }
    }
}

The Material 3 colorScheme automatically applies surfaceTint (usually the primary color) to create subtle elevation effects.

Creating Theme Toggle

Let users switch between light and dark themes:

Kotlin
@Composable
fun ThemeToggleExample() {
    var isDarkTheme by remember { mutableStateOf(false) }
    
    MyAppTheme(darkTheme = isDarkTheme) {
        val colorScheme = MaterialTheme.colorScheme
        
        Scaffold { paddingValues ->
            Column(
                modifier = Modifier
                    .fillMaxSize()
                    .padding(paddingValues)
                    .background(colorScheme.background)
                    .padding(16.dp)
            ) {
                Card(
                    modifier = Modifier.fillMaxWidth(),
                    colors = CardDefaults.cardColors(
                        containerColor = colorScheme.surfaceVariant
                    )
                ) {
                    Row(
                        modifier = Modifier
                            .fillMaxWidth()
                            .padding(16.dp),
                        horizontalArrangement = Arrangement.SpaceBetween,
                        verticalAlignment = Alignment.CenterVertically
                    ) {
                        Row(
                            horizontalArrangement = Arrangement.spacedBy(12.dp),
                            verticalAlignment = Alignment.CenterVertically
                        ) {
                            Icon(
                                imageVector = if (isDarkTheme) {
                                    Icons.Default.DarkMode
                                } else {
                                    Icons.Default.LightMode
                                },
                                contentDescription = null,
                                tint = colorScheme.onSurfaceVariant
                            )
                            Text(
                                text = "Dark Mode",
                                style = MaterialTheme.typography.bodyLarge,
                                color = colorScheme.onSurfaceVariant
                            )
                        }
                        
                        Switch(
                            checked = isDarkTheme,
                            onCheckedChange = { isDarkTheme = it },
                            colors = SwitchDefaults.colors(
                                checkedThumbColor = colorScheme.primary,
                                checkedTrackColor = colorScheme.primaryContainer
                            )
                        )
                    }
                }
            }
        }
    }
}

Material Theme Builder Integration

Want to generate a complete Material 3 colorScheme visually..? Google provides an amazing tool called Material Theme Builder.

Using Material Theme Builder

  1. Visit Material Theme Builder
  2. Choose your primary color or upload an image
  3. Customize secondary and tertiary colors if desired
  4. Click “Export” and select “Jetpack Compose (Theme.kt)”

The tool generates complete Kotlin code.

Just copy this code into your Theme.kt file and you’re done..!

Pro tip: The Material Theme Builder ensures all colors are harmonious and accessible, saving you hours of manual color picking.

Best Practices for Material 3 ColorScheme

1. Always Use Semantic Names

Use the semantic color roles instead of hard-coded values:

Kotlin
// Good: Uses Material 3 colorScheme
Box(
    modifier = Modifier.background(MaterialTheme.colorScheme.surface)
)

// Bad: Hard-coded color
Box(
    modifier = Modifier.background(Color.White)
)

Why this matters: Semantic names adapt automatically to light/dark mode and dynamic color.

2. Pair Colors Correctly

Always use the “on” variant for text on colored surfaces:

Kotlin
// Good: Proper pairing ensures readability
Card(
    colors = CardDefaults.cardColors(
        containerColor = colorScheme.primaryContainer
    )
) {
    Text(
        text = "Accessible text",
        color = colorScheme.onPrimaryContainer
    )
}

// Bad: Might have contrast issues
Card(
    colors = CardDefaults.cardColors(
        containerColor = colorScheme.primaryContainer
    )
) {
    Text(
        text = "Poor contrast",
        color = colorScheme.secondary // Wrong pairing!
    )
}

3. Provide Dynamic Color Fallbacks

Always have a backup for devices that don’t support dynamic color:

Kotlin
val colorScheme = when {
    dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
        if (darkTheme) dynamicDarkColorScheme(context)
        else dynamicLightColorScheme(context)
    }
    darkTheme -> DarkColorScheme // Fallback
    else -> LightColorScheme     // Fallback
}

4. Test Both Themes

Always test your UI in both light and dark modes:

Kotlin
@Preview(
    name = "Light Mode",
    showBackground = true
)
@Preview(
    name = "Dark Mode",
    showBackground = true,
    uiMode = Configuration.UI_MODE_NIGHT_YES
)
@Composable
fun PreviewProfileScreen() {
    MyAppTheme {
        ProfileScreen()
    }
}

5. Leverage Preview Parameters

Use preview parameters to test different scenarios:

Kotlin
@Preview(name = "Light - No Dynamic Color", showBackground = true)
@Composable
fun LightStaticPreview() {
    MyAppTheme(darkTheme = false, dynamicColor = false) {
        MyComposable()
    }
}

@Preview(name = "Dark - No Dynamic Color", showBackground = true)
@Composable
fun DarkStaticPreview() {
    MyAppTheme(darkTheme = true, dynamicColor = false) {
        MyComposable()
    }
}

Understanding Color Harmonization

The Material 3 colorScheme uses advanced color science to ensure harmony. Here’s what happens under the hood:

HCT Color Space

Material 3 uses HCT (Hue, Chroma, Tone) instead of RGB or HSL:

  • Hue: The color type (0–360 degrees)
  • Chroma: The colorfulness or saturation (0–120+)
  • Tone: The perceived lightness (0–100)

Tonal Palettes

When you provide a seed color, the Material 3 colorScheme:

  1. Converts it to HCT color space
  2. Generates tonal palettes at specific chroma levels
  3. Maps tones to color roles based on their purpose
  4. Ensures all combinations meet WCAG contrast requirements

Example of Tone Mapping

Kotlin
Primary Palette (High Chroma):
- primary: tone 40 (light) / tone 80 (dark)
- onPrimary: tone 100 (light) / tone 20 (dark)
- primaryContainer: tone 90 (light) / tone 30 (dark)

Neutral Palette (Low Chroma):
- surface: tone 99 (light) / tone 10 (dark)
- onSurface: tone 10 (light) / tone 90 (dark)
Tonal Palettes (Light Theme)

You don’t need to understand all the math — just know that it works beautifully..!

Troubleshooting Common Issues

Issue 1: Colors Not Updating

Problem: Changes to colorScheme don’t appear in the UI.

Solution: Make sure you’re wrapping your content in the theme:

Kotlin
setContent {
    MyAppTheme {  // Essential wrapper!
        Surface(
            modifier = Modifier.fillMaxSize(),
            color = MaterialTheme.colorScheme.background
        ) {
            MyApp()
        }
    }
}

Issue 2: Dynamic Colors Not Working

Problem: App doesn’t match wallpaper colors.

Solution: Check these points:

  1. Device Version: Dynamic color requires Android 12+ (API 31+)
  2. Feature Flag: Ensure dynamicColor = true in your theme
  3. Wallpaper: Try changing your wallpaper to trigger update
  4. Build Version: Verify Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
Kotlin
// Debug logging
val colorScheme = when {
    dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
        Log.d("Theme", "Using dynamic colors from wallpaper")
        if (darkTheme) dynamicDarkColorScheme(context)
        else dynamicLightColorScheme(context)
    }
    else -> {
        Log.d("Theme", "Using static fallback colors")
        if (darkTheme) DarkColorScheme else LightColorScheme
    }
}

Issue 3: Wrong Colors in Previews

Problem: Compose previews show incorrect or default colors.

Solution: Always wrap preview content in your theme:

Kotlin
@Preview
@Composable
fun MyComposablePreview() {
    MyAppTheme {  // Never forget the theme wrapper!
        MyComposable()
    }
}

Issue 4: System Bars Not Matching Theme

Problem: Status bar and navigation bar don’t match app theme.

Solution: Update system bars in your theme composable:

Kotlin
val view = LocalView.current
if (!view.isInEditMode) {
    SideEffect {
        val window = (view.context as Activity).window
        // Set status bar color
        window.statusBarColor = colorScheme.surface.toArgb()
        // Set navigation bar color
        window.navigationBarColor = colorScheme.surface.toArgb()
        // Adjust icon colors
        WindowCompat.getInsetsController(window, view).apply {
            isAppearanceLightStatusBars = !darkTheme
            isAppearanceLightNavigationBars = !darkTheme
        }
    }
}

Issue 5: Gradle Build Errors

Problem: Cannot resolve Material 3 symbols.

Solution: Ensure you have the correct dependencies:

Kotlin
dependencies {
    // Use BOM for version management
    implementation(platform("androidx.compose:compose-bom:2024.02.00"))
    implementation("androidx.compose.material3:material3")
    
    // Or specify version explicitly
    implementation("androidx.compose.material3:material3:1.2.0")
}

Complete Working Example

Here’s a complete, copy-paste-ready example:

build.gradle.kts (Module)

Kotlin
plugins {
    id("com.android.application")
    id("org.jetbrains.kotlin.android")
}

android {
    namespace = "com.softaai.myapp"
    compileSdk = 34
    
    defaultConfig {
        applicationId = "com.softaai.myapp"
        minSdk = 21
        targetSdk = 34
        versionCode = 1
        versionName = "1.0"
    }
    
    buildFeatures {
        compose = true
    }
    
    composeOptions {
        kotlinCompilerExtensionVersion = "1.5.8"
    }
    
    kotlinOptions {
        jvmTarget = "1.8"
    }
}

dependencies {
    implementation(platform("androidx.compose:compose-bom:2024.02.00"))
    implementation("androidx.compose.material3:material3")
    implementation("androidx.compose.ui:ui")
    implementation("androidx.compose.ui:ui-tooling-preview")
    implementation("androidx.activity:activity-compose:1.8.2")
    implementation("androidx.core:core-ktx:1.12.0")
    implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
    
    debugImplementation("androidx.compose.ui:ui-tooling")
}

Theme.kt

Kotlin
package com.softaai.myapp.ui.theme

import android.app.Activity
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat

private val LightColorScheme = lightColorScheme(
    primary = Color(0xFF6750A4),
    onPrimary = Color(0xFFFFFFFF),
    primaryContainer = Color(0xFFEADDFF),
    onPrimaryContainer = Color(0xFF21005D),
    secondary = Color(0xFF625B71),
    onSecondary = Color(0xFFFFFFFF),
    secondaryContainer = Color(0xFFE8DEF8),
    onSecondaryContainer = Color(0xFF1D192B),
    tertiary = Color(0xFF7D5260),
    onTertiary = Color(0xFFFFFFFF),
    tertiaryContainer = Color(0xFFFFD8E4),
    onTertiaryContainer = Color(0xFF31111D),
    error = Color(0xFFB3261E),
    onError = Color(0xFFFFFFFF),
    errorContainer = Color(0xFFF9DEDC),
    onErrorContainer = Color(0xFF410E0B),
    background = Color(0xFFFFFBFE),
    onBackground = Color(0xFF1C1B1F),
    surface = Color(0xFFFFFBFE),
    onSurface = Color(0xFF1C1B1F),
    surfaceVariant = Color(0xFFE7E0EC),
    onSurfaceVariant = Color(0xFF49454F),
    outline = Color(0xFF79747E),
    outlineVariant = Color(0xFFCAC4D0),
    scrim = Color(0xFF000000),
    inverseSurface = Color(0xFF313033),
    inverseOnSurface = Color(0xFFF4EFF4),
    inversePrimary = Color(0xFFD0BCFF),
)

private val DarkColorScheme = darkColorScheme(
    primary = Color(0xFFD0BCFF),
    onPrimary = Color(0xFF381E72),
    primaryContainer = Color(0xFF4F378B),
    onPrimaryContainer = Color(0xFFEADDFF),
    secondary = Color(0xFFCCC2DC),
    onSecondary = Color(0xFF332D41),
    secondaryContainer = Color(0xFF4A4458),
    onSecondaryContainer = Color(0xFFE8DEF8),
    tertiary = Color(0xFFEFB8C8),
    onTertiary = Color(0xFF492532),
    tertiaryContainer = Color(0xFF633B48),
    onTertiaryContainer = Color(0xFFFFD8E4),
    error = Color(0xFFF2B8B5),
    onError = Color(0xFF601410),
    errorContainer = Color(0xFF8C1D18),
    onErrorContainer = Color(0xFFF9DEDC),
    background = Color(0xFF1C1B1F),
    onBackground = Color(0xFFE6E1E5),
    surface = Color(0xFF1C1B1F),
    onSurface = Color(0xFFE6E1E5),
    surfaceVariant = Color(0xFF49454F),
    onSurfaceVariant = Color(0xFFCAC4D0),
    outline = Color(0xFF938F99),
    outlineVariant = Color(0xFF49454F),
    scrim = Color(0xFF000000),
    inverseSurface = Color(0xFFE6E1E5),
    inverseOnSurface = Color(0xFF313033),
    inversePrimary = Color(0xFF6750A4),
)

@Composable
fun MyAppTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    dynamicColor: Boolean = true,
    content: @Composable () -> Unit
) {
    val context = LocalContext.current
    
    val colorScheme = when {
        dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
            if (darkTheme) dynamicDarkColorScheme(context)
            else dynamicLightColorScheme(context)
        }
        darkTheme -> DarkColorScheme
        else -> LightColorScheme
    }
    
    val view = LocalView.current
    if (!view.isInEditMode) {
        SideEffect {
            val window = (view.context as Activity).window
            window.statusBarColor = colorScheme.surface.toArgb()
            window.navigationBarColor = colorScheme.surface.toArgb()
            WindowCompat.getInsetsController(window, view).apply {
                isAppearanceLightStatusBars = !darkTheme
                isAppearanceLightNavigationBars = !darkTheme
            }
        }
    }
    
    MaterialTheme(
        colorScheme = colorScheme,
        typography = Typography,
        content = content
    )
}

MainActivity.kt

Kotlin
package com.softaai.myapp

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
import com.softaai.myapp.ui.theme.MyAppTheme
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            MyAppTheme {
                Surface(
                    modifier = Modifier.fillMaxSize(),
                    color = MaterialTheme.colorScheme.background
                ) {
                    // Your app content here
                    ProfileScreen()
                }
            }
        }
    }
}

Quick Reference Guide

Essential ColorScheme Properties

Kotlin
val colorScheme = MaterialTheme.colorScheme

// Primary colors
colorScheme.primary                // Main brand color
colorScheme.onPrimary              // Text on primary
colorScheme.primaryContainer       // Lighter primary variant
colorScheme.onPrimaryContainer     // Text on primary container

// Secondary colors
colorScheme.secondary              // Secondary accent
colorScheme.onSecondary            // Text on secondary
colorScheme.secondaryContainer     // Lighter secondary
colorScheme.onSecondaryContainer   // Text on secondary container

// Tertiary colors
colorScheme.tertiary               // Third accent color
colorScheme.onTertiary             // Text on tertiary
colorScheme.tertiaryContainer      // Lighter tertiary
colorScheme.onTertiaryContainer    // Text on tertiary container

// Surface and background
colorScheme.surface                // Card/sheet background
colorScheme.onSurface              // Text on surfaces
colorScheme.surfaceVariant         // Alternative surface
colorScheme.onSurfaceVariant       // Text on surface variant
colorScheme.background             // Main app background
colorScheme.onBackground           // Text on background

// Error colors
colorScheme.error                  // Error state color
colorScheme.onError                // Text on error
colorScheme.errorContainer         // Error message background
colorScheme.onErrorContainer       // Text in error messages

// Utility colors
colorScheme.outline                // Borders and dividers
colorScheme.outlineVariant         // Subtle borders
colorScheme.surfaceTint            // Elevation tint (usually primary)
colorScheme.scrim                  // Semi-transparent overlays

// Inverse colors (for tooltips, snackbars)
colorScheme.inverseSurface         // High-contrast surface
colorScheme.inverseOnSurface       // Text on inverse surface
colorScheme.inversePrimary         // Primary color on inverse surface

Common Usage Patterns

Kotlin
// Primary button
Button(
    onClick = { },
    colors = ButtonDefaults.buttonColors(
        containerColor = MaterialTheme.colorScheme.primary,
        contentColor = MaterialTheme.colorScheme.onPrimary
    )
) {
    Text("Click Me")
}

// Surface card
Card(
    colors = CardDefaults.cardColors(
        containerColor = MaterialTheme.colorScheme.surface
    )
) {
    Text(
        text = "Card Content",
        color = MaterialTheme.colorScheme.onSurface,
        modifier = Modifier.padding(16.dp)
    )
}

// Custom background
Box(
    modifier = Modifier
        .background(MaterialTheme.colorScheme.primaryContainer)
        .padding(16.dp)
) {
    Text(
        text = "Custom Container",
        color = MaterialTheme.colorScheme.onPrimaryContainer
    )
}

// Error state
Surface(
    color = MaterialTheme.colorScheme.errorContainer,
    modifier = Modifier.padding(8.dp)
) {
    Text(
        text = "Error message",
        color = MaterialTheme.colorScheme.onErrorContainer,
        modifier = Modifier.padding(16.dp)
    )
}

Conclusion

The Material 3 colorScheme is a game-changer for Android app theming. It takes the complexity out of color design and gives you a robust, accessible, and beautiful color system right out of the box.

Here’s what we’ve covered:

Understanding: The Material 3 colorScheme generates complete, harmonious palettes from seed colors or wallpaper

Color Roles: Semantic color names ensure accessibility and visual consistency

Implementation: Simple Kotlin setup with Jetpack Compose and dynamic color support

Dynamic Color: Apps automatically match user wallpapers on Android 12+ for personalized experiences

Best Practices: Use semantic colors, test both themes, provide fallbacks, and optimize performance

Real Examples: Production-ready Kotlin code you can use in your projects immediately

Troubleshooting: Solutions to common issues developers face

The beauty of the Material 3 colorScheme is that it makes professional color design accessible to everyone. You don’t need to be a color theory expert to create stunning, accessible Android apps.

Getting Started Today

Start your next Android project by:

  1. Adding Dependencies: Include Material 3 in your build.gradle.kts
  2. Creating Your Theme: Set up lightColorScheme() and darkColorScheme() in Theme.kt
  3. Enabling Dynamic Color: Support Android 12+ wallpaper theming
  4. Using Semantic Colors: Reference MaterialTheme.colorScheme throughout your composables
  5. Testing Thoroughly: Preview in both light and dark modes

The Material 3 colorScheme handles the complexity of color science, accessibility, and harmonization so you can focus on building amazing user experiences. And that’s what makes it so powerful.

Additional Resources

Official Documentation:

Tools:

Sample Projects:

Now go build something beautiful with the Material 3 colorScheme in Kotlin..!

Mermaid

What Is Mermaid? A Complete Guide to the Text-Based Diagramming Language Developers Love

Diagrams are essential in software development. They help explain system architecture, workflows, data flow, and logic in ways plain text cannot. But traditional diagram tools can be slow, visual-only, and hard to maintain.

That’s where Mermaid plays an important role.

Mermaid is a text-based diagramming language that lets developers create diagrams using simple, readable syntax. Instead of dragging boxes and arrows, you write text. Mermaid turns that text into clean, professional diagrams automatically.

In this guide, you’ll learn what Mermaid is, how it works, why developers love it, and how to start using it with real examples.

What Is Mermaid?

Mermaid is an open-source JavaScript-based diagramming and charting tool that allows you to generate diagrams from plain text.

You describe a diagram using Mermaid syntax, and Mermaid renders it as a visual diagram.

In simple terms:

Text in → Diagram out

Mermaid is widely used by developers, technical writers, DevOps engineers, and product teams because it fits naturally into code-driven workflows.

Why Developers Prefer Mermaid

Mermaid solves many problems that traditional diagram tools create.

1. Diagrams as Code

With Mermaid, diagrams live next to your code. That means:

  • You can store diagrams in Git
  • Track changes with version control
  • Review diagrams in pull requests
  • Update diagrams as easily as text

No more outdated architecture diagrams.

2. Simple and Readable Syntax

Mermaid syntax is designed to be easy to read, even if you’ve never used it before.

Here’s a basic example:

Mermaid
graph TD
    A[User] --> B[Web App]
    B --> C[Database]

Even without knowing Mermaid, you can understand what this diagram does.

3. Works Everywhere Developers Work

Mermaid integrates with many popular tools, including:

  • Markdown files
  • GitHub
  • GitLab
  • Notion
  • Obsidian
  • VS Code
  • Documentation platforms

If you already write Markdown, you’re halfway there.

How Mermaid Works

Mermaid follows a simple process:

  1. You write Mermaid syntax
  2. The Mermaid engine parses the text
  3. The diagram is rendered visually

The source remains readable text, which makes Mermaid ideal for long-term documentation.

Common Diagram Types Supported by Mermaid

Mermaid supports a wide range of diagram types used in real-world development.

Let’s go through the most popular ones.

Flowcharts in Mermaid

Flowcharts are one of the most common uses of Mermaid.

Basic Flowchart

Mermaid
flowchart TD
    Start --> Check{Is user logged in?}
    Check -->|Yes| Dashboard
    Check -->|No| Login
  • flowchart TD means top-to-bottom layout
  • Curly braces {} define a decision
  • |Yes| and |No| label arrows

This makes Mermaid perfect for explaining logic and user flows.

Sequence Diagrams in Mermaid

Sequence diagrams show how different systems interact over time.

API Request Flow

Mermaid
sequenceDiagram
    User ->> Frontend: Clicks "Submit"
    Frontend ->> Backend: Send API request
    Backend ->> Database: Query data
    Database -->> Backend: Return result
    Backend -->> Frontend: Response
  • Arrows show communication
  • ->> is a request
  • -->> is a response

Mermaid sequence diagrams are excellent for backend and API documentation.

Class Diagrams in Mermaid

Class diagrams are useful in object-oriented design.

Simple Class Diagram

Mermaid
classDiagram
    class User {
        +String name
        +String email
        +login()
    }

    class Order {
        +int orderId
        +float total
    }

    User "1" --> "many" Order
  • Classes are defined with attributes and methods
  • Relationships are easy to read
  • Works well for system design docs

State Diagrams in Mermaid

State diagrams show how something changes over time.

Order Status

Mermaid
stateDiagram-v2
    [*] --> Pending
    Pending --> Paid
    Paid --> Shipped
    Shipped --> Delivered

This is commonly used in workflow and business logic documentation.

Gantt Charts in Mermaid

Mermaid can also create project timelines.

Gantt Chart

Mermaid
gantt
    title Project Timeline
    dateFormat YYYY-MM-DD
    section Development
    Planning :done, 2026-03-01, 5d
    Coding :active, 2026-03-06, 10d
    Testing : 2026-03-16, 5d

This is useful for lightweight planning directly inside documentation.

Where You Can Use Mermaid

Mermaid works in many real-world environments.

Popular Platforms That Support Mermaid

  • GitHub Markdown
  • GitLab README files
  • Notion
  • Obsidian
  • VS Code (with extensions)
  • Static site generators
  • Internal documentation tools

This makes Mermaid ideal for teams that value documentation quality.

Mermaid vs Traditional Diagram Tools

Mermaid wins when documentation needs to stay accurate and maintainable.

Best Practices for Using Mermaid

To get the most out of Mermaid, follow these tips:

  • Keep diagrams simple and focused
  • Use clear labels
  • Avoid overloading one diagram
  • Store Mermaid diagrams close to related code
  • Treat diagrams as part of the development process

Is Mermaid Hard to Learn?

Not at all.

Most developers learn Mermaid basics in under an hour. Since the syntax is readable, you can often understand diagrams without knowing Mermaid at all.

That’s one reason Mermaid adoption keeps growing.

Why Mermaid Aligns with Modern Documentation Standards

Mermaid fits naturally into modern documentation practices, including:

  • Docs-as-code workflows
  • Developer experience (DX)
  • Agile and DevOps practices
  • AI-assisted documentation
  • Search-friendly, structured content

Because Mermaid diagrams are text-based, they integrate seamlessly with version-controlled documentation and are more accessible to AI tools than image-based diagrams. This makes them better suited for indexing, analysis, and automated summarization within modern documentation workflows.

Conclusion

Mermaid changes how developers think about diagrams.

Instead of treating diagrams as static images, Mermaid makes them living documentation. They evolve with your code, stay accurate, and remain easy to maintain.

If you care about clean documentation, team collaboration, and long-term clarity, Mermaid is worth learning.

Once you start using Mermaid, it’s hard to go back.

Compose Preview

Compose Preview Explained: How It Works, Why It Matters, and Where It Falls Short

If you’re building Android apps with Jetpack Compose, chances are you’ve already used Compose Preview. Or at least clicked the little Preview tab in Android Studio and hoped it would magically show your UI.

Sometimes it does.
Sometimes it doesn’t.

In this blog, we’ll break down Compose Preview, covering everything from core mechanics to practical tips. You’ll learn:

  • What Compose Preview actually is
  • How it works under the hood
  • Why it matters for real-world development
  • Where it struggles and why
  • When to trust it and when not to

Let’s start with the basics.

What Is Compose Preview?

Compose Preview is a design-time tool in Android Studio that lets you see your Jetpack Compose UI without running the app on a device or emulator.

It renders composable functions directly inside the IDE.

That means:

  • Faster feedback
  • No APK install
  • No waiting for Gradle every time you tweak padding or text size

In short, Compose Preview helps you design UI faster.

A Simple Compose Preview Example

Let’s start with a basic example.

Kotlin
@Composable
fun Greeting(name: String) {
    Text(text = "Hello, $name!")
}

This composable works, but Android Studio can’t preview it yet. Why?

Because Greeting needs a parameter.

That’s where Compose Preview comes in.

Adding a Preview Function

Kotlin
@Preview(showBackground = true)
@Composable
fun GreetingPreview() {
    Greeting(name = "Android")
}
  • @Preview tells Android Studio: Render this composable
  • showBackground = true adds a white background so text is readable
  • GreetingPreview() supplies sample data ("Android")

This preview function is not used in production.
It exists only for design-time visualization.

That’s an important detail many beginners miss.

How Compose Preview Works Behind the Scenes

Compose Preview does not run your full app.

Instead, Android Studio:

  1. Compiles the composable function
  2. Runs it in a special design-time environment
  3. Skips most Android framework components
  4. Renders the UI using sample data

That’s why previews are fast.

And that’s also why they’re limited.

Why Compose Preview Matters So Much

1. Faster UI Iteration

With Compose Preview, you can:

  • Adjust spacing
  • Change colors
  • Try different text styles
  • Experiment with layouts

All without touching an emulator.

For UI-heavy screens, this saves hours over time.

2. Encourages Smaller, Cleaner Composables

Compose Preview works best with small, focused composables.

That naturally pushes you toward:

  • Better separation of concerns
  • Reusable UI components
  • Clearer code structure

This directly improves long-term maintainability.

3. Better Design Collaboration

Designers and developers can:

  • Review UI changes quickly
  • Compare states side by side
  • Validate layouts early

Compose Preview becomes a shared visual language.

Advanced Compose Preview Features You Should Know

Beyond basic previews, several advanced features make Compose Preview even more powerful.

Preview with Different Device Configurations

The @Preview annotation accepts parameters that let you simulate different devices, screen sizes, and system settings.

Kotlin
@Preview(
    name = "Small phone",
    device = Devices.PIXEL_3A,
    showSystemUi = true
)
@Preview(
    name = "Large phone",
    device = Devices.PIXEL_7_PRO,
    showSystemUi = true
)
@Preview(
    name = "Tablet",
    device = Devices.PIXEL_TABLET,
    showSystemUi = true
)
@Preview(
    name = "Foldable",
    device = Devices.FOLDABLE,
    showSystemUi = true
)
@Preview(
    name = "Landscape",
    device = Devices.PIXEL_7_PRO,
    widthDp = 891,
    heightDp = 411
)
@Preview(
    name = "Dark Theme",
    uiMode = Configuration.UI_MODE_NIGHT_YES,
    showBackground = true
)
@Preview(showBackground = true)
@Composable
fun ResponsiveLayoutPreview() {
    MaterialTheme {
        Surface(
            modifier = Modifier.fillMaxSize(),
            tonalElevation = 4.dp
        ) {
            Column(
                modifier = Modifier
                    .fillMaxSize()
                    .padding(24.dp),
                verticalArrangement = Arrangement.spacedBy(20.dp)
            ) {

                // Header
                Text(
                    text = "Responsive UI",
                    style = MaterialTheme.typography.headlineMedium,
                    fontWeight = FontWeight.Bold
                )

                Text(
                    text = "Adaptive layouts across form factors",
                    style = MaterialTheme.typography.bodyMedium,
                    color = MaterialTheme.colorScheme.onSurfaceVariant
                )

                Divider()

                Column(
                    verticalArrangement = Arrangement.spacedBy(12.dp)
                ) {
                    FeatureRow("Phones", "Compact & large screens")
                    FeatureRow("Tablets", "Expanded content layouts")
                    FeatureRow("Foldables", "Posture-aware UI")
                    FeatureRow("Themes", "Light & Dark mode ready")
                }
            }
        }
    }
}

@Composable
private fun FeatureRow(
    title: String,
    subtitle: String
) {
    Column {
        Text(
            text = title,
            style = MaterialTheme.typography.titleMedium,
            fontWeight = FontWeight.SemiBold
        )
        Text(
            text = subtitle,
            style = MaterialTheme.typography.bodySmall,
            color = MaterialTheme.colorScheme.onSurfaceVariant
        )
    }
}

Let me break down what’s happening here:

  • device = Devices.PIXEL_7_PRO: This tells Compose Preview to render your composable as if it’s running on a Pixel 7 Pro device, matching that specific screen size and dimensions.
  • showSystemUi = true: This parameter displays the system UI elements like the status bar and navigation bar, giving you a more realistic preview of how your app will look.
  • uiMode = Configuration.UI_MODE_NIGHT_YES: This simulates dark mode, letting you verify that your colors and themes work properly in both light and dark settings.

You can stack multiple @Preview annotations on the same function to see all these variations simultaneously.

Preview Parameters for Dynamic Content

Sometimes you want to test your composables with different data sets. The @PreviewParameter annotation helps with this.

Kotlin
class UserStateProvider : PreviewParameterProvider<Boolean> {
    override val values = sequenceOf(true, false)
}

@Preview(showBackground = true)
@Composable
fun StatusBadgePreview(
    @PreviewParameter(UserStateProvider::class) isActive: Boolean
) {
    Box(
        modifier = Modifier
            .size(100.dp)
            .background(
                color = if (isActive) Color.Green else Color.Red,
                shape = CircleShape
            ),
        contentAlignment = Alignment.Center
    ) {
        Text(
            text = if (isActive) "Active" else "Inactive",
            color = Color.White,
            fontWeight = FontWeight.Bold
        )
    }
}

Here,

The UserStateProvider class implements PreviewParameterProvider<Boolean>, which means it provides a sequence of Boolean values for previewing. The values property returns both true and false.

When you use @PreviewParameter(UserStateProvider::class) on the isActive parameter, Compose Preview automatically generates two separate previews—one for each value in the sequence. You get both the active and inactive states without writing separate preview functions.

This approach is incredibly useful when testing with lists of data, different user types, or various configuration options.

Interactive Preview Mode

Recent versions of Android Studio introduced interactive preview mode, which lets you click buttons, scroll lists, and interact with your UI directly in the preview pane. This feature brings you even closer to the actual app experience without leaving the IDE.

To enable it, look for the interactive mode toggle in the preview pane toolbar. Keep in mind that interactions are limited to the composable being previewed — you can’t navigate to other screens or trigger real network calls.

Where Compose Preview Falls Short

Compose Preview is helpful, but it’s not perfect.

Let’s talk honestly about its limitations.

1. No Real Runtime Logic

Compose Preview does not handle:

  • Network calls
  • Database access
  • ViewModel state from real sources
  • Dependency injection (Hilt, Koin)

If your composable depends on runtime data, preview will break.

That’s why preview-friendly composables should take simple, deterministic parameters that can be easily mocked in previews, rather than ViewModels.

2. Limited Interaction Support

You can’t:

  • Click buttons meaningfully
  • Trigger navigation
  • Test animations properly
  • Simulate gestures accurately

Compose Preview shows how things look, not how they behave.

For behavior, you still need:

  • Emulators
  • Physical devices
  • UI tests

3. Can Be Slow in Large Projects

As your project grows:

  • Previews may take longer to render
  • IDE memory usage increases
  • Sometimes previews just refuse to refresh

This isn’t your fault. It’s a known trade-off.

4. Not a Replacement for Testing

Compose Preview is not a test.

It won’t catch:

  • Crashes
  • Logic bugs
  • Edge-case states
  • Performance issues

Think of it as a design aid, not a quality gate.

Best Practices for Using Compose Preview

To get the most out of Compose Preview:

Keep Preview Functions Simple and Focused

Your preview functions should be straightforward and serve a single purpose. Don’t overcomplicate them with business logic or complex data transformations.

Kotlin
// Good: Simple and clear
@Preview(showBackground = true)
@Composable
fun LoadingButtonPreview() {
    LoadingButton(
        text = "Loading",
        isLoading = true,
        onClick = { }
    )
}

// Avoid: Too much logic in preview
@Preview(showBackground = true)
@Composable
fun ComplicatedPreview() {
    val viewModel = remember { MyViewModel() }
    val state by viewModel.uiState.collectAsState()
    // This won't work well in preview..!
}

The first preview is clean and predictable. The second tries to instantiate a ViewModel, which likely depends on dependency injection, context, or other resources that aren’t available in preview mode.

Use Preview Groups for Organization

When you have many related previews, organize them into preview groups for better navigation.

Kotlin
annotation class ComponentPreviews

@ComponentPreviews
@Preview(name = "Small Button", widthDp = 100)
@Preview(name = "Medium Button", widthDp = 200)
@Preview(name = "Large Button", widthDp = 300)
@Composable
fun ButtonSizePreview() {
    Button(onClick = { }) {
        Text("Click Me")
    }
}

By creating a custom annotation like @ComponentPreviews and applying it alongside your @Preview annotations, you can filter and group previews in Android Studio. This becomes invaluable when working on large projects with hundreds of composables.

Create Preview Fixtures for Common Data

Maintain a separate file with preview fixtures — sample data objects you can reuse across multiple previews.

Kotlin
// PreviewFixtures.kt
object PreviewFixtures {
    val sampleUser = UserData(
        name = "Amol Pawar",
        email = "[email protected]",
        joinDate = "March 2022"
    )
    
    val sampleMessages = listOf(
        MessageData("Hello there!", "Amol", "9:00 AM"),
        MessageData("How are you?", "Rutuja", "9:05 AM"),
        MessageData("Doing great!", "Amol", "9:10 AM")
    )
    
    val longText = """
        This is a longer text sample that helps us test how our UI
        handles content that spans multiple lines. It's useful for
        checking text wrapping, overflow behavior, and spacing.
    """.trimIndent()
}

Then use these fixtures in your previews:

Kotlin
@Preview(showBackground = true)
@Composable
fun UserProfileWithFixturePreview() {
    UserProfile(
        userId = "sample",
        getUserData = { PreviewFixtures.sampleUser }
    )
}

This approach keeps your preview code DRY (Don’t Repeat Yourself) and makes it easier to maintain consistency across previews.

Test Edge Cases in Previews

Don’t just preview your happy path. Create previews for edge cases like empty states, error states, and extreme data conditions.

Kotlin
@Preview(name = "Empty List", showBackground = true)
@Composable
fun EmptyListPreview() {
    MessageList(messages = emptyList())
}

@Preview(name = "Very Long Name", showBackground = true)
@Composable
fun LongNamePreview() {
    ProfileCard(
        name = "Soundarya Bhagayalaxmi Venkateshwari Basapa Rao",
        isOnline = true,
        profileImageUrl = null
    )
}

@Preview(name = "Single Character", showBackground = true)
@Composable
fun SingleCharPreview() {
    ProfileCard(
        name = "X",
        isOnline = false,
        profileImageUrl = null
    )
}

These edge case previews help you catch layout issues before they reach production. Does your text truncate properly? Do your empty states look intentional rather than broken? 

The Future of Compose Preview

The Compose Preview tool continues to evolve with each Android Studio release. Recent improvements include better performance, enhanced animation support, and more sophisticated interactive capabilities.

Looking ahead, we can expect:

  • Deeper integration with design tools: Better collaboration between designers and developers through improved Figma integration and design token support.
  • AI-assisted previews: Automated generation of preview functions based on your composable parameters and common usage patterns.
  • Enhanced debugging: More powerful inspection tools for understanding why your UI renders the way it does.
  • Cloud-based previews: The ability to share interactive previews with team members without requiring them to open Android Studio.

The Android development community actively shapes these improvements through feedback, so don’t hesitate to file feature requests or bug reports.

Conclusion

Despite its limitations, Compose Preview is an essential part of modern Android development. The speed and convenience it offers make it ideal for rapid UI iteration and component-level design work.

The key is knowing when to use it. Compose Preview works best for visual validation and layout refinement, while emulators or real devices are still necessary for testing interactions, animations, and real data flows.

When used with preview-friendly composables and best practices, Compose Preview significantly improves development speed and feedback. It turns UI work into a more iterative, design-driven process rather than a cycle of long builds and guesswork.

Happy previewing..!

error: Content is protected !!