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.
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.
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.
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:
- Capture the input events in Compose.
- Convert these events into the format Chromium can understand.
- 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:
- When the user is typing, the app receives composition events containing uncommitted text. This text is updated with each keystroke.
- The IME displays a suggestion popup, rendered natively by the OS, showing possible characters to choose from.
- 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.
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.
Sending…
Sorry, the sending was interrupted
Please try again. If the issue persists, contact us at info@teamdev.com.
Your personal JxBrowser trial key and quick start guide will arrive in your Email Inbox in a few minutes.