
Streaming-text typewriter for LLM apps: renders streaming tokens with live progressive Markdown, per-language syntax-highlighted code blocks, human-like speed curves, configurable cursor, tap-to-skip, and accessibility support.
The streaming-text typewriter built for LLM apps on Compose Multiplatform. Renders a
Flow<String> of tokens with live progressive Markdown, syntax-highlighted code blocks that
build up as tokens arrive, three speed curves (linear / ease-out / natural), a configurable
blinking cursor, tap-to-skip, graceful stop-mid-stream, selectable text, and a screen-reader-
friendly live region — on every CMP target.
Pairs naturally with
prompt-bar— drop both in and you have a ChatGPT-quality chat UI on Android, iOS, Desktop, and Web in ~20 lines. PromptBar's Send/Stop button auto-syncs withstate.isStreaming.
Existing CMP typewriters take a static String — they're animations, not stream renderers.
LlmTypewriter is built around the AI chatbot streaming-token use case that nobody else has
shipped:
| Capability | LlmTypewriter | Typist-CMP | Texty | GetStream StreamingText
|
|---|---|---|---|---|
Flow<String> source |
✅ | ❌ static string | ❌ static string | ✅ Android-only |
| Live progressive Markdown | ✅ | ❌ | ❌ | ❌ |
| Syntax-highlighted code blocks (live) | ✅ Kotlin/JS/Python | ❌ | ❌ | ❌ |
| Speed curves (linear / easeOut / natural) | ✅ | ❌ linear only | ❌ linear only | ❌ |
| Tap-to-skip | ✅ | ❌ | ❌ | ❌ |
| Graceful stop-mid-stream | ✅ | ❌ | ❌ | partial |
Custom @Composable cursor |
✅ | ❌ | ❌ no cursor | ❌ |
| Selectable text mid-stream | ✅ | ❌ | ❌ | partial |
| A11y live-region announcements | ✅ | ❌ | ❌ | partial |
| CMP target coverage | Android · iOS · Desktop · Wasm | Mostly | Mostly | Android-only |
| Active maintenance | ✅ | ❌ stale ~2y | locked in chat SDK |
// settings.gradle.kts — already on Maven Central, no extra repos.
dependencies {
implementation("io.github.nadeemiqbal:llm-typewriter:0.1.0")
}@Composable
fun ChatBubble(chatViewModel: ChatViewModel) {
val state = rememberStreamingTypewriterState()
StreamingTypewriter(
tokens = chatViewModel.responseFlow, // Flow<String>
state = state,
renderer = rememberMarkdownTypewriterRenderer(),
cursor = TypewriterCursor.Line,
speedCurve = SpeedCurve.Natural,
)
Button(onClick = { state.stop() }, enabled = state.isStreaming) { Text("Stop") }
}TypewriterText(
text = "Build LLM-powered apps for Android, iOS, Desktop, and Web.",
cursor = TypewriterCursor.Block,
speedCurve = SpeedCurve.Natural,
)CyclingTypewriterText(
phrases = listOf("Type", "Stream", "Render", "Anywhere"),
holdMs = 1200L,
)StreamingTypewriter(
tokens = tokens,
cursor = TypewriterCursor.Custom {
Box(Modifier.size(12.dp).background(MaterialTheme.colorScheme.primary, CircleShape))
},
)| Curve | Behaviour | Use for |
|---|---|---|
SpeedCurve.Linear |
Constant cadence | Logs, terminals |
SpeedCurve.EaseOut |
Slight stretch on whitespace | Marketing copy |
SpeedCurve.Natural |
Pauses on .!?,;:\n — simulates human typing |
LLM chat, intros |
| Renderer | What it does |
|---|---|
PlainTypewriterRenderer |
Paints raw text in the ambient style. Zero parsing cost. |
rememberMarkdownTypewriterRenderer() |
Live progressive Markdown — headings, bold, italic, strikethrough, inline code, links, fenced code blocks with per-language syntax highlighting (Kotlin / JS / TS / Python). |
The Markdown parser is prefix-stable: a **bold mid-stream renders as plain text until the
closing ** arrives, but every token before that opening ** stays exactly where it was.
Code fences highlight progressively as tokens stream in — no waiting for the closing ```.
| Target | Status |
|---|---|
| Android (minSdk 24) | ✅ |
| iOS (x64, arm64, simulatorArm64) | ✅ |
| Desktop (JVM 11) | ✅ |
| Web (wasmJs) | ✅ |
StreamingTypewriterState is fully usable without composition — handy for tests and for hosts
that want to drive the typewriter from a background coroutine:
val state = StreamingTypewriterState()
state.appendToken("Hello, ")
state.appendToken("world!")
state.completeSource()
state.skipToEnd() // flush buffer
state.stop() // freeze mid-stream
state.resume() // pick up where we left off
state.reset() // start fresh./gradlew :sample:desktopApp:run # Desktop
./gradlew :sample:androidApp:assembleDebug # Android
./gradlew :sample:webApp:wasmJsBrowserDevelopmentRun # WebThe sample includes a fake-LLM that streams pre-canned responses with Markdown + code blocks, a speed-curve switcher, and Stop/Resume/Skip controls.
Apache 2.0 — see LICENSE.
The streaming-text typewriter built for LLM apps on Compose Multiplatform. Renders a
Flow<String> of tokens with live progressive Markdown, syntax-highlighted code blocks that
build up as tokens arrive, three speed curves (linear / ease-out / natural), a configurable
blinking cursor, tap-to-skip, graceful stop-mid-stream, selectable text, and a screen-reader-
friendly live region — on every CMP target.
Pairs naturally with
prompt-bar— drop both in and you have a ChatGPT-quality chat UI on Android, iOS, Desktop, and Web in ~20 lines. PromptBar's Send/Stop button auto-syncs withstate.isStreaming.
Existing CMP typewriters take a static String — they're animations, not stream renderers.
LlmTypewriter is built around the AI chatbot streaming-token use case that nobody else has
shipped:
| Capability | LlmTypewriter | Typist-CMP | Texty | GetStream StreamingText
|
|---|---|---|---|---|
Flow<String> source |
✅ | ❌ static string | ❌ static string | ✅ Android-only |
| Live progressive Markdown | ✅ | ❌ | ❌ | ❌ |
| Syntax-highlighted code blocks (live) | ✅ Kotlin/JS/Python | ❌ | ❌ | ❌ |
| Speed curves (linear / easeOut / natural) | ✅ | ❌ linear only | ❌ linear only | ❌ |
| Tap-to-skip | ✅ | ❌ | ❌ | ❌ |
| Graceful stop-mid-stream | ✅ | ❌ | ❌ | partial |
Custom @Composable cursor |
✅ | ❌ | ❌ no cursor | ❌ |
| Selectable text mid-stream | ✅ | ❌ | ❌ | partial |
| A11y live-region announcements | ✅ | ❌ | ❌ | partial |
| CMP target coverage | Android · iOS · Desktop · Wasm | Mostly | Mostly | Android-only |
| Active maintenance | ✅ | ❌ stale ~2y | locked in chat SDK |
// settings.gradle.kts — already on Maven Central, no extra repos.
dependencies {
implementation("io.github.nadeemiqbal:llm-typewriter:0.1.0")
}@Composable
fun ChatBubble(chatViewModel: ChatViewModel) {
val state = rememberStreamingTypewriterState()
StreamingTypewriter(
tokens = chatViewModel.responseFlow, // Flow<String>
state = state,
renderer = rememberMarkdownTypewriterRenderer(),
cursor = TypewriterCursor.Line,
speedCurve = SpeedCurve.Natural,
)
Button(onClick = { state.stop() }, enabled = state.isStreaming) { Text("Stop") }
}TypewriterText(
text = "Build LLM-powered apps for Android, iOS, Desktop, and Web.",
cursor = TypewriterCursor.Block,
speedCurve = SpeedCurve.Natural,
)CyclingTypewriterText(
phrases = listOf("Type", "Stream", "Render", "Anywhere"),
holdMs = 1200L,
)StreamingTypewriter(
tokens = tokens,
cursor = TypewriterCursor.Custom {
Box(Modifier.size(12.dp).background(MaterialTheme.colorScheme.primary, CircleShape))
},
)| Curve | Behaviour | Use for |
|---|---|---|
SpeedCurve.Linear |
Constant cadence | Logs, terminals |
SpeedCurve.EaseOut |
Slight stretch on whitespace | Marketing copy |
SpeedCurve.Natural |
Pauses on .!?,;:\n — simulates human typing |
LLM chat, intros |
| Renderer | What it does |
|---|---|
PlainTypewriterRenderer |
Paints raw text in the ambient style. Zero parsing cost. |
rememberMarkdownTypewriterRenderer() |
Live progressive Markdown — headings, bold, italic, strikethrough, inline code, links, fenced code blocks with per-language syntax highlighting (Kotlin / JS / TS / Python). |
The Markdown parser is prefix-stable: a **bold mid-stream renders as plain text until the
closing ** arrives, but every token before that opening ** stays exactly where it was.
Code fences highlight progressively as tokens stream in — no waiting for the closing ```.
| Target | Status |
|---|---|
| Android (minSdk 24) | ✅ |
| iOS (x64, arm64, simulatorArm64) | ✅ |
| Desktop (JVM 11) | ✅ |
| Web (wasmJs) | ✅ |
StreamingTypewriterState is fully usable without composition — handy for tests and for hosts
that want to drive the typewriter from a background coroutine:
val state = StreamingTypewriterState()
state.appendToken("Hello, ")
state.appendToken("world!")
state.completeSource()
state.skipToEnd() // flush buffer
state.stop() // freeze mid-stream
state.resume() // pick up where we left off
state.reset() // start fresh./gradlew :sample:desktopApp:run # Desktop
./gradlew :sample:androidApp:assembleDebug # Android
./gradlew :sample:webApp:wasmJsBrowserDevelopmentRun # WebThe sample includes a fake-LLM that streams pre-canned responses with Markdown + code blocks, a speed-curve switcher, and Stop/Resume/Skip controls.
Apache 2.0 — see LICENSE.