Kotlin UI, translated from CSS

You already understand layout, spacing, colour, and type. Compose is those same ideas with a different vocabulary, plus one big twist: there is no stylesheet. And now you can edit the code and see it render, right on this page.

You already know

  • The box model
  • Flexbox mental model
  • Design tokens & variables
  • States & transitions

You'll be learning

  • Composables = elements
  • Modifiers = declarations
  • State = automatic re-render
  • No cascade, no selectors

01 The mental model

One paragraph, and everything else follows from it.

In CSS you write markup, then separately write rules that reach into that markup via selectors. The rules cascade; the browser figures out which wins.

In Compose, a UI is a Kotlin function. Calling the function builds the element. Styling is done by passing arguments and chaining Modifier values on the element itself. There's no global namespace, no specificity, no cascade. The "style" for a thing lives exactly where the thing is written.

The one-liner: a <div> becomes a function call, a CSS rule becomes an argument or a Modifier, and a class becomes another function.

CSS worldCompose world
Element / tagA @Composable function call
ClassA reusable @Composable fun
CSS ruleFunction arguments + Modifier chain
Specificity / cascadeNo CSS cascade; values come from arguments, modifier order, and composition locals
StylesheetA theme object (MaterialTheme) or Kotlin constants
CSS custom propertyMaterialTheme.colorScheme / CompositionLocal
Re-render on state changeAutomatic: reading state in a composable subscribes it
DevToolsLayout Inspector + @Preview in Android Studio
Pseudo-classes (:hover)State objects + Modifier.hoverable
Container queriesBoxWithConstraints
CSS variables in :rootMaterialTheme.colorScheme
Hot reloadLive Edit in Android Studio / @Preview

Units: forget px. Sizes are in dp (density-independent pixels, not the same unit as CSS px, though layouts may use similar numeric values), and font sizes are in sp (scales with the user's font-size setting). 16.dp, 14.sp.

02 Your first composable

The same card, written twice.

CSScard.html + card.css
<div class="card">
  <h2>Hello</h2>
  <p>Body copy</p>
</div>

.card {
  background: #161b22;
  padding: 16px;
  border-radius: 12px;
}
.card h2 {
  font-size: 22px;
  margin: 0 0 8px;
}
KotlinProfileCard.kt
@Composable
fun ProfileCard() {
    Column(
        modifier = Modifier
            .background(Color(0xFF161B22), RoundedCornerShape(12.dp))
            .padding(16.dp),
        verticalArrangement = Arrangement.spacedBy(8.dp)
    ) {
        Text(
            text = "Hello",
            fontSize = 22.sp,
            fontWeight = FontWeight.SemiBold,
            color = Color(0xFFE6EDF3)
        )
        Text("Body copy", color = Color(0xFFC9D1D9))
    }
}

Hello

Body copy

Things to notice:

  • Column replaced the div and the default block flow at once.
  • The margin on h2 became Arrangement.spacedBy(8.dp) on the parent; Compose has no margin (more on that in §06).
  • Colours are Color(0xFFRRGGBB): 0xFF is the alpha channel, then hex, just like #RRGGBB with FF in front.
  • Nothing is global. Rename the function and nothing else in the app can accidentally style it.

03 Live playground

Edit the code. The preview updates as you type. Everything below is rendered by a small Compose interpreter that runs in this page.

How to use it: pick a preset from the dropdown, tweak values, add children, change modifiers, anything. Press ⌘/Ctrl + Enter to force a re-render. If the code is invalid, the error shows up in the preview.

Supported in this playground: Column, Row, Box, Text, Spacer, Divider; modifiers padding, background, size, width, height, fillMaxWidth, fillMaxHeight, weight, clip, border, alpha, offset, rotate, shadow, clickable; values dp, sp, Color(0x…), RoundedCornerShape, CircleShape, Arrangement.*, Alignment.*, FontWeight.*, MaterialTheme.colorScheme.*.

Write the composable body directly; no @Composable fun Foo() { } wrapper needed (though one is tolerated and unwrapped).

04 The cheat sheet

Bookmark this. Nine out of ten questions are answered here.

What you wantCSSCompose
Vertical stackdisplay:flex; flex-direction:columnColumn { }
Horizontal stackdisplay:flexRow { }
Overlay / stack on topposition:absoluteBox { }: later children draw on top
Gap between childrengap:12pxArrangement.spacedBy(12.dp)
Main-axis alignjustify-contentverticalArrangement (Column) / horizontalArrangement (Row)
Cross-axis alignalign-itemshorizontalAlignment (Column) / verticalAlignment (Row)
Fill remaining spaceflex:1Modifier.weight(1f)
Full widthwidth:100%Modifier.fillMaxWidth()
Fixed sizewidth:120px; height:48pxModifier.size(120.dp, 48.dp)
PaddingpaddingModifier.padding(16.dp)
MarginmarginDoesn't exist; use Arrangement.spacedBy or padding on the child
Border + radiusborder, border-radiusModifier.border(1.dp, color, RoundedCornerShape(12.dp))
Clip childrenoverflow:hiddenModifier.clip(RoundedCornerShape(12.dp))
BackgroundbackgroundModifier.background(color, shape)
Gradientlinear-gradient(…)Modifier.background(Brush.linearGradient(colors))
Shadowbox-shadowModifier.shadow(8.dp, shape): elevation, not offsets
Opacityopacity:.5Modifier.alpha(0.5f)
Rotatetransform:rotate(45deg)Modifier.rotate(45f)
z-indexz-indexDeclaration order in a Box, or Modifier.zIndex(1f)
Scrolling columnoverflow-y:autoLazyColumn or Modifier.verticalScroll(rememberScrollState())
Pointer / clickcursor:pointer + listenerModifier.clickable { } (includes ripple)
Font size / weightfont-size, font-weightfontSize = 14.sp, fontWeight = FontWeight.Bold
Text alignmenttext-aligntextAlign = TextAlign.Center
Hover:hoverModifier.hoverable + collectIsHoveredAsState()
Media query@mediaBoxWithConstraints { maxWidth }
CSS variable--brandMaterialTheme.colorScheme.primary
Reusable class.btn { }Extract a @Composable fun
Disabled state[disabled]enabled = false argument
Focus ring:focusModifier.focusable + FocusState
Cursorcursor:pointerModifier.pointerHoverIcon(PointerIcon.Hand)
Sticky headerposition:stickystickyHeader { } inside LazyColumn
Skeleton shimmerCSS animationrememberInfiniteTransition + animated brush

05 Layout: Row, Column, Box

Three containers replace all of flexbox and most of positioning.

Column is flex-direction: column. Row is the default flex direction. Box is a relative-positioned stacking context; children pile up, aligned by contentAlignment.

CSSrow.css
.row {
  display: flex;
  justify-content: space-between;
  align-items: center;
  width: 100%;
}
KotlinTotalRow.kt
Row(
    modifier = Modifier.fillMaxWidth(),
    horizontalArrangement = Arrangement.SpaceBetween,
    verticalAlignment = Alignment.CenterVertically
) {
    Text("Total")
    Text("42.00", fontWeight = FontWeight.Bold)
}

The naming rule is: arrangement is along the main axis (the direction the children flow), alignment is across it.

CSS valueCompose
flex-startArrangement.Start
centerArrangement.Center
flex-endArrangement.End
space-betweenArrangement.SpaceBetween
space-aroundArrangement.SpaceAround
space-evenlyArrangement.SpaceEvenly
gap: 12pxArrangement.spacedBy(12.dp)

Overlapping with Box

For position:absolute-style layering, use Box and Modifier.align:

KotlinBadge.kt
Box(contentAlignment = Alignment.Center) {
    Image(painter = avatar, contentDescription = null)

    // Pinned to the top-right of the Box, like position:absolute; top:0; right:0
    Box(
        modifier = Modifier
            .align(Alignment.TopEnd)
            .size(10.dp)
            .background(Color(0xFF7EE787), CircleShape)
    )
}

No position:relative needed. Box is already a positioning context. Later children simply draw on top of earlier ones.

06 Spacing & the box model

Padding exists. Margin does not. This trips up everybody.

Compose has exactly one spacing concept on an element: padding, which sits inside the element's own bounds (so background and click area include it). There is no margin.

Three ways to get space between things

  1. Siblings: Arrangement.spacedBy(12.dp) on the parent Row/Column. This is the direct equivalent of gap and covers most old margin uses.
  2. Space around one child: add Modifier.padding(…) to that child. Visually it behaves like margin; just remember the padding is inside the child, so a background drawn before the padding will fill it.
  3. Space inside a container: Modifier.padding(…) on the container itself.
KotlinSpacing.kt
Column(
    modifier = Modifier.padding(24.dp),            // space inside the container
    verticalArrangement = Arrangement.spacedBy(16.dp) // gap between children
) {
    Text("One")
    Text("Two")
    Text(
        text = "Three",
        modifier = Modifier.padding(top = 8.dp)     // extra space around this one child
    )
}

Padding shorthand is named arguments: Modifier.padding(horizontal = 16.dp, vertical = 10.dp), or Modifier.padding(start = 8.dp, top = 4.dp, end = 8.dp, bottom = 4.dp). Note start/end instead of left/right; that's automatic RTL support, same idea as CSS logical properties.

07 Modifiers, and why order matters

A modifier chain is not a rule block. It's a pipeline.

Modifier.background(…).padding(16.dp) reads left to right as "paint a background, then shrink the content area inwards." Each call wraps the previous one. This is why the order changes the result; unlike CSS, background and padding are order-independent declarations.

.background(red).padding(18.dp)
Text
.padding(18.dp).background(red)
Text

The same rule applies to border, clip, clickable, and shadow: whichever comes first in the chain defines the outer edge for everything after it.

Rule of thumb: layout modifiers first (size, fillMaxWidth, padding), then decoration (background, border, clip, shadow), then interaction (clickable) last-ish. If something "doesn't apply", check the order first.

Every composable should accept a modifier

This is the Compose equivalent of letting a parent class style a child. Convention: take modifier: Modifier = Modifier as the first optional parameter and apply it to the outermost element.

KotlinTag.kt
@Composable
fun Tag(
    text: String,
    modifier: Modifier = Modifier      // caller can style/position it
) {
    Text(
        text = text,
        modifier = modifier
            .clip(RoundedCornerShape(50.dp))
            .background(Color(0xFF1C2230))
            .padding(horizontal = 10.dp, vertical = 4.dp),
        fontSize = 12.sp
    )
}

// Caller decides where it goes:
Tag("New", modifier = Modifier.align(Alignment.TopEnd))

08 Colour, type, shape

Design tokens live in a theme object: think :root custom properties.

Instead of --color-primary: #6C5CE7 in :root, you build a MaterialTheme and read tokens off it anywhere in the tree. Changing the theme re-renders everything that read from it, exactly like swapping a CSS variable.

KotlinTheme.kt
@Composable
fun AppTheme(content: @Composable () -> Unit) {
    MaterialTheme(
        colorScheme = lightColorScheme(
            primary    = Color(0xFF6C5CE7),
            background = Color(0xFFF8F9FB),
            onSurface  = Color(0xFF14161A)
        ),
        typography = Typography(
            titleLarge  = TextStyle(fontSize = 22.sp, fontWeight = FontWeight.SemiBold),
            bodyMedium  = TextStyle(fontSize = 14.sp, lineHeight = 20.sp)
        ),
        shapes = Shapes(
            medium = RoundedCornerShape(12.dp)
        ),
        content = content
    )
}
KotlinAnywhere inside AppTheme
Text(
    text = "Subscribe",
    style = MaterialTheme.typography.titleLarge,
    color = MaterialTheme.colorScheme.primary
)

Surface(
    shape = MaterialTheme.shapes.medium,
    color = MaterialTheme.colorScheme.surface
) {
    // ...
}

Text does not inherit typography from its parent the way HTML text inherits font-family. Each Text reads LocalTextStyle, which defaults to bodyLarge. So set styles per Text, or override LocalTextStyle for a subtree if you really want inheritance.

Spacing scale as tokens

Just like you'd define --space-1: 4px; --space-2: 8px; in CSS, define your spacing scale in Kotlin and use it everywhere. This makes your UI feel consistent across screens without any global stylesheet.

KotlinSpacing.kt
object Spacing {
    val xs = 4.dp
    val sm = 8.dp
    val md = 12.dp
    val lg = 16.dp
    val xl = 24.dp
    val xxl = 32.dp
}

// Then:
Column(
    modifier = Modifier.padding(horizontal = Spacing.lg),
    verticalArrangement = Arrangement.spacedBy(Spacing.sm)
) { /* ... */ }

09 State & interaction

There is no :hover, no classList.toggle. There's state.

In CSS + JS you flip a class and let CSS react. In Compose you hold a value in state; any composable that reads it re-runs when it changes. No manual DOM updates, no addEventListener.

KotlinCounter.kt
@Composable
fun Counter() {
    // 1. declare state           2. read/write it
    var count by remember { mutableStateOf(0) }

    Column(horizontalAlignment = Alignment.CenterHorizontally) {
        Text("$count", fontSize = 48.sp)
        Button(onClick = { count++ }) {
            Text("+1")
        }
    }
}

remember retains a value across recompositions while that call remains in the composition. It is not tied to a DOM node, and the value can be recreated if the composable leaves and later re-enters the composition. The by keyword is Kotlin property delegation; the state example also needs the usual Compose runtime imports.

Here's what that counter actually does

The code above is real Compose. Below is a plain HTML mock of the same behaviour, so you can see how state drives the UI without a compiler:

0
clicks

A toggle with a hover-free interaction

KotlinLikeButton.kt
@Composable
fun LikeButton() {
    var liked by remember { mutableStateOf(false) }

    val tint by animateColorAsState(
        targetValue = if (liked) Color(0xFFFF5C8A)
                      else MaterialTheme.colorScheme.onSurfaceVariant,
        label = "tint"
    )

    Row(
        modifier = Modifier
            .clip(RoundedCornerShape(50.dp))
            .clickable { liked = !liked }
            .padding(horizontal = 16.dp, vertical = 10.dp),
        verticalAlignment = Alignment.CenterVertically,
        horizontalArrangement = Arrangement.spacedBy(8.dp)
    ) {
        Icon(Icons.Default.Favorite, contentDescription = null, tint = tint)
        Text(if (liked) "Liked" else "Like", color = tint)
    }
}

Notice the if. Because "styling" is just Kotlin, you can compute it with expressions, loops, function calls, anything. No need for a parallel set of .is-active modifier classes.

The three state tools you'll reach for

ToolWhat it's forCSS-world equivalent
remember { mutableStateOf(x) }Local UI state that survives recompositionA JS variable bound to a DOM element
rememberSaveableSame, plus survives configuration changes (rotation)sessionStorage
StateFlow / collectAsStateObservable state coming from a ViewModelRedux/observable store
derivedStateOfComputed value from other stateCSS custom property derived from another
rememberCoroutineScopeLaunch async work from a click handlerasync () => {}

10 Lists

ul > li often maps to LazyColumn, which composes and lays out items as they are needed.

HTMLmessages.html
<ul class="messages">
  <li class="message">…</li>
  <li class="message">…</li>
</ul>

.messages {
  display: flex;
  flex-direction: column;
  gap: 12px;
  padding: 16px;
  overflow-y: auto;
}
KotlinMessages.kt
LazyColumn(
    contentPadding = PaddingValues(16.dp),
    verticalArrangement = Arrangement.spacedBy(12.dp)
) {
    items(messages, key = { it.id }) { message ->
        MessageRow(message)
    }
}
  • contentPadding is the list's internal padding (the equivalent of padding on a scroll container), and it doesn't clip the scroll area.
  • key is :nth-child-safety: give each row a stable id so Compose keeps the right element when the list changes.
  • Use a plain Column for a handful of items; LazyColumn for anything that scrolls.
KotlinGrids and horizontal lists
// CSS: display:grid; grid-template-columns: repeat(2, 1fr)
LazyVerticalGrid(columns = GridCells.Fixed(2)) {
    items(photos) { PhotoTile(it) }
}

// CSS: display:flex; overflow-x:auto
LazyRow(
    contentPadding = PaddingValues(horizontal = 16.dp),
    horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
    items(categories) { CategoryChip(it) }
}

11 Common patterns

Recipes you'll rebuild on every screen. Every one of these works in the playground.

Card with avatar + meta
Row(
    modifier = Modifier
        .fillMaxWidth()
        .background(Color(0xFFFFFFFF), RoundedCornerShape(12.dp))
        .padding(14.dp),
    horizontalArrangement = Arrangement.spacedBy(12.dp),
    verticalAlignment = Alignment.CenterVertically
) {
    Box(
        modifier = Modifier
            .size(40.dp)
            .background(Color(0xFF6C5CE7), CircleShape),
        contentAlignment = Alignment.Center
    ) {
        Text("M", color = Color(0xFFFFFFFF), fontWeight = FontWeight.Bold)
    }
    Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
        Text("Mia Chen", fontWeight = FontWeight.SemiBold)
        Text("@miachen", fontSize = 13.sp, color = Color(0xFF5A6270))
    }
}
Badge on a corner
Box(modifier = Modifier.size(56.dp)) {
    Box(
        modifier = Modifier
            .size(56.dp)
            .background(Color(0xFFF8F9FB), CircleShape)
    )
    Box(
        modifier = Modifier
            .align(Alignment.TopEnd)
            .size(14.dp)
            .background(Color(0xFFFF5C8A), CircleShape)
    )
}
List item row
Row(
    modifier = Modifier
        .fillMaxWidth()
        .padding(horizontal = 16.dp, vertical = 12.dp),
    horizontalArrangement = Arrangement.SpaceBetween,
    verticalAlignment = Alignment.CenterVertically
) {
    Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
        Text("Morning run", fontWeight = FontWeight.SemiBold)
        Text("3.2 km · 18 min", fontSize = 13.sp, color = Color(0xFF5A6270))
    }
    Text("→", color = Color(0xFF6C5CE7))
}
Chip / tag row
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
    Text(
        "Design",
        fontSize = 12.sp,
        color = Color(0xFF6C5CE7),
        modifier = Modifier
            .clip(RoundedCornerShape(50.dp))
            .background(Color(0xFFEEE9FF))
            .padding(horizontal = 10.dp, vertical = 4.dp)
    )
    Text(
        "Code",
        fontSize = 12.sp,
        color = Color(0xFF5A6270),
        modifier = Modifier
            .clip(RoundedCornerShape(50.dp))
            .background(Color(0xFFF1F3F6))
            .padding(horizontal = 10.dp, vertical = 4.dp)
    )
}
Stat strip
Row(
    modifier = Modifier.fillMaxWidth(),
    horizontalArrangement = Arrangement.SpaceEvenly
) {
    Column(horizontalAlignment = Alignment.CenterHorizontally) {
        Text("42", fontWeight = FontWeight.Bold, fontSize = 20.sp)
        Text("Runs", fontSize = 12.sp, color = Color(0xFF5A6270))
    }
    Column(horizontalAlignment = Alignment.CenterHorizontally) {
        Text("3.2", fontWeight = FontWeight.Bold, fontSize = 20.sp)
        Text("Km", fontSize = 12.sp, color = Color(0xFF5A6270))
    }
    Column(horizontalAlignment = Alignment.CenterHorizontally) {
        Text("18", fontWeight = FontWeight.Bold, fontSize = 20.sp)
        Text("Min", fontSize = 12.sp, color = Color(0xFF5A6270))
    }
}
Divided list
Column {
    Text("One", modifier = Modifier.padding(16.dp))
    Divider()
    Text("Two", modifier = Modifier.padding(16.dp))
    Divider()
    Text("Three", modifier = Modifier.padding(16.dp))
}

Notice the pattern. Every recipe is a Column or Row plus a couple of modifiers. There's no "utility class" layer: the layout is the component.

12 Responsive

@media becomes "ask the container how wide it is".

There are no media queries. Instead, BoxWithConstraints gives you the measured maxWidth/maxHeight of the available space, and you branch in Kotlin.

KotlinResponsive.kt
@Composable
fun CardGrid(cards: List<Card>) {
    BoxWithConstraints {
        val columns = when {
            maxWidth < 600.dp  -> 1
            maxWidth < 960.dp  -> 2
            else               -> 3
        }

        LazyVerticalGrid(columns = GridCells.Fixed(columns)) {
            items(cards) { card -> CardView(card) }
        }
    }
}

On Android you can also read the window size class (calculateWindowSizeClass()) which is the closest thing to breakpoints, but BoxWithConstraints is the container-query equivalent and often what you actually want.

Rule of thumb: reach for BoxWithConstraints first. It's the closest thing Compose has to container queries, and it means your component responds to the space it's placed in, not the whole screen.

13 Animation

transition: all .2s becomes an animated state value.

Instead of declaring a transition and changing a class, you animate a value and use it. Compose drives the value; anything that reads it updates.

CSSpanel.css
.panel {
  opacity: 0;
  transform: translateY(8px);
  transition: opacity .2s, transform .2s;
}
.panel.open {
  opacity: 1;
  transform: translateY(0);
}
KotlinPanel.kt
val offset by animateDpAsState(
    targetValue = if (open) 0.dp else 8.dp,
    label = "offset"
)
val alpha by animateFloatAsState(
    targetValue = if (open) 1f else 0f,
    label = "alpha"
)

Modifier
    .offset(y = offset)
    .alpha(alpha)

For enter/exit of a whole subtree, use AnimatedVisibility(visible = open) { … }: that's your display:none with a fade built in.

CSSCompose
transition: opacity .2sanimateFloatAsState
transition: transform .3sanimateDpAsState / animateFloatAsState
transition: background .2sanimateColorAsState
Keyframe animationAnimatable + animateTo with keyframes
Spring easingspring(dampingRatio, stiffness)
Enter / exitAnimatedVisibility
List item changesModifier.animateItem() (Compose Foundation 1.7+)
Infinite pulserememberInfiniteTransition

14 Traps for CSS brains

The five things that will bite you first.

1. There is no cascade

You cannot write a rule in a parent that restyles a child. If a child needs a variation, pass a parameter or a Modifier. This feels like a loss for a week and then feels like freedom forever.

2. There is no margin

Use Arrangement.spacedBy between siblings, or padding on the child. See §06.

3. Modifier order is semantic

.background().padding() ≠ .padding().background(). See §07.

4. Everything is Kotlin, so there's no "styles" layer

That's the upside. Extract a composable instead of a class, use constants instead of a variables file, and use a theme for the tokens that genuinely need to be global.

5. Don't do work inside a composable

A composable can run many times per second while animating. Run coroutine side effects in LaunchedEffect { } and use DisposableEffect or SideEffect when appropriate. remember { } caches a value across recompositions; it is not a side-effect handler.

Bonus: Text("Hello") doesn't inherit colour or font from a wrapping Row. In HTML, text inherits; in Compose, it reads LocalTextStyle / LocalContentColor, which default to the theme's values, not the parent element's.

And one more: there's no :hover, no :focus, no :active. Every interaction is state you hold and read. If you want a hover effect, you write a boolean and animate it.

15 Kotlin essentials for UI work

Compose is Kotlin, so a few language features show up in almost every screen.

Values, functions, and null safety

KotlinKotlinBasics.kt
val title: String = "Welcome"       // read-only reference
var count: Int = 0                 // reassignment allowed
val subtitle = "Hello, Compose"    // type inferred

fun greeting(name: String): String = "Hello, $name"

// Nullable types must be handled deliberately.
val nickname: String? = null
val label = nickname ?: "Guest"   // Elvis operator

// Safe call: length is Int? because nickname may be null.
val length = nickname?.length

Prefer val unless reassignment is genuinely needed. A type ending in ? can hold null; the compiler makes you handle that possibility rather than letting it surprise you at runtime.

Data classes and immutable UI models

KotlinProfile.kt
data class Profile(
    val name: String,
    val role: String,
    val isOnline: Boolean = false
)

@Composable
fun ProfileHeader(profile: Profile) {
    Column {
        Text(profile.name)
        Text(profile.role)
        if (profile.isOnline) Text("Online")
    }
}

A composable should usually receive the data it needs as parameters. Keeping models immutable makes changes easier to reason about and helps you avoid hidden state.

Important: the snippets in this guide are teaching examples, not one file you can paste wholesale into a project. Real files need the appropriate imports, dependencies, and an enclosing Compose app.

16 Accessibility is part of the UI

A screen is not finished just because it looks right on your device.

  • Use semantic components. Prefer Button, Checkbox, and other Material components over clickable decorative containers when they fit the interaction.
  • Give icon-only actions a description. A screen reader cannot infer that a magnifying-glass icon means “Search”.
  • Don't encode meaning with colour alone. Pair colour with text, an icon, or another clear signal.
  • Respect text scaling. Use sp for text and test with larger system font settings. Avoid fixed heights that clip text.
  • Check contrast and touch size. Controls should be easy to see and comfortably activate, including on smaller phones.
  • Group related content sensibly. Use semantics only when needed, and avoid merging labels in ways that make navigation confusing.
KotlinAccessibleAction.kt
IconButton(
    onClick = onSearch,
    modifier = Modifier.semantics {
        contentDescription = "Search"
    }
) {
    Icon(
        imageVector = Icons.Default.Search,
        contentDescription = null
    )
}

For an icon inside a control that already has a semantic label, contentDescription = null prevents the icon from being announced redundantly. Import the relevant Material icon and semantics APIs in your project.

17 State and architecture that scale

Keep the UI declarative: state goes in, pixels come out, events go back up.

For a tiny demo, remember may be enough. For a real screen, separate the UI from data loading and business rules. A common Android pattern is a ViewModel exposing observable UI state, with the composable collecting it.

KotlinCounterScreen.kt · simplified pattern
data class CounterUiState(val count: Int = 0)

class CounterViewModel : ViewModel() {
    private val _uiState = MutableStateFlow(CounterUiState())
    val uiState: StateFlow<CounterUiState> = _uiState.asStateFlow()

    fun increment() {
        _uiState.update { it.copy(count = it.count + 1) }
    }
}

@Composable
fun CounterRoute(viewModel: CounterViewModel = viewModel()) {
    val state by viewModel.uiState.collectAsStateWithLifecycle()
    CounterContent(count = state.count, onIncrement = viewModel::increment)
}

This is a pattern sketch, not a standalone compile-ready file: it assumes the Android lifecycle, ViewModel, and coroutine Flow dependencies and imports. In production, keep the state holder's lifetime appropriate to the screen or navigation destination.

  • State hoisting: let a parent own state when multiple children need to share it. Pass the current value down and callbacks up.
  • Events are not state: a text field's current value is state; “submit was tapped” is an event. Model each according to how long it needs to live.
  • Don't launch work during composition: use event callbacks for user-triggered work and effect APIs for effects tied to composition.
  • Keep state minimal: avoid storing values that can be derived reliably from other state.

18 Testing and debugging

A small feedback loop catches problems before they become a mysterious blank screen.

Useful checks while building

  • Use @Preview to inspect composables quickly, but also run the app on an emulator or device. A preview is not a replacement for runtime testing.
  • Use Compose UI tests to find nodes by text or semantics, perform actions, and assert what the user should see.
  • Use Layout Inspector to inspect the actual hierarchy and modifiers when spacing or sizing behaves unexpectedly.
  • Read the first meaningful compiler or Logcat error. Later errors are often consequences of the first one.
  • Test narrow screens, landscape, larger text, dark theme, loading, empty, and error states, not only the happy path.
KotlinCounterScreenTest.kt · sketch
@Test
fun incrementButtonUpdatesCount() {
    composeTestRule.setContent {
        var count by remember { mutableIntStateOf(0) }
        Column {
            Text("Count: $count")
            Button(onClick = { count++ }) {
                Text("Increment")
            }
        }
    }

    composeTestRule.onNodeWithText("Increment").performClick()
    composeTestRule.onNodeWithText("Count: 1").assertExists()
}

The test sketch assumes a configured Compose test rule and the matching test dependencies. For app code, prefer testing the real screen or extracting logic into small units rather than testing only a duplicate toy implementation.

Debugging checklist: Is the state observable? Is it actually changing? Is the composable reading that state? Is a parent constraining the size? Is modifier order changing the result? Is the relevant code running on the expected screen?

19 Where next

You can't run Kotlin in a browser tab, so here's the fastest loop.

  • Install Android Studio, create an empty Compose activity, and put a @Preview function above your composable. The preview pane updates when you refresh or use Live Edit; @Preview itself is not a general-purpose browser-style hot reload.
  • Keep the reference docs open: the Modifier list is the closest thing to a CSS property reference.
  • Read Layout basics and State and Jetpack Compose next; together they cover 80% of day-to-day UI work.
  • Compose Multiplatform runs the same code on desktop, iOS, and web, so the skills transfer beyond Android.
  • The Component gallery is the closest thing to a design system reference: every Material 3 component is there with code samples.

Your unfair advantage: most Kotlin developers learn layout by trial and error. You already have a precise mental model of the box model, flex distribution, and design tokens. You mostly need to learn new nouns.