Addons
Kotlin-friendly wrappers for JavaScript libraries, loaded on demand.
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 } }
}
} 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] 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: