Build a Profile Card with Compose Desktop: Layouts & Styling | Kotlin Desktop Lesson 02
Video: Build a Profile Card with Compose Desktop: Layouts & Styling | Kotlin Desktop Lesson 02 by Taught by Celeste AI - AI Coding Coach
Layouts and Styling: Build a Profile Card with Compose Desktop
Three layout primitives —
Column,Row,Box— plusModifierchains. The vocabulary that builds every Compose UI.
Lesson 1 got a window on screen with a counter. Today we build something that looks like a real piece of UI: a profile card. Avatar circle, name, role, stats row, bio paragraph, follow button. The same Composable model, but now we're stacking the layout primitives that Compose Desktop uses for everything.
What we are building
A centred card with:
- A 96dp circular avatar with initials.
- Name (bold, 24sp) and role (smaller, muted).
- A divider.
- Three stats side-by-side (posts, followers, following).
- A bio paragraph.
- A full-width "Follow" button.
Material 3 dark theme, soft purple palette.
Project layout
demo-app/src/main/kotlin/
├── Main.kt # app shell: theme + window + ProfileCard
├── ProfileCard.kt # the card composable
├── StatItem.kt # the per-stat composable
└── Colors.kt # the named palette
Splitting into files is overkill for a single-screen demo — but starting the habit now means the later lessons (which grow much larger) are easy.
Colors.kt
val Purple80 = Color(0xFFCFBCFF)
val PurpleGrey80 = Color(0xFFCCC2DC)
val Purple40 = Color(0xFF6650A4)
val PurpleGrey40 = Color(0xFF625B71)
val DarkBackground = Color(0xFF1C1B1F)
val DarkSurface = Color(0xFF2B2930)
Six named Color values. Standard Material 3 dark scheme — the lighter shades are foreground accents, the darker shades are surfaces.
Centralising colours means any redesign is one file to edit.
Main.kt: the shell
@Composable
fun App() {
MaterialTheme(colorScheme = darkColorScheme()) {
Surface(modifier = Modifier.fillMaxSize(), color = DarkBackground) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
ProfileCard()
}
}
}
}
fun main() = application {
Window(onCloseRequest = ::exitApplication, title = "Profile Card") {
App()
}
}
Box is the third layout primitive. It stacks children on top of each other (z-axis) and lets you align them. contentAlignment = Alignment.Center puts the child in the middle of its parent.
For a single child centred in the window, Box with Center is the idiomatic pattern.
ProfileCard.kt: the card
@Composable
fun ProfileCard() {
Card(
modifier = Modifier.width(380.dp).padding(16.dp),
shape = RoundedCornerShape(24.dp),
colors = CardDefaults.cardColors(containerColor = DarkSurface),
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)
) {
Column(
modifier = Modifier.fillMaxWidth().padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
// avatar
Box(
modifier = Modifier.size(96.dp).clip(CircleShape).background(Purple40),
contentAlignment = Alignment.Center
) {
Text("AJ", fontSize = 36.sp, fontWeight = FontWeight.Bold, color = Color.White)
}
Spacer(modifier = Modifier.height(16.dp))
Text("Alex Johnson", fontSize = 24.sp, fontWeight = FontWeight.Bold, color = Color.White)
Spacer(modifier = Modifier.height(4.dp))
Text("Kotlin Developer", fontSize = 15.sp, color = PurpleGrey80)
Spacer(modifier = Modifier.height(20.dp))
Divider(color = PurpleGrey40.copy(alpha = 0.3f))
Spacer(modifier = Modifier.height(20.dp))
// stats
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) {
StatItem("128", "Posts")
StatItem("14k", "Followers")
StatItem("920", "Following")
}
Spacer(modifier = Modifier.height(20.dp))
// bio
Text(
text = "Building beautiful desktop apps with Compose. Passionate about clean code and great UI.",
fontSize = 14.sp,
color = PurpleGrey80,
textAlign = TextAlign.Center,
lineHeight = 22.sp
)
Spacer(modifier = Modifier.height(24.dp))
// follow button
Button(
onClick = {},
modifier = Modifier.fillMaxWidth().height(48.dp),
shape = RoundedCornerShape(12.dp),
colors = ButtonDefaults.buttonColors(containerColor = Purple40)
) {
Text("Follow", fontSize = 16.sp, fontWeight = FontWeight.SemiBold)
}
}
}
}
Three new techniques to walk.
Card
Card(
modifier = Modifier.width(380.dp).padding(16.dp),
shape = RoundedCornerShape(24.dp),
colors = CardDefaults.cardColors(containerColor = DarkSurface),
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)
)
Material's Card is a Surface with rounded corners and elevation built in. Four parameters do most of the work:
modifier— sizing and outer spacing.shape—RoundedCornerShape(24.dp)rounds all four corners. Other options:CircleShape,CutCornerShape, custom paths.colors—CardDefaults.cardColors(containerColor = ...)sets the fill. Material 3 uses defaults objects (suffixDefaults) for component-specific colour configuration.elevation— visual shadow under the card. Affects the appearance of depth in the dark theme.
CircleShape and clip()
Box(modifier = Modifier.size(96.dp).clip(CircleShape).background(Purple40), ...)
The avatar is a Box with three modifier methods chained.
size(96.dp) makes it a 96×96 square.
clip(CircleShape) rounds it into a circle. The clip modifier confines drawing inside the shape — the background, any image inside, any children — they all clip to the circle.
background(Purple40) fills the (now-circular) region.
Order matters! background(Purple40).clip(CircleShape) would clip the background after it's painted — which means you'd see a square Purple40 background that's then cut into a circle. With clip first, the background is constrained to the circle from the start. Both produce the right pixels here, but the rule of thumb is: clip first, then content.
Row with Arrangement.SpaceEvenly
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) {
StatItem("128", "Posts")
StatItem("14k", "Followers")
StatItem("920", "Following")
}
Row lays children horizontally. Arrangement.SpaceEvenly distributes the available space equally between, before, and after children — the three stat items spread out symmetrically.
Other arrangements:
- Start, Center, End — pack children to one edge.
- SpaceBetween — equal gaps between, no padding at edges.
- SpaceEvenly — equal gaps everywhere including edges.
- SpaceAround — same idea, edges get half-spacing.
For three columns of equal-weight content (toolbars, navigation tabs, stat rows), SpaceEvenly is usually right.
StatItem.kt: a small reusable composable
@Composable
fun StatItem(value: String, label: String) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(value, fontSize = 22.sp, fontWeight = FontWeight.Bold, color = Purple80)
Spacer(modifier = Modifier.height(4.dp))
Text(label, fontSize = 13.sp, color = PurpleGrey80)
}
}
A reusable Composable that takes two strings and renders a stacked value-plus-label. Used three times in the parent. Splitting it out gives the parent a readable structure (StatItem("128", "Posts") reads cleanly) and makes future changes to the stat style trivial.
This is the unit of Compose: small, named, single-responsibility Composables, composed together.
Text styling
A few text-styling parameters from the example:
fontSize = 24.sp— size in scale-independent pixels.fontWeight = FontWeight.Bold— weight enum (Normal,Medium,SemiBold,Bold, etc.).color = Color.White— explicit colour. Material'sMaterialTheme.colorScheme.onBackgroundwould be theme-aware; explicit colours are fine for one-off accents.textAlign = TextAlign.Center— horizontal alignment within the Text's box.lineHeight = 22.sp— line spacing for multi-line text.
For dense styling (bold + colour + line height + alignment), explicit attributes work. For very rich styles, build a TextStyle once and pass it.
Modifier chain order
The avatar shows it concretely:
Modifier.size(96.dp).clip(CircleShape).background(Purple40)
Read left-to-right: first impose a 96dp size, then clip to a circle, then paint a background. Each step transforms the layout/draw context for everything after it.
Try Modifier.background(Purple40).size(96.dp) — you'd get a coloured square that takes 96dp because the background is painted before sizing, then sizing constrains where things end up. Subtle, but the rule "clip before content, size before colour" applies most of the time.
Running it
./gradlew run. A centred card appears in the window. Resize the window — the card stays centred. The width(380.dp) keeps the card a fixed width regardless of window size.
For a card that scales with the window, replace width(380.dp) with fillMaxWidth(0.6f) (60% of parent width).
Common mistakes
Forgetting clip before background. The background paints under the clip, so a clipped circle on top of a coloured background still shows a square corner if the background was painted first. Order matters.
Row without fillMaxWidth. A row sizes itself to its content by default. Without fillMaxWidth, SpaceEvenly has no extra space to distribute and the children pile up.
Color.White on a transparent surface. White text on a dark theme is fine, but use MaterialTheme.colorScheme.onBackground for theme-aware colour — it switches automatically when the theme switches.
Spacer(height(0.dp)) instead of nothing. A zero-height Spacer is harmless but pointless. Just don't add a Spacer if you don't need space.
What's next
Lesson 3: state and input. Bind text fields, checkboxes, switches, and radio buttons to a single immutable data class of state. Pure functions for validation. The Compose pattern that scales from one form to one app.
Recap
Three layout primitives — Column, Row, Box. Card for elevated containers. Modifier chains for sizing, padding, clipping, backgrounds. Material 3 theming via MaterialTheme(colorScheme = darkColorScheme()). Small reusable Composables (StatItem) compose into bigger ones (ProfileCard).
Next lesson: state and input. See you in the next one.