Joy DOM

Kotlin DSL

Build Spec documents in type-safe Kotlin instead of decoding JSON, with the optional dom-dsl module.

The optional dom-dsl module builds Spec objects programmatically with a type-safe Kotlin builder DSL — handy for tests, inline content, or anywhere a JSON string is awkward. It produces the same Spec the JSON decoder does, so the output renders, resolves, and serializes identically.

dependencies {
    implementation("com.j0y.joy:dom-dsl:1.0.0")
}

The spec { } entry point

Every document starts with the top-level spec { } builder. Inside it you declare style entries, breakpoints, and exactly one root layout:

import com.j0y.joy.dom.dsl.*

val document = spec {
    style("div") {
        color = "#111111".color
        padding(4.px)
    }
    style(".card") {
        color = "#222222".color
        margin(8.px)
    }
    layout {
        div {
            props {
                id = "hero"
                className("card")
            }
            +"Hello, Joy-DOM!"
        }
    }
}

version defaults to 1 (the only valid value) and rarely needs setting. build() throws if no layout was declared — the spec requires a root node.

Declaring nodes

Inside layout { } (and inside any node body), use the built-in HTML tag helpers — div, span, p, img, h1h6. Each opens a nested NodeBuilder:

layout {
    div {
        h1 { +"Title" }
        p {
            +"A paragraph with "
            span { +"an inline run" }
        }
        img { props { id = "photo" } }
    }
}

The root layout { } must contain exactly one top-level node — a second one throws.

For custom (kebab-case) component types, use the generic node(type) { }:

layout {
    node("rating-stars") {
        props { /* ... */ }
    }
}

node(type) also exists as a free function that returns a standalone Node (val n = node("div") { ... }), and layout(node) / layout(type) { } overloads let you set the root from a prebuilt node or by raw type string.

Props: id, classes, style, events

A node's props { } block configures its id, class list, inline style, and event bindings:

div {
    props {
        id = "hero"
        className("card", "primary")   // accumulates across calls
        style {
            display = Display.Flex
            alignItems = AlignItems.Center
        }
        onClick("charge") {
            param("source", "stripe")
            param("amount", 1000)
        }
    }
}
  • className(vararg) appends classes; calling it again adds more.
  • style { } sets the node's inline style (same builder as the top-level style(selector) { }).
  • onClick / onFocus / onBlur / onChange bind a named action, with an optional params { } block. param(key, value) accepts String, Number, Boolean, or a raw JsonElement. You can also assign an Event directly: onBlur = Event(name = "blur").

Unknown sibling keys are preserved verbatim via extras(key, value) on both props { } and the node body.

Children: elements, text, numbers

Add element children with the tag helpers or node(type) { }. Add text and other leaf children with the unary + operator and explicit helpers:

p {
    +"Plain text"          // String → text child
    text("also text")      // explicit text
    number(42)             // numeric child (stored as Double)
    number(3.14)
    nullChild()            // JSON null child
}

A node with no child added serializes children as absent (null), distinct from an empty list — preserving the spec's absent-vs-empty distinction.

Styles, lengths, and colors

The style { } builder exposes every supported CSS property as a typed var (display, flexDirection, justifyContent, width, backgroundColor, fontSize, color, objectFit, …). Enums use their Kotlin types: Display.Flex, AlignItems.Center, JustifyContent.SpaceBetween.

Lengths come from extension properties on Int/Double:

style {
    width = 100.percent
    height = 50.5.percent
    fontSize = 24.px
    gap = 8.px
}

Spacing and radius have shorthand helpers (1-, 2-, and 4-value forms):

style {
    padding(8.px)                              // all sides
    padding(vertical = 10.px, horizontal = 20.px)
    margin(top = 0.px, right = 4.px, bottom = 0.px, left = 4.px)
    borderRadius(topLeft = 1.px, bottomRight = 3.px)
}

Colors parse from CSS strings via the .color extension, or build directly with constructors:

color = "#111111".color          // hex / rgb / hsl / named string
backgroundColor = rgb(255, 0, 0)
borderColor = rgba(0, 0, 0, 0.5)
// also: hex(...), hsl(h, s%, l%), lab(...), oklch(...)

Breakpoints and media queries

breakpoint { } adds a responsive override. Declare match conditions { } with the media-query builder, then per-id node prop overrides and per-selector style overrides:

spec {
    style(".card") { padding(8.px) }
    breakpoint {
        conditions {
            width(min = 768.px)      // min-width: 768px
        }
        nodes("hero") {
            style { color = "#ffffff".color }
        }
        style(".card") {
            padding(16.px)
        }
    }
    layout {
        div {
            props { id = "hero"; className("card") }
            +"Responsive"
        }
    }
}

The media-query builder covers width(min =, max =) (and an explicit-operator overload), landscape() / portrait(), print(), plus and { }, or { }, and not { } to combine and negate conditions.

The DSL mirrors the spec, not a superset

Every DSL construct maps 1:1 to a Spec field. There are no DSL-only features — anything you can build here you can also write as JSON, and round-trips through JoyDomJson are lossless.

What's next

On this page