Scaffold for Jetpack Compose Desktop

Kerry Bisset
8 min readApr 12, 2025

Have you ever been tempted to write a desktop application using Jetpack Compose? If you’ve explored Compose for mobile or web, you already know how powerful it can be. But the moment you try to reuse a Material Scaffold on a desktop form factor, things don’t quite feel right: the spacing is too generous, the touch targets too large for a mouse, and the overall layout feels like a mobile app that got stretched out.

I am going to show you a component called the Desktop Scaffold. We’ll look at how desktop UX diverges from mobile regarding screen real estate, interaction patterns, and window management, then walk through building a more suitable “shell” for your Compose Desktop apps. If you’re aiming for that native desktop experience and want to use Jetpack Compose, this one is for you.

Introducing the Desktop Scaffolds

Before describing the detailed scaffolds, here’s a quick pointer to the GitHub repository where you can find all the example code. This repository contains composables showcasing how to structure desktop-oriented UIs, including the DesktopApplicationScaffold and a DesktopAreaScaffold (coming up next).

The Concept of “Application vs. Area” Scaffolds

  • Desktop Application Scaffold: This is the overarching layout for your entire desktop app. Think of it as the foundation providing a top bar (with optional search and global actions), a navigation rail on the left, and the main content area. It’s perfect for projects needing a consistent layout across multiple screens or sections.
  • Desktop Area Scaffold (next section): Sometimes, you need an “inner scaffold” or a smaller, more focused layout region within your application. That’s where a “desktop area” scaffold comes in — allowing you to structure specific portions of the screen differently.

Desktop Application Scaffold

Below is the implementation of the DesktopApplicationScaffold. It follows the Material 3 slot pattern but has been optimized for desktop usage by default:

/**
* A desktop-oriented scaffold that implements the slot pattern similar to Material 3's Scaffold
* but optimized for desktop applications with navigation rail, expandable panel, and action bar.
*
* @param topBar Slot for the top bar containing search and global actions
* @param navigationRail Slot for the navigation rail on the far left
* @param content Slot for the main content area
* @param modifier Modifier for the entire scaffold
*/
@Composable
fun DesktopApplicationScaffold(
topBar: @Composable () -> Unit,
navigationRail: @Composable () -> Unit,
content: @Composable () -> Unit,
modifier: Modifier = Modifier
) {
Column(modifier = modifier.fillMaxSize()) {
// Top bar area
topBar()

// Main content row with navigation and content
Row(modifier = Modifier.fillMaxSize()) {
// Navigation rail (always visible)
navigationRail()

Box(
Modifier
.weight(1f)
.fillMaxHeight()
) {
content()
}
}
}
}

Supporting Composables

In the same repository, you’ll find two handy composables that slot perfectly into the DesktopApplicationScaffold:

DesktopNavigationRail

@Composable
fun DesktopNavigationRail(
header: @Composable () -> Unit = {},
footer: @Composable () -> Unit = {},
content: @Composable ColumnScope.() -> Unit,
backgroundColor: Color = MaterialTheme.colorScheme.surface,
contentColor: Color = contentColorFor(backgroundColor),
width: Dp = 56.dp,
modifier: Modifier = Modifier
) {
Surface(
color = backgroundColor,
contentColor = contentColor,
modifier = modifier.width(width).fillMaxHeight()
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxHeight().padding(vertical = 8.dp)
) {
// Header section
header()

// Main content section (navigation items)
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.weight(1f),
content = content
)

// Footer section
footer()
}
}
}

DesktopTopBar

@Composable
fun DesktopTopBar(
title: @Composable () -> Unit = {},
search: @Composable RowScope.() -> Unit = {},
actions: @Composable RowScope.() -> Unit = {},
backgroundColor: Color = MaterialTheme.colorScheme.surface,
contentColor: Color = contentColorFor(backgroundColor),
elevation: Dp = 4.dp,
height: Dp = 48.dp,
modifier: Modifier = Modifier
) {
Surface(
color = backgroundColor,
contentColor = contentColor,
tonalElevation = elevation,
modifier = modifier.fillMaxWidth().height(height)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp)
) {
// Title/branding section
title()

// Search section (centered)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.weight(1f),
horizontalArrangement = Arrangement.End,
content = { search() }
)

// Actions section
Row(
verticalAlignment = Alignment.CenterVertically,
content = actions
)
}
}
}
Example of the two areas.

Desktop Area Scaffold

While the Desktop Application Scaffold is a great starting point for your app’s overall layout, you may need additional or more flexible sub-layouts within specific UI parts. That’s where a Desktop Area Scaffold can be incredibly useful.

Below is the implementation of how to structure a more “dynamic” desktop layout inside an already existing application. This is an inner scaffold designed to handle collapsible panels, a central content area, and an optional additional panel (like a preview pane or secondary sidebar).

@Composable
fun DesktopAreaScaffold(
actionBar: @Composable () -> Unit = {},
modifier: Modifier = Modifier,
navigationPanel: @Composable (PanelState) -> Unit = {},
navigationPanelState: NavigationPanelState = rememberNavigationPanelState(),
navigationPanelWidth : Dp = 240.dp,
optionalPanel: (@Composable (PanelState) -> Unit)? = null,
optionalPanelState: OptionalPanelState? = null,
optionalPanelWidth : Dp = 240.dp,
content: @Composable () -> Unit
) {
Column(modifier) {
// Action bar area
actionBar()

Row {
// Navigation panel (expandable)
val navPanelWidth by animateDpAsState(
targetValue = if (navigationPanelState.isExpanded) navigationPanelWidth else 0.dp,
animationSpec = tween(durationMillis = 300),
label = "Navigation Panel Width"
)

val animationProgress = if (navigationPanelState.isExpanded) {
if (navPanelWidth.value == 240f) 1f else navPanelWidth.value / 240f
} else {
if (navPanelWidth.value == 0f) 0f else navPanelWidth.value / 240f
}

val panelState = if (navigationPanelState.isExpanded) {
PanelState.Expanded(animationProgress)
} else {
PanelState.Collapsed(animationProgress)
}

AnimatedCard(navigationPanelState.isExpanded, Modifier.width(navPanelWidth).padding(4.dp)) {
Box(modifier = Modifier.width(navPanelWidth).fillMaxHeight()) {
navigationPanel(panelState)
}
}

// Main content area
Box(modifier = Modifier.weight(1f).fillMaxHeight()) {
Card(modifier = Modifier.fillMaxSize().padding(4.dp)) {
content()
}
}

// Optional panel (e.g., details or properties pane)
if (optionalPanel != null && optionalPanelState != null) {
val optPanelWidth by animateDpAsState(
targetValue = if (optionalPanelState.isExpanded) optionalPanelWidth else 0.dp,
animationSpec = tween(durationMillis = 300),
label = "Optional Panel Width"
)
AnimatedCard(optionalPanelState.isExpanded, Modifier.width(optPanelWidth).padding(4.dp)) {
Box(modifier = Modifier.width(optPanelWidth).fillMaxHeight()) {
optionalPanel(panelState)
}
}
}
}
}
}

This scaffold allows for more granular control inside your application. For example, you might use the DesktopApplicationScaffold at the top level (with a consistent top bar and navigation rail across the whole app) and then embed a DesktopAreaScaffold inside one of the screens or routes that needs an expandable panel.

Supporting Composables

Just like the DesktopNavigationRail and DesktopTopBar in the main application scaffold, there are some handy supporting composables here, too:

DesktopPanel

@Composable
fun DesktopPanel(
header: @Composable () -> Unit = {},
content: @Composable ColumnScope.() -> Unit,
footer: @Composable ColumnScope.() -> Unit = {},
backgroundColor: Color = MaterialTheme.colorScheme.surface,
contentColor: Color = contentColorFor(backgroundColor),
modifier: Modifier = Modifier
) {
Surface(
color = backgroundColor,
contentColor = contentColor,
modifier = modifier.fillMaxHeight()
) {
Column(
modifier = Modifier.fillMaxHeight().padding(vertical = 8.dp)
) {
// Header section
header()

// Main content section
Column(
modifier = Modifier.weight(1f).fillMaxWidth(),
content = content
)

// Footer section
footer()
}
}
}

DesktopActionBar

@Composable
fun DesktopActionBar(
expansionAction : @Composable () -> Unit = {},
primaryAction: @Composable () -> Unit = {},
actions: @Composable RowScope.() -> Unit = {},
footer: @Composable () -> Unit = {},
backgroundColor: Color = MaterialTheme.colorScheme.surface,
contentColor: Color = contentColorFor(backgroundColor),
height: Dp = 56.dp,
elevation: Dp = 2.dp,
modifier: Modifier = Modifier
) {
Surface(
color = backgroundColor,
contentColor = contentColor,
tonalElevation = elevation,
modifier = modifier.fillMaxWidth().height(height)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp)
) {
// Expansion action (e.g., a button or icon to expand/collapse the panel)
expansionAction()

// Primary action
primaryAction()

// Other actions (scrollable if needed)
Row(
modifier = Modifier
.horizontalScroll(rememberScrollState())
.weight(1f)
.padding(start = 8.dp),
verticalAlignment = Alignment.CenterVertically,
content = actions
)

// Footer area
footer()
}
}
}@Composable
fun DesktopActionBar(
expansionAction : @Composable () -> Unit = {},
primaryAction: @Composable () -> Unit = {},
actions: @Composable RowScope.() -> Unit = {},
footer: @Composable () -> Unit = {},
backgroundColor: Color = MaterialTheme.colorScheme.surface,
contentColor: Color = contentColorFor(backgroundColor),
height: Dp = 56.dp,
elevation: Dp = 2.dp,
modifier: Modifier = Modifier
) {
Surface(
color = backgroundColor,
contentColor = contentColor,
tonalElevation = elevation,
modifier = modifier.fillMaxWidth().height(height)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp)
) {
// Expansion action (e.g., a button or icon to expand/collapse the panel)
expansionAction()

// Primary action
primaryAction()

// Other actions (scrollable if needed)
Row(
modifier = Modifier
.horizontalScroll(rememberScrollState())
.weight(1f)
.padding(start = 8.dp),
verticalAlignment = Alignment.CenterVertically,
content = actions
)

// Footer area
footer()
}
}
}

When To Use DesktopAreaScaffold

  • You already have a high-level application scaffold in place and need more advanced, nested panels.
  • Certain sections of your app require a secondary or tertiary area that can be expanded or hidden independently of the main navigation rail.
  • You want to keep your main top bar consistent across all screens, but have the freedom to add local toolbars, sidebars, or footers in specific sections.

DesktopTextField: A More Compact Text Field for Desktop

While the out-of-the-box Material text fields work great on touch devices or when following a strict Material 3 design language, they can feel oversized in desktop contexts — especially when placed in toolbars or side panels. That’s why DesktopTextField and DesktopFilledTextField where introduced: two variants of a slimmer text field that still preserve Compose’s flexibility but with dimensions, spacing, and styling more suitable for desktop use.

Key Differences from Standard Material Text Fields

Fixed 36.dp Height

  • A more compact vertical size than the default Material text fields (which tend to be ~56.dp or taller).

BasicTextField Under the Hood

  • Instead of using TextField from Material, both versions leverage BasicTextField with a custom decorationBox.
  • This allows fine-tuning of borders, background colors, shape, and paddings without inheriting the larger Material constraints.

Style & Shape Tweaks

  • An 8.dp rounded corner shape keeps things modern without consuming too much space.
  • Default text style is set to 13.sp, which is often more appropriate for a desktop interface than 16.sp or larger.

Two Variants

  • DesktopTextField: Uses a 1.dp border by default, with a transparent background (good for outlines).
  • DesktopFilledTextField: Applies a subtle background tint (surfaceVariant with alpha) to differentiate the field from the surrounding UI.

When to Use DesktopTextField vs. Material Text Field

Use DesktopTextField / DesktopFilledTextField when:

  • You need to conserve space in a top bar, action bar, or narrow side panel.
  • You want a design that feels more aligned with desktop UI conventions (slightly smaller text, less padding).

Stick with Material’s default TextField when:

  • You’re building a touch-focused or cross-platform interface where the standard Material sizes are appropriate.

Wrapping Up

Designing for desktop form factors in Jetpack Compose requires a bit of extra planning beyond the standard mobile-focused Material 3 approach. By crafting Desktop Scaffolds — both at the application level and within specific content areas — you can provide a more natural, space-efficient, and desktop-friendly UI structure. Coupled with smaller components like DesktopTextField, your Compose Desktop apps will feel right at home on larger screens, with layouts and text sizes that reflect the expectations of traditional desktop users.

Embrace Desktop-Specific Layouts

  • The DesktopApplicationScaffold and DesktopAreaScaffold patterns let you adapt the slot-based approach of Material’s Scaffold to desktop paradigms—like permanently visible navigation, optional side panels, and nested toolbars.

Use Slimmer Components

  • DesktopTextField variants address the fact that desktop users aren’t typically interacting via touch. Smaller heights and altered padding provide a more compact but still stylish text entry experience.
  1. Mix and Match
  • Your app might have a consistent global structure (via DesktopApplicationScaffold) and more specialized nested scaffolds for certain screens (via DesktopAreaScaffold). Each scaffold can have its action bars, collapsible panels, or side-by-side content panels as needed.

--

--

No responses yet