Addons

Kotlin-friendly wrappers for JavaScript libraries, loaded on demand.

Every addon is dynamically imported via js("import(...)"). The JS library ships in its own webpack chunk and only downloads when a component needs it.

The AddonRegistry pattern

An addon is a typed facade over a JavaScript library. Register it in your app, then subscribe to it from any component:

// 1. Register in your KraftApp builder
val kraft = kraftApp {
    semanticUI()

    addons {
        marked()
        signaturePad()
        pixiJs(lazy = true)   // only load when first component subscribes
    }
}

// 2. Subscribe from any component
class MarkdownView(ctx: NoProps) : PureComponent(ctx) {
    private val marked: MarkedAddon? by subscribingTo(addons.marked)

    override fun VDom.render() {
        val addon = marked
        if (addon == null) {
            ui.placeholder.segment { +"Loading..." }
            return
        }

        ui.segment {
            unsafe { +addon.markdown2html("# Hello, Kraft!") }
        }
    }
}

The addon property is null until the JS library finishes loading. The component re-renders automatically when the addon becomes ready — subscribing is just like any other stream.

Eager vs lazy loading

  • marked()eager (default). Loads immediately on app startup.
  • pixiJs(lazy = true)lazy. Loads only when the first component subscribes. Good for heavy libraries (pixi.js is ~300 KB) that aren't needed on every page.

Available addons

Addon Wraps What it does
marked marked + DOMPurify Markdown → sanitized HTML
prismjs Prism Syntax highlighting with plugins
chartjs Chart.js Bar, line, pie, radar charts
pdfjs pdf.js Render PDFs in-browser (CDN-loaded)
pixijs PixiJS v8 2D WebGL/WebGPU rendering
threejs Three.js 3D WebGL rendering
signaturepad signature_pad Capture handwritten signatures
jwtdecode jwt-decode Decode JWT tokens client-side
avatars minidenticons SVG identicons from strings
browserdetect Bowser Browser and OS detection
nxcompile @nx-js/compiler-util Sandboxed JS code execution
sourcemappedstacktrace sourcemapped-stacktrace Map minified stack traces to source

Adding addons

In your build.gradle.kts:

jsMain {
    dependencies {
        implementation("io.peekandpoke.kraft:addons-marked:0.107.2")
        implementation("io.peekandpoke.kraft:addons-prismjs:0.107.2")
        implementation("io.peekandpoke.kraft:addons-chartjs:0.107.2")
        // ... add the ones you need
    }
}

marked — Markdown rendering

class MarkdownView(ctx: NoProps) : PureComponent(ctx) {
    private val marked: MarkedAddon? by subscribingTo(addons.marked)

    override fun VDom.render() {
        val addon = marked ?: return ui.placeholder.segment { +"Loading..." }

        ui.segment {
            unsafe {
                +addon.markdown2html("""
                    # Hello
                    - item 1
                    - item 2
                    `inline code`
                """.trimIndent())
            }
        }
    }
}

markdown2html() sanitizes the output through DOMPurify automatically, so user-generated markdown can't inject <script> tags.

prismjs — Syntax highlighting

PrismJS is special — the Prism component handles loading internally, so you use it directly without subscribingTo:

override fun VDom.render() {
    PrismKotlin("""
        fun greet(name: String) = "Hello, $name!"
    """.trimIndent()) {
        lineNumbers()
        copyToClipboard()
    }
}

Language components: PrismKotlin, PrismJava, PrismJavascript, PrismJson, PrismHtml, PrismCss, PrismXml, PrismRust, PrismTypescript, and more.

Plugins: lineNumbers(), copyToClipboard(), inlineColor(), showLanguage().

chartjs — Data visualization

class SalesChart(ctx: NoProps) : PureComponent(ctx) {
    private val chart: ChartJsAddon? by subscribingTo(addons.chartJs)

    // ChartJsComponent subscribes to the addon internally — just use it directly
    override fun VDom.render() {
        div {
            css { height = 50.vh }

            ChartJs(chartJsData {
                jsObject {
                    type = "bar"
                    data = jsObject {
                        labels = arrayOf("Jan", "Feb", "Mar", "Apr", "May")
                        datasets = arrayOf(jsObject {
                            label = "Sales"
                            data = arrayOf(12, 19, 3, 5, 8)
                            backgroundColor = value("rgba(99, 132, 255, 0.5)")
                        })
                    }
                }
            })
        }
    }
}

pixijs — 2D WebGL/WebGPU

PixiJS is a hardware-accelerated 2D renderer for games and interactive graphics. Register it as lazy — it's a big library and usually only needed on specific pages:

addons {
    pixiJs(lazy = true)
}
class Scene(ctx: NoProps) : PureComponent(ctx) {
    private val pixi: PixiJsAddon? by subscribingTo(addons.pixiJs)
    private var app: Application? = null
    private var starting: Boolean = false

    init {
        lifecycle {
            onMount { tryStart() }
            onUpdate { tryStart() }
            onUnmount {
                app?.destroy(rendererDestroy = true)
                app = null
            }
        }
    }

    private fun tryStart() {
        val addon = pixi ?: return
        if (app != null || starting) return
        val container = dom as? HTMLDivElement ?: return

        starting = true
        launch {
            val a = addon.createApplication()
            a.init(jsObject {
                width = 800
                height = 600
                backgroundColor = 0x1a1a2e
            }).await()
            container.append(a.canvas)
            app = a

            // Draw a red rectangle
            val g = addon.createGraphics()
            g.rect(100.0, 100.0, 200.0, 150.0).fill(0xff3355)
            a.stage.addChild(g)
        }
    }

    override fun VDom.render() {
        div { css { width = 800.px; height = 600.px } }
    }
}
The starting flag prevents a race: onMount and onUpdate both call tryStart(), but the launch is async. Without a sync flag, you'd create two Applications before the first finishes.

pdfjs — PDF viewer

class DocumentView(ctx: NoProps) : PureComponent(ctx) {
    private val pdf: PdfJsAddon? by subscribingTo(addons.pdfJs)

    override fun VDom.render() {
        ScrollingPdfViewer(
            src = PdfSource.Url("https://example.com/document.pdf"),
            options = ScrollingPdfViewer.Options(
                maxHeightLandscapeVh = 80,
                maxHeightPortraitVh = 80,
                scaleRange = 0.1..3.0,
            ),
            onChange = { state ->
                console.log("Page ${state.currentPage} of ${state.totalPages}")
            },
        )
    }
}

pdf.js is loaded from a CDN via ScriptLoader, not bundled with your app.

signaturepad — Capture signatures

class SignatureCapture(ctx: NoProps) : PureComponent(ctx) {
    private var signaturePng: FileBase64? by value(null)
    private val padRef = ComponentRef.Tracker<SignaturePad>()

    override fun VDom.render() {
        div {
            css { position = Position.relative; height = 200.px }

            SignaturePad {
                it.export {
                    signaturePng = toPng()
                }
            }.track(padRef)
        }

        padRef { pad ->
            ui.button {
                onClick { pad.clear() }
                icon.eraser(); +"Clear"
            }
        }

        signaturePng?.let { png ->
            img { src = png.asDataUrl() }
        }
    }
}

Export as PNG, JPG, or SVG via toPng(), toJpg(quality), toSvg().

jwtdecode — Decode JWTs

class TokenInspector(ctx: NoProps) : PureComponent(ctx) {
    private val jwt: JwtDecodeAddon? by subscribingTo(addons.jwtDecode)
    private var token by value("eyJhbGc...")

    override fun VDom.render() {
        val addon = jwt ?: return
        val claims = addon.decodeJwtAsMap(token)

        pre { +JSON.stringify(addon.decodeJwt(token)) }
    }
}

avatars — SVG identicons

class UserAvatar(ctx: Ctx<Props>) : Component<UserAvatar.Props>(ctx) {
    data class Props(val email: String)

    private val avatars: AvatarsAddon? by subscribingTo(addons.avatars)

    override fun VDom.render() {
        val addon = avatars ?: return
        img { src = addon.getDataUrl(props.email) }
    }
}

browserdetect — Browser & OS info

class Diagnostics(ctx: NoProps) : PureComponent(ctx) {
    private val bd: BrowserDetectAddon? by subscribingTo(addons.browserDetect)

    override fun VDom.render() {
        val addon = bd ?: return
        val detect = addon.forCurrentBrowser()

        ui.list {
            li { +"Browser: ${detect.getBrowser().name}" }
            li { +"OS: ${detect.getOs().name}" }
            li { +"Platform: ${detect.getPlatform().type}" }
            li { +"Supports PDF: ${detect.supportsPdf()}" }
        }
    }
}

Writing your own addon

Not seeing what you need? The addon pattern is ~50 lines of Kotlin:

package my.app.addons.fuse

import io.peekandpoke.kraft.KraftDsl
import io.peekandpoke.kraft.addons.registry.*
import kotlinx.coroutines.await
import kotlin.js.Promise

// Facade — the typed API your components will use
class FuseAddon internal constructor(
    private val fuseModule: dynamic,
) {
    fun <T> createSearch(items: Array<T>, options: dynamic): dynamic {
        val ctor = fuseModule
        return js("new ctor(items, options)")
    }
}

// Registry key + DSL + accessor
val fuseAddonKey = AddonKey<FuseAddon>("fuse")

@KraftDsl
fun AddonRegistryBuilder.fuse(lazy: Boolean = false): Addon<FuseAddon> = register(
    key = fuseAddonKey,
    name = "fuse",
    lazy = lazy,
) {
    @Suppress("UnsafeCastFromDynamic")
    val module: dynamic = (js("import('fuse.js')") as Promise<dynamic>).await()

    FuseAddon(fuseModule = module.default ?: module)
}

val AddonRegistry.fuse: Addon<FuseAddon>
    get() = this[fuseAddonKey]
The val ctor = fuseModule pattern is important: js("new this.x(...)") doesn't work because this inside js() refers to the JavaScript this, not the Kotlin instance. Capture the reference into a local val first.

See it in action

The kraft/examples/addons project demos every addon, including the PixiJS Breakout game: