At TeamDev, we develop JxBrowser, a commercial library that embeds Chromium into Java desktop apps. Over the years, we’ve implemented JxBrowser BrowserView controls for the classic Java UI toolkits like Swing, JavaFX, and SWT. Then, we decided to take the next step and extend support to Compose Multiplatform for desktop.

In this article, I’ll walk you through how we built a BrowserView control for Compose Desktop and the technical challenges we solved during this process — from rendering and input handling to lifecycle and native window management.

A browser inside your app 

A web view is a UI component that lets an app display and interact with web content. Think of it as a browser embedded in your application that you control with code. On the screen, it looks like a rectangle with a web page inside:

A web view embedded in a desktop app

A web view embedded in a desktop app.

Under the hood, a web view embeds an entire browser engine. For example, on Android, the built-in WebView component uses Chromium. When your app creates a WebView, it connects to the Chromium engine, allowing it to render HTML, run JavaScript, and handle cookies or web requests, all within the app’s lifecycle.

Web views are a great way to modernize legacy desktop apps. You can embed new UI parts built with your favorite JavaScript frontend framework and gradually replace the old UI, eventually moving the entire app to the web step by step.

Why Compose 

Jetpack Compose quickly gained popularity among Kotlin developers on Android. With Compose Multiplatform, the same declarative UI model is now available on desktop, opening the door for modern Java and Kotlin applications.

We wanted to align with a technology that’s actively evolving and has a strong, growing community behind it. Since Compose doesn’t include a built-in web view for desktop, we saw an opportunity to fill that gap.

A different kind of UI 

If you come from a classic Java background, Compose may feel unfamiliar. Traditional UI toolkits like Swing, JavaFX, or SWT use an imperative model: you create buttons, labels, and panels manually, and then write code to update them.

For example, if you want to update a label when a button is clicked, you write something like:

private int count = 0;
private JLabel label = new JLabel("Clicked 0 times");
private JButton button = new JButton("Click me");
...
button.addActionListener(e -> label.setText("Clicked " + ++count + " times"));

JPanel panel = new JPanel();
panel.add(label);
panel.add(button);

You tell the system exactly what to do and when.

Compose takes a different path. Originally built for Android, it follows a declarative model, where you describe what the UI should look like for a given state:

var count by remember { mutableStateOf(0) }
Column {
    Text("Clicked $count times")
    Button(onClick = { count++ }) {
        Text("Click me")
    }
}

Here, you don’t manually update the label. You declare that the text depends on count, and Compose automatically updates the UI when count changes.

We’ve spent years adapting Chromium to work with imperative frameworks. Moving to a declarative one meant rethinking that integration from the ground up.

In the sections below, I’ll walk you through what we built and what we learned along the way.

Rendering 

When you open a web page, the browser turns HTML, CSS, and JavaScript into pixels on your screen. Chromium does this in a separate process, fully isolated from your application. Our job is to take those pixels and show them inside a UI.

That’s the tricky part — web content rendering is already expensive, plus it happens entirely outside Compose’s rendering pipeline.

To solve this, we have two approaches: pixel copying and rendering to a native surface.

Pixel copying 

In this approach, Chromium renders the page and copies the resulting pixels into a buffer in Java memory. We can read the pixels from that buffer and create an image to display in a Compose component.

val image = mutableStateOf<Image?>(null)
...
private fun handlePaintRequest(request: PaintRequest) {
    val newImage = Image.makeRaster(bytes = request.pixels, ...)
    scope.launch {
        // Dispose of the previous image to prevent memory leaks.
        image.value?.close()
        image.value = newImage
    }
}

Once the image is ready, we draw it using a Canvas composable:

Canvas(modifier = Modifier.fillMaxSize()) {
    drawIntoCanvas {
        it.nativeCanvas.drawImage(image.value, left = 0f, top = 0f)
    }
}

Each time Chromium sends a new frame, the image state updates. Since we observe this state, Compose automatically redraws the UI with the latest browser content.

Native surface 

In this approach, Chromium renders pixels directly to a native system window, which we embed inside a Compose component. You can think of it as placing a live browser window into a blank area of your UI. It moves, resizes, and minimizes along with the rest of the app.

A native window on top of the app

A native window on top of the app.

Under the hood, JxBrowser implements this approach differently on each platform. On Windows and Linux, we embed the Chromium window directly into the app’s window. On macOS, where cross-process window embedding isn’t allowed, we use the CALayer sharing approach. The idea is that Chromium renders into a layer in its GPU process, and we use native macOS APIs to share that layer with the app’s process and display it.

Performance 

When choosing between pixel copying and native surface rendering, performance is a key factor. Let’s briefly compare both approaches.

Pixel copying 

Pixel copying is expensive. Each frame must be transferred from Chromium, converted into an image, and drawn in a Canvas. This process consumes CPU and memory, and the impact increases with higher resolution and frame rate.

Performance of pixel copying across platforms

Performance of pixel copying across platforms.

Usually, this approach can be optimized using dirty rectangles. When only part of the rendered content updates, we can repaint just that region. But Compose doesn’t offer that kind of control. The Canvas composable redraws the entire area on every frame, even if only a few pixels have changed.

Native surface 

Here, rendering performance is nearly identical to Chromium or Chrome itself (4К 60FPS), because Chromium renders the pixels directly on a native window embedded in the component.

Which one to use? 

Well, it comes down to a key trade-off: native windows don’t play well with Compose components. You can’t draw Compose UI over the web view since the native window will always stay on top. That’s because native windows are heavyweight and rendered separately by the OS, while Compose components are lightweight and drawn into a single layer.

The only workaround is to move overlays like tooltips or popups into a separate Compose window placed above the web view. For example, to show browser modal dialogs, you can use the standard DialogWindow composable, which opens a new window on top.

Pixel copying doesn’t have this limitation. Because everything is drawn in the same Compose layer, you can freely place the Compose UI over the browser content.

To sum it up:

  • Pixel copying offers full flexibility, but comes with higher CPU and memory usage.
  • Native surfaces deliver better performance, but limit your ability to overlay Compose UI.

Pick what works best for your app, or switch between them when needed.

Resizing the browser 

When you resize a window in Google Chrome, the operating system sends Chrome a notification, prompting it to re-layout and redraw its content at the new size. But when Chromium is embedded inside another application, it doesn’t get that message on its own.

Instead, we have to handle this manually. We read the new size from Compose, tell Chromium to resize the browser, and let it start rendering frames at the new size.

With onGloballyPositioned, we can get a composable’s size and position. Then take those values and tell Chromium that the window has been resized and it needs to start rendering larger or smaller frames.

val density = LocalDensity.current
Box(
    modifier = Modifier
        .fillMaxSize()
        .onGloballyPositioned { coords ->
            chromium.updateBounds(
                positionInWindow = coords.scaledPositionInWindow(density),
                size = coords.scaledSize(density)
            )
        }
)

Note that this modifier provides coordinates in raw pixels, but Chromium expects coordinates adjusted for the monitor’s scaling. Before passing the values from Compose to Chromium, we need to scale them properly.

Input synchronization 

On desktop, browsers handle mouse, keyboard, and touch input. When Chromium runs in a regular browser or renders on a native surface, the operating system sends input events directly to it. But with the pixel copying approach, Compose intercepts those events first, so Chromium doesn’t receive them automatically.

To ensure Chromium responds to user interactions, we need to:

  1. Capture the input events in Compose.
  2. Convert these events into the format Chromium can understand.
  3. Forward the converted events to Chromium.

Chromium will then react to this input as usual and will re-render the changes. We can achieve this with the pointerInput modifier:

.pointerInput(Unit) {
    awaitPointerEventScope {
        while (true) {
            val event = awaitPointerEvent()
            when (event.type) {
                Press -> chromium.forwardMousePressed(
                    location = event.location,
                    locationOnScreen = event.locationOnScreen,
                    button = event.button,
                    modifiers = event.modifiers,
                    clickCount = event.clickCount
                )
                Release -> chromium.forwardMouseReleased(event)
                Move -> chromium.forwardMouseMoved(event)
                // Scroll, Enter, Exit
            }
        }
    }
}

For keyboard input, we can use onKeyEvent. Same flow here: capture, convert, forward.

.onKeyEvent { event ->
    when {
        event.type == KeyDown -> chromium.forwardKeyPressed(
            code = event.keyCode,
            location = event.location,
            modifiers = event.modifiers
        )
        event.type == KeyUp -> chromium.forwardKeyReleased(event)
        event.isTypedEvent -> chromium.forwardKeyTyped(event)
    }
    STOP_PROPAGATION
}

IME support 

Keyboard input isn’t complete yet, as we still need to support international text like Chinese, Japanese, or Korean. These languages use Input Method Editors (IME), which let users enter complex characters that aren’t available on a standard keyboard.

Here’s how it works:

  1. When the user is typing, the app receives composition events containing uncommitted text. This text is updated with each keystroke.
  2. The IME displays a suggestion popup, rendered natively by the OS, showing possible characters to choose from.
  3. When the user selects a character, the app receives a commit event with the final text and inserts it into the field.

IME suggestion popup

IME suggestion popup.

Compose supports IME in standard composables, but making it work in a custom one is not straightforward. Usually, you’d use a TextField, but in our case, the actual input field lives inside a web page and is a piece of canvas for Compose.

To resolve this, we need a way to receive IME events just like TextField does. When a TextField is focused, it connects to the platform’s input system and starts receiving IME events, like composition updates or character commits. Compose converts those events into command objects that we can handle in code. Here’s how that looks in practice:

.onFocusChanged {
    if (it.isFocused) {
        // Start listening for IME input when component receives focus.
        inputSession = textInputService.startInput(
            value = TextFieldValue(),
            imeOptions = ImeOptions.Default,
            onImeActionPerformed = { /* no-op */ },
            onEditCommand = ::onEditCommands,
        )
        // Position the IME popup.
        inputSession?.notifyFocusedRect(imePopupRect)
    } else {
        inputSession?.dispose()
    }
}

Note that we pass a dummy TextFieldValue and leave onImeActionPerformed empty, since all text input and actions are handled directly by Chromium. The only thing we forward are the edit commands, which describe how the input should change, so Chromium can render the composition correctly:

private fun onEditCommands(commands: List<EditCommand>) {
    commands.forEach { command ->
        when (command) {
            is CommitTextCommand -> chromium.commitText(command.text)
            is SetComposingTextCommand -> chromium.setComposition(command.text)
        }
    }
}

Once Chromium receives these events, it updates the input field on the web page and renders the selected characters.

State and lifecycle management 

In JxBrowser, you’re expected to close objects when they’re no longer needed. So when the web view appears, we need to set things up, and when it disappears, we must tear them down. Sounds simple, right?

In Compose, components are created and disposed automatically as state changes. To hook into this lifecycle, we can use DisposableEffect. It lets us run setup code when a composable enters the composition, and clean up when it leaves:

DisposableEffect(Unit) {
    // Set up resources: pixel buffer, listeners, service objects, etc.
    onDispose {
        // Clean up resources.
    }
}

This works well for simple cases where the web view composable (BrowserView) is created once and stays on screen. However, let’s consider a more complex example: a tabbed UI, where each tab should display a different BrowserView:

val engine = remember { Engine(renderingMode = OFF_SCREEN) }
val browser1 = remember { engine.newBrowser() }
val browser2 = remember { engine.newBrowser() }
val browser3 = remember { engine.newBrowser() }
val tabs = listOf("Tab 1", "Tab 2", "Tab 3")
val selectedTabIndex = remember { mutableStateOf(0) }

Column {
    TabRow(selectedTabIndex = selectedTabIndex.value) {
        tabs.forEachIndexed { index, title ->
            Tab(
                selected = selectedTabIndex.value == index,
                onClick = { selectedTabIndex.value = index },
                text = { Text(title) }
            )
        }
    }
    when (selectedTabIndex.value) {
        0 -> BrowserView(browser1)
        1 -> BrowserView(browser2)
        2 -> BrowserView(browser3)
    }
}

When you switch tabs, the previous BrowserView is removed from the composition, and a new one is added in its place. In Compose, removing a composable also disposes its state unless it’s remembered outside. This means the BrowserView tears down everything and then recreates it from scratch. As a result, the rendered content is briefly lost and reinitialized, which can cause a visible flicker on screen.

To avoid this, we decoupled the state from the composable. Instead of managing resources inside BrowserView, we introduced BrowserViewState — a holder for everything the browser view needs: the Browser, the last rendered frame, listeners, and service objects. Now, BrowserView becomes stateless:

val state1 = rememberBrowserViewState(browser1)
val state2 = rememberBrowserViewState(browser2)
val state3 = rememberBrowserViewState(browser3)

Column {
    TabRow(selectedTabIndex = selectedTabIndex.value) {
        tabs.forEachIndexed { index, title ->
            Tab(
                selected = selectedTabIndex.value == index,
                onClick = { selectedTabIndex.value = index },
                text = { Text(title) }
            )
        }
    }
    when (selectedTabIndex.value) {
        0 -> BrowserView(state1)
        1 -> BrowserView(state2)
        2 -> BrowserView(state3)
    }
}

The rememberBrowserViewState holds the state across compositions. When this function leaves the composition, it automatically closes the state and cleans up, unless you do it manually using state.close() or browser.close(). Please note that it closes the browser view state, but does not close the browser object itself.

Filling the gaps with AWT 

Under the hood, Compose for Desktop is built on top of Java AWT and Swing. AWT ( Abstract Window Toolkit) is Java’s foundational UI framework, which provides access to native windows, events, and low-level input handling — the kind of stuff that Compose abstracts away. But when integrating Chromium, we need that low-level access.

We use AWT types directly in several parts of our integration. Here are some examples.

Raw input events 

Some input details, like screen position, click count, or certain key codes, aren’t exposed by Compose. But they’re still available through the original AWT event, which you can access via event.awtEventOrNull.

For example, when a user types a character, we need to pass that character to Chromium. But the typed character is only available in AWT’s keyTyped events.

val keyChar = event.awtEventOrNull!!.keyChar

Since JxBrowser already supports Swing, which is also based on AWT, we could reuse our experience with AWT event handling. Some parts, like scrolling, required tuning that we arrived at empirically. To make it work well, we scale the scroll delta manually using platform-specific constants:

// Platform-specific scroll scaling.
private val POINTS_PER_UNIT = if (isMac()) 10F else 100F / 3
// Invert direction to match Chromium's expectation.
private const val DIRECTION_FIX = -1F
...
val awtEvent = event.awtEventOrNull as MouseWheelEvent
val delta = awtEvent.unitsToScroll * POINTS_PER_UNIT * DIRECTION_FIX
val deltaX = if (awtEvent.isShiftDown) delta else 0F
val deltaY = if (!awtEvent.isShiftDown) delta else 0F

Window listeners 

To handle input in the right place, or to display a tooltip or IME popup where it should appear, Chromium needs to know the exact position of the rendered content, both relative to the window and relative to the screen.

Compose gives us onGloballyPositioned to track layout changes, but it doesn’t notify us when the window is moved, minimized, or restored. For that, we rely on AWT window events.

These events are dispatched by the underlying AWT window that hosts the Compose window. If your composable is inside a WindowScope, you can get access to that window and register listeners:

val onWindowMoved = object : ComponentAdapter() {
    override fun componentMoved(e: ComponentEvent) {
        val window = e.component as Window
        chromium.updateBounds(
            positionInScreen = positionInWindow + window.position
        )
    }
}
val onWindowIconified = object : WindowAdapter() {
    override fun windowIconified(e: WindowEvent) = chromium.minimize()
    override fun windowDeiconified(e: WindowEvent) = chromium.restore()
}

window.addComponentListener(onWindowMoved)
window.addWindowListener(onWindowIconified)

File choosers 

When a web page triggers a file upload, Chromium asks the app to show a file chooser dialog. Since Compose doesn’t include a built-in file dialog component, we can use AWT’s FileDialog or Swing’s JFileChooser to handle it.

Where we are now 

After months of development, testing, and polishing, BrowserView now feels right at home in Compose. It supports both rendering modes and works on Windows, macOS, and Linux.

Along the way, Compose Multiplatform proved to be a solid framework with great community support. During development, we reported several bugs and often asked questions on the Kotlin Slack. The responses were quick and helpful, and we often saw fixes land in the next release.

We’re proud of the result and excited to see what the Kotlin community builds with it. Feel free to use JxBrowser in your Compose Desktop applications, and let us know if you have any feedback or suggestions.

Spinner

Sending…

Sorry, the sending was interrupted

Please try again. If the issue persists, contact us at info@teamdev.com.

Read and agree to the terms to continue.

Your personal JxBrowser trial key and quick start guide will arrive in your Email Inbox in a few minutes.