Creating Web Components in Kotlin and Compose HTML
08 May, 2025
For a project I'm working on, we are investigating the possibility of using web components in order to encapsulate a very complex piece of UI. This UI is written in Compose HTML however so that poses an extra challenge.
Two years ago I was experimenting with this already but ran into quite some issues related to the way KotlinJS was compiled. To use Web Components you need to have a proper ES6 Classes, but this was not fully supported by KotlinJS. Especially not in combination with the Compose compiler.
I saw there was some improvement in this area recently so I decided to give it a try again. And I was pleasantly surprised!
Why?
First of all, it is important to understand why I'd want to have Web Components created by Kotlin/JS. The most important one is easy-of-use in the target application and encapsulation.
We can expose a full Compose HTML application to another HTML page by:
- providing the JS file generated by the compiler
- using the custom tag inside the target HTML
The other benefit is the encapsulation:
- The DOM is contained in the Shadow DOM
- Styling is contained within the component
Prequisites
I assume you have a basic understanding of Kotlin and Gradle, so I won't go into that here. In order to get started, you need to have the following dependencies and versions:
# libs.versions.toml
[versions]
kotlin = "2.1.20"
compose = "1.7.3"
coroutines = "1.10.2"
kotlin-wrappers = "2025.5.3"
[libraries]
compose-html-core = { module = "org.jetbrains.compose.html:html-core", version.ref = "compose" }
compose-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "compose" }
kotlin-wrappers-bom = { module = "org.jetbrains.kotlin-wrappers:kotlin-wrappers-bom", version.ref = "kotlin-wrappers" }
kotlin-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
[plugins]
kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
compose = { id = "org.jetbrains.compose", version.ref = "compose" }
// build.gradle.kts
import org.jetbrains.kotlin.gradle.dsl.JsModuleKind
import org.jetbrains.kotlin.gradle.dsl.KotlinJsCompile
plugins {
alias(libs.plugins.kotlin.multiplatform)
alias(libs.plugins.compose.compiler)
alias(libs.plugins.compose)
}
kotlin {
js(IR) {
browser()
binaries.executable()
}
sourceSets {
val jsMain by getting {
dependencies {
implementation(project.dependencies.platform(libs.kotlin.wrappers.bom))
implementation(compose.html.core)
implementation(compose.runtime)
implementation("org.jetbrains.kotlin-wrappers:kotlin-browser")
implementation(libs.kotlin.coroutines.core)
}
}
}
}
tasks.withType<KotlinJsCompile>().configureEach {
compilerOptions {
moduleKind.set(JsModuleKind.MODULE_ES)
useEsClasses.set(true)
}
}
Please take note of the following:
- we need to actually generate ES6 classes
- we need to use the
kotlin-wrappers-bom
in order to get the excellent kotlin-wrappers browser library (https://github.com/JetBrains/kotlin-wrappers)
Creating a basic Web Component
Let's create a simple timer component. It should feature:
- A start button
- A stop button
- A reset button
- A time display
- Properties to set the time
- Events to notify when the timer has started, stopped or reset, and when the time has been reached
Laying down the basic structure
Let's look at how to built a Web Component as described by MDN (https://developer.mozilla.org/en-US/docs/Web/API/Web_components/Using_custom_elements#implementing_a_custom_element).
// example in Javascript
class MyWebComponent extends HTMLElement {
constructor() {
super();
}
// Element functionality written in here
}
The equivalent in Kotlin is:
// example in Kotlin
import org.w3c.dom.HTMLElement
class TimerWebComponent : HTMLElement() {
}
Here we immediately hit a snag. The HTMLElement from org.wrc.dom.HTMLElement
is abstract, but it requires us to implement all methods.
That's why we will use the web.html.HTMLElement
variant from Kotlin wrappers.
import web.html.HTMLElement
class TimerWebComponent : HTMLElement() {
init {
println("TimerWebComponent initialized!")
}
}
Let's create a simple entry point for the application:
fun main() {
val timer = TimerWebComponent()
}
and embed our JS in a HTML page in `resources/index.html:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Compose Web Components</title>
<script src="compose-web-components.js"></script>
</head>
<body>
</body>
</html>
and when running we are bombarded with error messages saying that the TimerWebComponent can not be constructed. Looking at the MDN documentation we actually see we need to register our Web Component in order to use it as a tag.
// javascript
customElements.define("my-custom-element", MyCustomElement);
We can achieve this in Kotlin by using:
import web.components.customElements
import web.html.HtmlTagName
fun main() {
customElements.define(HtmlTagName("timer-web-component"), TimerWebComponent::class.js)
}
and updating our index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Compose Web Components</title>
<script src="compose-web-components.js"></script>
</head>
<body>
</body>
<timer-web-component></timer-web-component>
</html>
Everything is working! Wait.... the init
block is not running. That is a bug in KotlinJS which is already being tracked here: https://youtrack.jetbrains.com/issue/KT-44444
We need to add the @JsExport annotation to the class for it to work. Besides that, it is nice to add a @JsName annotation as well so we do not get name mangling.
@OptIn(ExperimentalJsExport::class)
@JsExport
@JsName("TimerWebComponent")
class TimerWebComponent : HTMLElement() {
init {
println("TimerWebComponent init")
}
}
Lo and behold, we get a notice in the console!
Adding callbacks and the shadow DOM
According to the MDN documentation there is a set of callbacks we can implement. We can use the wrappers again.
@OptIn(ExperimentalJsExport::class)
@JsExport
@JsName("TimerWebComponent")
class TimerWebComponent : HTMLElement(), CustomElement.WithCallbacks {
override fun connectedCallback() {
println("TimerWebComponent connected!")
}
override fun disconnectedCallback() {
println("TimerWebComponent disconnected!")
}
override fun adoptedCallback() {
println("TimerWebComponent adopted!")
}
override fun attributeChangedCallback(name: String, oldValue: JsAny?, newValue: JsAny?) {
println("TimerWebComponent attributeChangedCallback $name $oldValue $newValue")
}
}
They seem to be working!
Now time to add the shadow DOM and some content:
class TimerWebComponent : HTMLElement(), CustomElement.WithCallbacks {
val shadow: ShadowRoot = this.attachShadow(ShadowRootInit(mode = ShadowRootMode.closed))
override fun connectedCallback() {
document.createElement("h1").apply {
innerText = "Hello from WebComponent"
shadow.appendChild(this)
}
}
// ...
}
Great succes, we have a simple web component working.
Basic Compose integration
Now it should be pretty easy to integrate Compose HTML into our Web Component:
override fun connectedCallback() {
val root = document.createElement("main").apply {
shadow.appendChild(this)
}
renderComposable(root as org.w3c.dom.HTMLElement) {
val time = remember { mutableStateOf(0) }
LaunchedEffect(Unit) {
while (true) {
time.value += 1
delay(1000)
}
}
H1 { Text("Time: ${time.value}") }
}
}
Getting reactive
One of the features of Web Components it the fact they have callbacks for when the attributes of the web component change.
By using the excellent StateFlow<T>
of Coroutines we can easily react to changes inside our composables.
The attributeChangedCallback
is as follows:
override fun attributeChangedCallback(name: String, oldValue: JsAny?, newValue: JsAny?) {
// implement handling of changes here
}
According to the MDN documentation we need to specify a static value on the WebComponent to indicate which attributes need to be observed:
// Example from MDN in javascript
class MyCustomElement extends HTMLElement {
static observedAttributes = ["size"];
constructor() {
super();
}
attributeChangedCallback(name, oldValue, newValue) {
console.log(
`Attribute ${name} has changed from ${oldValue} to ${newValue}.`,
);
}
}
customElements.define("my-custom-element", MyCustomElement);
We need to make sure this works in Kotlin as well.
Let's first think of what we want inside our web commponent. We want to be able to get a StateFlow belonging to the attribute.
This StateFlow should ideally be typed, but properties are JsAny?
so we need to cast them. Also, a name should be attached to them.
I like to make things explicit, so I am just going to create a data class
for this situation:
data class ObservedAttribute<T>(
val name: String,
val default: T,
val cast: (JsAny?) -> T
)
No it would be handy if we have some sort of 'registry' where we can register our observed attributes and have it handle the attributeChangedCallback
class ObservedAttributes(
attributes: List<ObservedAttribute<*>>
) : CustomElement.WithAttributeChangedCallback {
constructor(vararg attributes: ObservedAttribute<*>) : this(attributes.toList())
private val names = attributes.associateBy { it.name }
private val flows = attributes.associate { it to MutableStateFlow(it.default) }
override fun attributeChangedCallback(name: String, oldValue: JsAny?, newValue: JsAny?) {
val name = requireNotNull(names[name]) { "Unknown attribute: $name" }
val flow = requireNotNull(flows[name]) { "Unknown attribute: $name" }
flow.update { name.cast(newValue) }
}
@Suppress("UNCHECKED_CAST")
fun <T> getOrNull(attr: ObservedAttribute<T>): StateFlow<T>? = flows[attr] as? StateFlow<T>
operator fun <T> get(attr: ObservedAttribute<T>): StateFlow<T> = requireNotNull(getOrNull(attr)) { "Unknown attribute: $attr" }
}
And now we can integrate it into our web component. We use the @JsStatic annotation to make sure the static property is available.:
class TimerWebComponent : HTMLElement(), CustomElement.WithCallbacks {
companion object {
val attributes = arrayOf(Attributes.Time)
@OptIn(ExperimentalJsStatic::class)
@JsStatic()
@JsName("observedAttributes")
val staticObservedAttributes = attributes.map { it.name }.toTypedArray()
object Attributes {
val Time = ObservedAttribute("time", 0, { it?.toString()?.toIntOrNull() ?: 0 })
}
}
val observedAttributes = ObservedAttributes(*TimerWebComponent.attributes)
// ....
renderComposable(root as org.w3c.dom.HTMLElement) {
var currentTime by remember { mutableStateOf(0) }
val initialTime by remember { observedAttributes[Attributes.Time] }.collectAsState()
LaunchedEffect(initialTime) {
currentTime = initialTime
while (true) {
delay(1000)
currentTime -= 1
currentTime = currentTime.coerceAtLeast(0)
}
}
H1 { Text("Time: ${currentTime}") }
}
// ...
override fun attributeChangedCallback(name: String, oldValue: JsAny?, newValue: JsAny?) {
observedAttributes.attributeChangedCallback(name, oldValue, newValue)
}
}
Now we only need to update the index.html
<timer-component time="100"></timer-component>
And we have a functioning timer counting down! Let's test the reactivity by changing the time attribute:
<timer-component time="100"></timer-component>
<script>
const timer = document.querySelector('timer-component');
setTimeout(() => {
timer.setAttribute("time", 200)
}, 1000)
</script>
it works like a charm!
Sending out events
Sending out events is actually pretty easy and is part of the normal DOM API, using the dispatchEvent
method.
We need to construct a CustomEvent
with the following properties:
bubbles
: we want this event to bubble up through the DOM, sotrue
composed
: whether or not we want it to pass through the Shadow DOM boundary. Well... yes, sotrue
detail
: The payload we want to have. This depends on the event of course.
First, let us define the following events:
timerStarted
event, with a payload of theinitialTime
timerEnded
event, with a payload of theinitialTime
private fun dispatchTimerStarted(initialTime: Int) {
val event = CustomEvent(EventType("timerStarted"), CustomEventInit(detail = initialTime))
this.dispatchEvent(event)
}
private fun dispatchTimerEnded(initialTime: Int) {
val event = CustomEvent(EventType("timerEnded"), CustomEventInit(detail = initialTime))
this.dispatchEvent(event)
}
And in the LaunchedEffect
:
LaunchedEffect(initialTime) {
currentTime = initialTime
while (true) {
if (currentTime == initialTime) dispatchTimerStarted(initialTime)
delay(1000)
currentTime -= 1
currentTime = currentTime.coerceAtLeast(0)
if (currentTime == 0) {
dispatchTimerEnded(initialTime)
break
}
}
}
and now we can subscribe using:
const timer = document.querySelector('timer-component');
timer.addEventListener('timerStarted', (evt) => console.log(`Timer started, initial: ${ evt.detail } seconds`));
timer.addEventListener('timerEnded', (evt) => console.log(`Timer ended, original: ${ evt.detail } seconds`));
Improving the API by abstraction
Looking closely at the timer component we have a lot of boilerplate. This can easily be captured in an abstract WebComponent
class:
abstract class WebComponent(
factory: Factory<out WebComponent>,
mode: ShadowRootMode = ShadowRootMode.closed,
protected val observedAttributes: ObservedAttributes = ObservedAttributes(factory.attributes),
rootElementTagName: String = "main",
) : HTMLElement(), CustomElement.WithCallbacks, CustomElement.WithAttributeChangedCallback by observedAttributes {
abstract class Factory<T : WebComponent>(
val tagName: String,
val clazz: CustomElementConstructor<T>,
val attributes: List<ObservedAttribute<*>> = emptyList(),
) {
fun register() {
clazz.asDynamic().observedAttributes = attributes.map { it.name }.toTypedArray()
customElements.define(HtmlTagName(tagName), clazz)
}
}
data class ObservedAttribute<T>(
val name: String,
val default: T,
val cast: (JsAny?) -> T
)
class ObservedAttributes(
attributes: List<ObservedAttribute<*>>
) : CustomElement.WithAttributeChangedCallback {
private val names = attributes.associateBy { it.name }
private val flows = attributes.associate { it to MutableStateFlow(it.default) }
override fun attributeChangedCallback(name: String, oldValue: JsAny?, newValue: JsAny?) {
val name = requireNotNull(names[name]) { "Unknown attribute: $name" }
val flow = requireNotNull(flows[name]) { "Unknown attribute: $name" }
flow.update { name.cast(newValue) }
}
@Suppress("UNCHECKED_CAST")
fun <T> getOrNull(attr: ObservedAttribute<T>): StateFlow<T>? = flows[attr] as? StateFlow<T>
operator fun <T> get(attr: ObservedAttribute<T>): StateFlow<T> = requireNotNull(getOrNull(attr)) { "Unknown attribute: $attr" }
}
data class EventDescriptor<T>(val name: String, val bubbles: Boolean = true, val cancellable: Boolean? = null, val composed: Boolean = true)
val shadow: ShadowRoot = this.attachShadow(ShadowRootInit(mode = mode))
val root = document.createElement(rootElementTagName).apply {
shadow.appendChild(this)
}
fun <T> dispatchEvent(descriptor: EventDescriptor<T>, payload: T) = this.dispatchEvent(
CustomEvent(
type = EventType(descriptor.name),
init = CustomEventInit(
bubbles = descriptor.bubbles,
cancelable = descriptor.cancellable,
composed = descriptor.composed,
detail = payload,
)
)
)
override fun disconnectedCallback() {}
override fun adoptedCallback() {}
}
We are using a factory pattern here in order encapsulate the statics and improve the wiring of the observed attributes. Besides that we've introduced some helpers for events.
Please note that we have to do some funky stuff with an asDynamic
on the class in order to register the observedAttributes.
This is because the @JsStatic can not be used on the abstract class.
Now we can gradually build upon that and add a ComposedWebComponent:
abstract class ComposedWebComponent(
factory: Factory<out WebComponent>,
mode: ShadowRootMode = ShadowRootMode.closed,
observedAttributes: ObservedAttributes = ObservedAttributes(factory.attributes),
rootElementTagName: String = "main",
) : WebComponent(factory, mode, observedAttributes, rootElementTagName) {
override fun connectedCallback() {
renderComposable(root as org.w3c.dom.HTMLElement) {
render()
}
}
@Composable abstract fun render()
}
And the Timer component becomes cleaner:
@OptIn(ExperimentalJsExport::class)
@JsExport
@JsName("TimerWebComponent")
class TimerWebComponent : ComposedWebComponent(factory = Factory) {
object Factory : WebComponent.Factory<TimerWebComponent>(
tagName = "timer-component",
clazz = TimerWebComponent::class.js,
attributes = listOf(Attributes.Time),
)
object Attributes {
val Time = ObservedAttribute("time", 0, { it?.toString()?.toIntOrNull() ?: 0 })
}
object Events {
val TimerStarted = EventDescriptor<Int>("timerStarted")
val TimerEnded = EventDescriptor<Int>("timerEnded")
}
@Composable override fun render() {
var currentTime by remember { mutableStateOf(0) }
val initialTime by remember { observedAttributes[Attributes.Time] }.collectAsState()
LaunchedEffect(initialTime) {
currentTime = initialTime
while (true) {
if (currentTime == initialTime) dispatchEvent(TimerStarted, initialTime)
delay(1000)
currentTime -= 1
currentTime = currentTime.coerceAtLeast(0)
if (currentTime == 0) {
dispatchEvent(TimerEnded, initialTime)
break
}
}
}
H1 { Text("Time: ${currentTime}") }
}
}
The main function slightly changes to:
fun main() {
TimerWebComponent.Factory.register()
}
Finalizing the functionality
We are still missing the interactivity, so let's add it:
@Composable override fun render() {
var isStarted by remember { mutableStateOf(false) }
var currentTime by remember { mutableStateOf(0) }
val initialTime by remember { observedAttributes[Attributes.Time] }.collectAsState()
LaunchedEffect(isStarted) {
if (!isStarted) return@LaunchedEffect
while (true) {
if (currentTime == initialTime) dispatchEvent(TimerStarted, initialTime)
delay(1000)
currentTime -= 1
currentTime = currentTime.coerceAtLeast(0)
if (currentTime == 0) {
dispatchEvent(TimerEnded, initialTime)
break
}
}
}
LaunchedEffect(initialTime) { currentTime = initialTime }
H1 { Text("Time: ${currentTime}") }
Button(
attrs = {
onClick { isStarted = true }
if (isStarted) disabled()
}
) {
Text("Start")
}
Button (
attrs = {
onClick { currentTime = initialTime }
}
){
Text("Reset")
}
Button(
attrs = {
onClick { isStarted = false }
if (!isStarted) disabled()
}
) {
Text("Stop")
}
}
Adding styles and finishing up
Adding styles can just be done by using the Compose HTML Stylesheet builders, although I prefer to keep the style tag at the top of the shadow DOM instead of in the root composable node. I modified the Factory a bit for that to include it. This can be seen in the final code on Github.
Now, we can easily compile it and include it in this very page. Lo and behold.
And even multiple are possible now with different timings:
The bundle size is pretty big (~400kB), but this can be properly cached. Besides, it contains:
- Compose Runtime
- Compose HTML
- Coroutines
So, there is a small cost to pay but you are able to use the ergonomics of Kotlin and the excellent Compose Runtime.
Conclusion
This was a pretty fun thing to do! And in my opinion embedding Compose HTML inside a Web Component feels very natural! I know it should be equally simple to include Compose Web in Web Components, but I myself are more interested in the production-ready HTML variant.
The final code can be found here:
https://github.com/helico-tech/kotlin-experiments/tree/403dbc6e7db6afe5eacb45031834e498bd9d85cd