A lightweight Kotlin Multiplatform UI framework that lets you write declarative UI in .ktsx files — a JSX-inspired syntax that compiles to idiomatic Kotlin DSL code at build time.
<Column>
<Text value="Hello, world!" />
<Button if={isLoggedIn} onClick={handleLogout}>
<Text value="Log out" />
</Button>
<Button else onClick={handleLogin}>
<Text value="Log in" />
</Button>
</Column>
↓ generated at build time ↓
fun MyScreen() = buildUi {
Column {
Text(value = "Hello, world!")
if (isLoggedIn) {
Button(onClick = handleLogout) {
Text(value = "Log out")
}
} else {
Button(onClick = handleLogin) {
Text(value = "Log in")
}
}
}
}| Artifact | Description |
|---|---|
io.github.ktsx:ktsx-core:0.1.0 |
Runtime — UIElement, UiBuilder, ComponentRegistry, Modifier, AnimationSpec, built-in components |
io.github.ktsx:ktsx-processor:0.1.0 |
Build-time — .ktsx parser and Kotlin code generator |
io.github.ktsx:ktsx-gradle-plugin:0.1.0 |
Gradle plugin — wires .ktsx → .kt generation into your build |
In your module's build.gradle.kts:
plugins {
id("com.ktsx.ui") version "0.1.0"
}The plugin automatically:
- Discovers all
.ktsxfiles in your source sets - Generates a
.ktfile for each one at build time - Adds the generated sources to your compile classpath
dependencies {
implementation("io.github.ktsx:ktsx-core:0.1.0")
}ktsx {
// Override the default package derived from the file path
defaultPackage.set("com.example.ui")
// Strip the comment header from generated files (default: true)
generateCommentHeader.set(false)
}Artifacts are published to Maven Central. Until then, to use a local build:
// settings.gradle.kts
dependencyResolutionManagement {
repositories {
mavenLocal()
mavenCentral()
}
}Then publish locally:
./gradlew :core:publishToMavenLocal
./gradlew :processor:publishToMavenLocal
./gradlew :gradle-plugin:publishToMavenLocalPlace .ktsx files anywhere in your source set (alongside .kt files). Each file generates one top-level buildUi { } function named after the file.
src/main/ktsx/HomeScreen.ktsx → generates fun HomeScreen() = buildUi { … } in the same package.
| Component | Description |
|---|---|
Column |
Vertical container |
Row |
Horizontal container |
Text |
Text node |
Button |
Tappable button |
Show |
Always-mounted visibility toggle |
AnimatedVisibility |
Animated mount/unmount |
AnimatedContent |
Cross-animate between states |
Crossfade |
Opacity crossfade shorthand |
<Text value="string literal" />
<Button onClick={kotlinExpression} enabled={isReady} />
String literals use "double quotes". Any Kotlin expression goes inside {curly braces} and is emitted verbatim into the generated code.
Directives are special attributes that control code generation. They are stripped from the node before it is rendered.
Consecutive siblings form an if/else if/else block:
<Button if={isLoggedIn} onClick={logout}>
<Text value="Log out" />
</Button>
<Text else-if={isPending} value="Loading…" />
<Button else onClick={login}>
<Text value="Log in" />
</Button>
if (isLoggedIn) {
Button(onClick = logout) { Text(value = "Log out") }
} else if (isPending) {
Text(value = "Loading…")
} else {
Button(onClick = login) { Text(value = "Log in") }
}Keeps the node in the tree; the platform renderer hides it without unmounting. Unlike if, there is no enter/exit animation — use AnimatedVisibility for that.
<Row show={isConnected}>
<Text value="Online" />
</Row>
Show(visible = isConnected) {
Row { Text(value = "Online") }
}Repeats the node for every element in a collection. The value is any valid Kotlin for-expression:
<Card for={item in posts}>
<Text value={item.title} />
</Card>
<Row for={(index, item) in tabs.withIndex()}>
<Text value={item.label} />
</Row>
for (item in posts) {
Card { Text(value = item.title) }
}
for ((index, item) in tabs.withIndex()) {
Row { Text(value = item.label) }
}Directives can be combined on the same tag. Evaluation order: for wraps if/else, which wraps show:
<Text for={item in notifications} if={item.unread} value={item.title} />
for (item in notifications) {
if (item.unread) {
Text(value = item.title)
}
}All built-in components accept a modifier parameter for layout and visual styling. Modifiers are immutable and chain with .:
<Column modifier={Modifier.padding(16).background("#FAFAFA").fillMaxWidth()}>
<Text value="Styled" modifier={Modifier.alpha(0.7f)} />
</Column>
Available entries: padding, margin, background, size, width, height, fillMaxWidth, fillMaxHeight, fillMaxSize, border, alpha, shadow, clipCircle, clipRounded, rotate, scale, custom.
<AnimatedVisibility
visible={isVisible}
enterSpec={AnimationSpec.Fade(durationMs=300)}
exitSpec={AnimationSpec.Slide(direction=AnimationSpec.SlideDirection.BOTTOM_TO_TOP, durationMs=250)}
>
<Card><Text value="Appeared!" /></Card>
</AnimatedVisibility>
Cross-animates between different states:
<AnimatedContent targetState={currentPage} transitionSpec={AnimationSpec.Tween(durationMs=250)}>
<HomePage />
</AnimatedContent>
Shorthand for opacity crossfade:
<Crossfade targetState={isLoading} durationMs={300}>
<Spinner if={isLoading} />
<Content else />
</Crossfade>
| Spec | Description |
|---|---|
Fade(durationMs) |
Opacity only |
Tween(durationMs, easing, delayMs) |
Duration-based with easing |
Spring(stiffness, dampingRatio) |
Physics-based (no fixed duration) |
Slide(direction, durationMs) |
Slide in/out |
Scale(fromScale, toScale, durationMs) |
Scale in/out |
Composite(vararg specs) |
Multiple specs simultaneously |
Register third-party or app-specific components with ComponentRegistry at startup:
ComponentRegistry.register("Avatar") { attrs, content ->
// attrs["src"], attrs["size"], etc.
add(UINode("Avatar", attrs, content?.let { buildUi(it) } ?: emptyList()))
}Then use them in .ktsx files like any built-in component:
<Avatar src={user.avatarUrl} size={40} />
git clone https://github.com/ktsx/ktsx.git
cd ktsx
# Run all tests
./gradlew test
# Publish to local Maven repository
./gradlew publishToMavenLocal
# Run the example
./gradlew :example:runRequires JDK 17+, Kotlin 2.2.20, Android Gradle Plugin 8.13.0.