Getting started with the Compose HTML library
In this tutorial, we will create a simple web UI application using the Compose HTML Library.
Prerequisites
You need to have the following software installed before you begin:
- JDK 11 or later
- IntelliJ IDEA Community Edition or Ultimate Edition 2020.2 or later (you can use other editors, but for this tutorial we assume you are using IntelliJ IDEA)
Creating a new project
You can create the project by downloading the template here.
If you want to create the project manually, perform the following steps:
1. Create a Kotlin Multiplatform project:
- Select
Gradle
on the left menu - Tick
Kotlin DSL build script
- Tick
Kotlin/Multiplatform

2. Update settings.gradle.kts
:
pluginManagement {
repositories {
gradlePluginPortal()
maven("https://maven.pkg.jetbrains.space/public/p/compose/dev")
}
}
3. Update build.gradle.kts
:
// Add compose gradle plugin
plugins {
kotlin("multiplatform") version "2.1.20"
id("org.jetbrains.compose") version "1.8.2"
}
// Add maven repositories
repositories {
mavenCentral()
maven("https://maven.pkg.jetbrains.space/public/p/compose/dev")
google()
}
// Enable JS(IR) target and add dependencies
kotlin {
js(IR) {
browser()
binaries.executable()
}
sourceSets {
val jsMain by getting {
dependencies {
implementation(compose.html.core)
implementation(compose.runtime)
}
}
}
}
5. Add the following directories to the project:
- src/jsMain/kotlin
- src/jsMain/resources
6. Add the index.html
file to the resources
:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Sample</title>
</head>
<body>
<div id="root"></div>
<script src="REPLACE_WITH_YOUR_MODULE_NAME.js"></script>
</body>
</html>
7. Add the Main.kt
file to the kotlin
:
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.runtime.Composable
import org.jetbrains.compose.web.attributes.*
import org.jetbrains.compose.web.css.*
import org.jetbrains.compose.web.dom.*
import org.jetbrains.compose.web.renderComposable
fun main() {
var count: Int by mutableStateOf(0)
renderComposable(rootElementId = "root") {
Div({ style { padding(25.px) } }) {
Button(attrs = {
onClick { count -= 1 }
}) {
Text("-")
}
Span({ style { padding(15.px) } }) {
Text("$count")
}
Button(attrs = {
onClick { count += 1 }
}) {
Text("+")
}
}
}
}
Running the project
Use the command line to run:
./gradlew jsBrowserRun
Instead of manually compiling and executing a Kotlin/JS project every time you want to see the changes you made, you can use the continuous compilation mode:
./gradlew jsBrowserRun --continuous
Or run it from the IDE:

The browser will open localhost:8080
:

Common issues when running the project
[webpack-cli] Unable to load '@webpack-cli/serve' command
https://youtrack.jetbrains.com/issue/KT-49124
[webpack-cli] Unable to load '@webpack-cli/serve' command
[webpack-cli] TypeError: options.forEach is not a function
...
There is a temporary workaround:
In build.gradle.kts:
// a temporary workaround for a bug in jsRun invocation - see https://youtrack.jetbrains.com/issue/KT-48273
afterEvaluate {
rootProject.extensions.configure<org.jetbrains.kotlin.gradle.targets.js.nodejs.NodeJsRootExtension> {
versions.webpackDevServer.version = "4.0.0"
versions.webpackCli.version = "4.9.0"
}
}
Building the UI with the Compose HTML library
In this tutorial we will look at several examples that use the Composable HTML/CSS DSL to describe the user interface for your web application.
Entry point
The Compose HTML library needs an HTML node that will be a root of its composition. Inside this root node, Compose then manages its own DOM tree.
renderComposable(rootElementId = "root") {
// content goes here
}
HTML tags
Let's have a look at the Composable for a Div
tag (most other tags have the same signature):
Div(
attrs = {
// specify attributes here
style {
// specify inline style here
}
}
) {
// div content goes here
}
For convenience, some tags like Input
, A
, Form
, or Img
allow you to specify some extra parameters in the signature that are specific to the respective HTML tag. For example, let’s look at the Input
tag:
Input(
type = InputType.Text, // All InputTypes supported
attrs = {}
)
We can use the type
parameter which is provided for our convenience, or can use the attrs
block to specify the input type:
Input(attrs = { type(InputType.Text) })
Text
The Text
allows you to add text content to an HTML tag. Besides the text content it represents, it does not have any parameters:
Text("Arbitrary text")
If you want to apply styles to text, it needs to be wrapped in a container with a style applied, like a Span
or P
:
Span(
attrs = { style { color(Color.red) } } // inline style
) {
Text("Red text")
}
This corresponds to the following HTML code:
<span style="color: red;">Red text</span>
Attributes
The attrs
parameter (which we’ve already seen in some of the previous examples) allows us to specify element's attributes and properties.
The most flexible way to define attributes is by using the attr
function, which allows you to specify the attribute name and its value.
Div(
attrs = {
attr(attr = "custom_attr", value = "its_value")
}
) { /* content */ }
However, with this approach, Compose is not able to validate that the attribute exists on the HTML element, or is valid. This is why we also provide a set of helper functions for common attributes.
Common attributes
Here are some examples of common attributes that are available for most Composables representing HTML tags:
attrs = {
id("elementId")
classes("cl1", "cl2")
hidden(false)
title("title")
draggable(Draggable.Auto)
dir(DirType.Auto)
lang("en")
contentEditable(true)
}
Element specific attributes
Depending on the element you are working with, you may also have access to some specific attributes – attributes that are only meaningful for this particular tag. For example, the A
tag provides some specific attributes, that are specific to hyperlinks:
A(
attrs = {
href("https://localhost:8080/page2")
target(ATarget.Blank)
rel(ARel.Next)
hreflang("en")
download("https://...")
}
) {}
Some other elements that provide specific attributes include:
- Button
- Form
- Input
- Option
- Select
- OptGroup
- TextArea
- Img
To discover all attributes that are available in your current scope, you can use your IDE’s autocomplete feature. As we evolve these APIs, we also plan to add detailed documentation for them.
Events
You can declare event listeners in the attrs
block:
Button(
attrs = {
onClick { println("Button clicked") }
}
) { Text("Button") }
There are more examples about events handling here - Events Handling
Style
There are ways to set the style for a component:
- Using inline styles
- Using stylesheets
You can declare inline styles via the style
block of a component:
Div(
attrs = {
style {
display(DisplayStyle.Flex)
padding(20.px)
// custom property
property("font-family", "Arial, Helvetica, sans-serif")
}
}
) { /* content goes here */ }
You can find a more detailed overview of the style DSL, as well as additional examples here - Style DSL
Runnable example
import androidx.compose.runtime.Composable
import org.jetbrains.compose.web.attributes.*
import org.jetbrains.compose.web.css.*
import org.jetbrains.compose.web.dom.*
import org.jetbrains.compose.web.renderComposable
fun main() {
renderComposable(rootElementId = "root") {
Div(
attrs = {
// specify attributes here
style {
// specify inline style here
}
}
) {
Text("A text in <div>")
}
Input(
type = InputType.Text, // All InputTypes supported
attrs = {}
)
Text("Arbitrary text")
Span({
style { color(Color.red) } // inline style
}) {
Text("Red text")
}
Div(
attrs = {
id("elementId")
classes("cl1", "cl2")
hidden()
title("title")
draggable(Draggable.Auto)
dir(DirType.Auto)
lang("en")
contentEditable(true)
// custom attr
attr(attr = "custom_attr", value = "its_value")
}
) { /* content */ }
A(
attrs = {
href("https://localhost:8080/page2")
target(ATarget.Blank)
hreflang("en")
download("https://...")
}
) { Text("Link") }
Button(
attrs = {
onClick { println("Button clicked") }
}
) { Text("Button") }
Div({
style {
display(DisplayStyle.Flex)
padding(20.px)
// custom property
property("font-family", "Arial, Helvetica, sans-serif")
}
}) { Text("Text in Div with inline style") }
}
}
Controlled and Uncontrolled inputs
Input components have two modes: Controlled and Uncontrolled.
Controlled vs Uncontrolled behaviour
Let's create two inputs using different modes and compare them:
// Uncontrolled
Input(type = InputType.Text) {
defaultValue("Initial Value") // optional
onInput { event -> println(event.value) }
}
// Controlled
Input(type = InputType.Text) {
value("Some value") // calling value(...) is necessary to make input "Controlled"
onInput { event -> println(event.value) }
}
If you try running these snippets you'll see following behaviour:
- Uncontrolled text input will show "Initial value". Typing will make corresponding changes to the input's state.
- Controlled text input will show "Some value". But typing will not cause any changes.
- Both inputs will receive an
event
inonInput { }
handler
In the example above, we set hardcoded value
- value("Some value")
. Therefore, typing does nothing.
Under the hood, controlled input "restores" its state according to last known value
.
Using MutableState with Controlled Input
To make Controlled Input more useful we can use MutableState<*>
to keep input's value:
val inputState = remember { mutableStateOf("Some Text") }
Input(type = InputType.Text) {
value(inputState.value)
onInput { event -> println(event.value) }
}
We can see that, inputState
never mutates. If we run such an example as is, we'll see the same behaviour
as when value(...)
was hardcoded. But if we had some code that updates inputState
, then Input would recompose and new value would be shown.
In most cases, inputState
needs to be changed in onInput
event handler:
val inputState = remember { mutableStateOf("Some Text") }
Input(type = InputType.Text) {
value(inputState.value)
onInput { event -> inputState.value = event.value }
}
Conclusion
Uncontrolled input changes its content independently while Controlled input's content can be changed only by external state (such as MutableState).
In most cases Controlled input is the default choice.
Convenient controlled inputs
Here is a list of Composable functions which represent controlled inputs of different types:
- CheckboxInput
- DateInput
- DateTimeLocalInput
- EmailInput
- FileInput
- MonthInput
- NumberInput
- PasswordInput
- RadioInput
- RangeInput
- SearchInput
- TelInput
- TextInput
- TimeInput
- UrlInput
- WeekInput
Example:
val inputState = remember { mutableStateOf("Some Text") }
TextInput(value = inputState.value) {
onInput { event -> inputState.value = event.value }
}
Style DSL
In this tutorial we have a look at how to style the components using the Style DSL. It’s a typesafe DSL for style sheets, which you can use to express CSS rules in your Kotlin code, and even modify styles based on the state of your Compose application.
Inline Style
You can declare inline styles via the style
block of a component
Div({
style {
display(DisplayStyle.Flex)
padding(20.px)
// custom property (or not supported out of a box)
property("font-family", "Arial, Helvetica, sans-serif")
}
}) { /* content goes here */ }
In HTML, it will look like this:
<div style="display: flex; padding: 20px; font-family: Arial, Helvetica, sans-serif;"></div>
Stylesheet
An alternative way is to define a Stylesheet that contains rules:
object AppStylesheet : StyleSheet() {
val container by style { // container is a class
display(DisplayStyle.Flex)
padding(20.px)
// custom property (or not supported out of a box)
property("font-family", "Arial, Helvetica, sans-serif")
}
}
// Stylesheet needs to be mounted:
renderComposable("root") {
Style(AppStylesheet)
Container {
Text("Content")
}
}
@Composable
fun Container(content: @Composable () -> Unit) {
Div(
attrs = { classes(AppStylesheet.container) }
) {
content()
}
}
In HTML, it will look like this:
<style></style>
<div class="AppStylesheet-container">Content</div>
Selectors examples
The Style DSL also provides a way to combine and unify selectors:
object AppStylesheet : StyleSheet() {
init {
// `universal` can be used instead of "*": `universal style {}`
"*" style {
fontSize(15.px)
padding(0.px)
}
// raw selector
"h1, h2, h3, h4, h5, h6" style {
property("font-family", "Arial, Helvetica, sans-serif")
}
// combined selector
type("A") + attr( // selects all tags <a> with href containing 'jetbrains'
name = "href",
value = "jetbrains",
operator = CSSSelector.Attribute.Operator.Equals
) style {
fontSize(25.px)
}
}
// A convenient way to create a class selector
// AppStylesheet.container can be used as a class in component attrs
val container by style {
color(Color.red)
// hover selector for a class
self + hover() style { // self is a selector for `container`
color(Color.green)
}
}
}
Media query example
To specify media queries, you can use the media
function, which takes the related query, and a block of styles:
object AppStylesheet : StyleSheet() {
val container by style {
padding(48.px)
media(mediaMaxWidth(640.px)) {
self style {
padding(12.px)
}
}
}
}
CSS Variables
The style DSL also provides support for CSS variables.
object MyVariables {
// declare a variable
val contentBackgroundColor by variable<CSSColorValue>()
}
object MyStyleSheet: StyleSheet() {
val container by style {
//set variable's value for the `container` scope
MyVariables.contentBackgroundColor(Color("blue"))
}
val content by style {
// get the value
backgroundColor(MyVariables.contentBackgroundColor.value())
}
val contentWithDefaultBgColor by style {
// default value can be provided as well
// default value is used when the value is not previously set
backgroundColor(MyVariables.contentBackgroundColor.value(Color("#333")))
}
}
Runnable example
import androidx.compose.runtime.Composable
import org.jetbrains.compose.web.css.*
import org.jetbrains.compose.web.dom.*
import org.jetbrains.compose.web.renderComposable
object MyVariables {
// declare a variable
val contentBackgroundColor by variable<CSSColorValue>()
}
object MyStyleSheet: StyleSheet() {
val container by style {
//set variable's value for the `container` scope
MyVariables.contentBackgroundColor(Color("blue"))
}
val content by style {
// get the value
backgroundColor(MyVariables.contentBackgroundColor.value())
}
val contentWithDefaultBgColor by style {
// default value can be provided as well
// default value is used when the value is not previously set
backgroundColor(MyVariables.contentBackgroundColor.value(Color("#333")))
}
}
object AppStylesheet : StyleSheet() {
val container by style { // container is a class
display(DisplayStyle.Flex)
padding(20.px)
// custom property (or not supported out of a box)
property("font-family", "Arial, Helvetica, sans-serif")
}
}
@Composable
fun Container(content: @Composable () -> Unit) {
Div(
attrs = { classes(AppStylesheet.container) }
) {
content()
}
}
fun main() {
renderComposable(rootElementId = "root") {
Div({
style {
display(DisplayStyle.Flex)
padding(20.px)
// custom property (or not supported out of a box)
property("font-family", "Arial, Helvetica, sans-serif")
}
}) { /* content goes here */ }
Style(AppStylesheet)
Container {
Text("Content")
}
}
}
Using Effects
The Compose HTML library introduces a few dom-specific effects on top of existing effects from Compose.
ref in AttrsBuilder
Under the hood, ref
uses DisposableEffect
ref
can be used to retrieve a reference to a html element.
The lambda that ref
takes in is not Composable. It will be called only once when an element added into a composition.
Likewise, the lambda passed in onDispose
will be called only once when an element leaves the composition.
Div(attrs = {
ref { htmlDivElement ->
// htmlDivElement is a reference to the HTMLDivElement
onDispose {
// add clean up code here
}
}
}) {
// Content()
}
Only one ref
can be used per element. Calling it more than once will dismiss earlier calls.
For example, ref
can be used to add and remove some event listeners not provided out of the box.
DisposableRefEffect (deprecated)
Deprecated:
Consider using DisposableEffect
. Its scope provides scopeElement
- a reference to the underlying HTML element:
DisposableEffect(key) {
scopeElement.innerText = key
onDispose { scopeElement.innerText = "" }
}
Under the hood, DisposableRefEffect
uses DisposableEffect
DisposableRefEffect
is similar to ref
, since it also provides a reference to an element. At the same time it has few differences.
DisposableRefEffect
can be added only within a content lambda of an element, whileref
can be used only inattrs
scope.- Unlike
ref
,DisposableRefEffect
can be used as many times as needed and every effect will be unique. - DisposableRefEffect can be used with a
key
and without it. When it's used with akey: Any
, the effect will be disposed and reset whenkey
value changes. When it's used without a key, then it behaves likeref
- the effect gets called only once when an element enters the composition, and it's disposed only when the element leaves the composition.
Div {
// without a key
DisposableRefEffect { htmlDivElement ->
// htmlDivElement is a reference to the HTMLDivElement
onDispose {
// add clean up code here
}
}
}
var state by remember { mutableStateOf(1) }
Div {
// with a key.
// The effect will be called for every new state's value
DisposableRefEffect(state) { htmlDivElement ->
// htmlDivElement is a reference to the HTMLDivElement
onDispose {
// add clean up code here
}
}
}
DomSideEffect (deprecated)
Deprecated:
Consider using SideEffect.
If a reference to an underlying HTML element is needed, consider using DisposableEffect
and scopeElement
within its scope.
Under the hood, DomSideEffect
uses SideEffect
DomSideEffect
as well as DisposableRefEffect
can be used with a key and without it.
Unlike DisposableRefEffect
, DomSideEffect
without a key is invoked on every successful recomposition.
With a key
, it will be invoked only when the key
value changes.
Same as SideEffect, DomSideEffect
can be helpful when there is a need to update objects not managed by Compose.
In case of web, it often involves updating HTML nodes, therefore DomSideEffect
provides a reference to an element in the lambda.
Code Sample using effects
The code below showcases how it's possible to use non-composable components in Compose by applying DomSideEffect
and DisposableRefEffect
.
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.runtime.Composable
import kotlinx.browser.document
import org.jetbrains.compose.web.css.*
import org.jetbrains.compose.web.dom.*
import org.jetbrains.compose.web.renderComposable
import org.w3c.dom.HTMLElement
import org.w3c.dom.HTMLParagraphElement
// Here we pretend that `RedBoldTextNotComposableRenderer`
// wraps a UI logic provided by 3rd party library that doesn't use Compose
object RedBoldTextNotComposableRenderer {
fun unmountFrom(root: HTMLElement) {
root.removeChild(root.firstChild!!)
}
fun mountIn(root: HTMLElement) {
val pElement = document.createElement("p") as HTMLParagraphElement
pElement.setAttribute("style", "color: red; font-weight: bold;")
root.appendChild(pElement)
}
fun renderIn(root: HTMLElement, text: String) {
(root.firstChild as HTMLParagraphElement).innerText = text
}
}
// Here we define a Composable wrapper for the above code. Here we use DomSideEffect and DisposableRefEffect.
@Composable // @param `show: Boolean` was left here intentionally for the sake of the example
fun ComposableWrapperForRedBoldTextFrom3rdPartyLib(state: Int, show: Boolean) {
Div(attrs = {
style {
backgroundColor(Color.lightgray)
width(100.px)
minHeight(40.px)
padding(30.px)
}
}) {
if (!show) {
Text("No content rendered by the 3rd party library")
}
Div {
if (show) {
// Update the content rendered by "non-compose library" according to the `state`
DomSideEffect(state) { div ->
RedBoldTextNotComposableRenderer.renderIn(div, "Value = $state")
}
}
DisposableRefEffect(show) { div ->
if (show) {
// Let "non-compose library" control the part of the page.
// The content of this div is independent of Compose.
// It will be managed by RedBoldTextNotComposableRenderer
RedBoldTextNotComposableRenderer.mountIn(div)
}
onDispose {
if (show) {
// Clean up the html created/managed by "non-compose library"
RedBoldTextNotComposableRenderer.unmountFrom(div)
}
}
}
}
}
}
fun main() {
var state by mutableStateOf(0)
var showUncontrolledElements by mutableStateOf(false)
renderComposable(rootElementId = "root") {
ComposableWrapperForRedBoldTextFrom3rdPartyLib(state = state, show = showUncontrolledElements)
Div {
Label(forId = "checkbox") {
Text("Show/hide text rendered by 3rd party library")
}
CheckboxInput(checked = showUncontrolledElements) {
id("checkbox")
onInput {
showUncontrolledElements = it.value
}
}
}
Button(attrs = {
onClick { state += 1 }
}) {
Text("Incr. count ($state)")
}
}
}
Events handling
You can add event listeners in the attrs
block:
onClick
Button(
attrs = {
onClick { event ->
// event is of `SyntheticMouseEvent` type
println("button clicked at ${event.movementX}, ${event.movementY}")
val nativeEvent = event.nativeEvent // [MouseEvent](https://developer.mozilla.org/en/docs/Web/API/MouseEvent)
}
}
) {
Text("Button")
}
onInput
val text = remember { mutableStateOf("") }
TextArea(
value = text.value,
attrs = {
onInput {
text.value = it.value
}
}
)
Other event handlers
For events that don't have their own configuration functions in the attrs
block, you can use addEventListener
with the name
of the event, options
, and an pass an eventListener
which receives a SyntheticEvent
. In this example, we're defining the behavior of a Form
element when it triggers the submit
event:
Form(attrs = {
this.addEventListener("submit") {
console.log("Hello, Submit!")
it.preventDefault()
}
})
There are more event listeners supported out of the box. You can find all supported event listeners in the source code.
Runnable example
import androidx.compose.runtime.*
import org.jetbrains.compose.web.css.*
import org.jetbrains.compose.web.dom.*
import org.jetbrains.compose.web.renderComposable
fun main() {
renderComposable(rootElementId = "root") {
Button(
attrs = {
onClick { event ->
println("button clicked at ${event.movementX}, ${event.movementY}")
}
}
) {
Text("Button")
}
val text = remember { mutableStateOf("") }
TextArea(
value = text.value,
attrs = {
onInput {
text.value = it.value
}
}
)
Span {
Text("Typed text = ${text.value}")
}
}
}
Using test-utils for unit testing
Dependencies
It's necessary to add compose.html.testUtils
to jsTest dependencies:
sourceSets {
val jsMain by getting {
dependencies {
implementation(compose.html.core)
implementation(compose.runtime)
//....
}
}
val jsTest by getting {
implementation(kotlin("test-js"))
implementation(compose.html.testUtils)
//...
}
}
Example
// This is a function that we want to test
@Composable
fun TestButton(text: String, onButtonClick: () -> Unit) {
Button(attrs = {
onClick { onButtonClick() }
}) {
Text(text)
}
}
Let's add a test to ensure that button has correct text, and it's onClick works properly.
import org.jetbrains.compose.web.testutils.ComposeWebExperimentalTestsApi
import org.jetbrains.compose.web.testutils.runTest
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import org.w3c.dom.HTMLButtonElement
import kotlin.test.Test
import kotlin.test.assertEquals
@OptIn(ComposeWebExperimentalTestsApi::class)
class TestsForButton {
@Test
fun testButton() = runTest {
var counter by mutableStateOf(1)
composition {
TestButton(text = "$counter") {
counter++
}
}
assertEquals("<button>1</button>", root.innerHTML)
(root.firstChild!! as HTMLButtonElement).click()
waitForRecompositionComplete()
assertEquals("<button>2</button>", root.innerHTML)
counter = 10
waitForRecompositionComplete()
assertEquals("<button>10</button>", root.innerHTML)
}
}
Let's break it down:
runTest { ... }
Provides the TestScope with useful functions to configure the test.
composition { ... }
Takes a @Composable block with a content that we want to test.
It will automatically build and mount DOM into root
element.
root
It's not supposed to be used for elements manipulation.
It's mostly useful to make assertions on the html content (e.g. root.innerHtml
)
nextChild() and currentChild()
Under the hood nextChild()
iterates over root
children, providing convenient access to them.
currentChild()
doesn't move the iterator and returns the same element every time until nextChild()
called.
waitForRecompositionComplete()
It suspends until recomposition completes. It's useful when state changes, and we want to test that content updates as well. waitForRecompositionComplete
needs to be called after state change and before assertions.
waitForChanges(id: String)
It suspends until any change occur in the element with id
.
It's also useful to ensure that state changes make corresponding updates to the content.