Tailwind CSS-inspired, type-safe Kotlin extension functions for Compose Multiplatform
Modifier and TextStyle — spacing, sizing, color, typography, borders, opacity,
aspect-ratio, box-shadow, gradients, grid, filters, transitions, 2D/3D transforms, flex
alignment, dark mode, and responsive breakpoints, generated from Tailwind v4's actual
token values. See docs/tailwind-coverage-matrix.md
for the exact utility-by-utility status — every category, what's implemented, what's
deliberately not (and why), one row per Tailwind utility group.
Live showcase — every utility category, rendered live in the browser via Kotlin/Wasm.
// build.gradle.kts
dependencies {
implementation("io.github.ronjunevaldoz:tailwind-compose:<version>")
}import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.Text
import io.github.ronjunevaldoz.tailwind.modifiers.*
Column(modifier = Modifier.bgSlate50().p4()) {
Box(
Modifier
.size8()
.bgBlue500()
.rounded()
)
Text(
"Hello, tailwind-compose",
style = TextStyle().textLg().fontBold().textBlue600(),
)
}Utility names mirror Tailwind's own class names directly (p4() ~ p-4, bgBlue500() ~
bg-blue-500, roundedLg() ~ rounded-lg) rather than parameterized calls, so if you know
Tailwind you already know this API.
Modifier.background(), .clip(), and .alpha() in Compose only affect modifiers that come
after them in the chain — not ones before. This library's bg*(), rounded*(), and
opacity*() functions inherit that behavior, since they're thin wrappers over those exact
Compose primitives. In practice:
// ✅ correct — bg fills the padded area, rounded clips the border
Modifier.bgSlate200().p4()
Modifier.roundedFull().border4(TwColors.blue500)
Modifier.opacity50().bgBlue500()
// ❌ wrong — background only fills the inner (unpadded) content, rounding has no visible effect
Modifier.p4().bgSlate200()
Modifier.border4(TwColors.blue500).roundedFull()
Modifier.bgBlue500().opacity50()Rule of thumb: effect modifiers (bg/rounded/opacity/border/shadow) go first in the
chain, sizing/padding modifiers go after. For a card (shadow + rounded corners + background
together), use twCard() instead of composing the three yourself —
it's easy to get the order subtly wrong (e.g. a rounded card with a square-cornered shadow
underneath it).
Tailwind has no single card utility class — bg-white rounded-lg shadow-sm is three
independent CSS classes that apply to the same box regardless of order. twCard() exists
because the Compose equivalents (shadow()/clip()/background()) are order-sensitive
instead, threading one Shape through all three in the one order that keeps them visually
consistent:
Modifier.twCard(shape = RoundedCornerShape(TwRadius.lg), color = TwColors.white, shadowElevation = TwShadow.xs)
Modifier.twCard() // same defaults as aboveMirrors Tailwind's own dark: variant — not a semantic color scheme (Tailwind itself has
no background/surface/primary roles by default, so neither does this library):
Box(Modifier.bgWhite().twDark { bgSlate900() })
Text("Hello", style = TextStyle().textSlate900().twDark { textSlate50() })twDark { } applies its block only when isTwDarkTheme() (a thin wrapper over Compose's
isSystemInDarkTheme()) is true, otherwise the chain passes through unchanged. Override
LocalTwDarkTheme to force a theme in @Previews or tests — there's otherwise no
cross-platform way to force dark mode in a test harness:
CompositionLocalProvider(LocalTwDarkTheme provides true) { /* isTwDarkTheme() is true here */ }Tailwind's sm:/md:/lg:/xl:/2xl: breakpoints are cumulative min-width matches, not
a binary switch like dark:, so this is a value-resolver rather than a chainable variant —
it picks one value up front, mobile-first, largest-matching-breakpoint-wins:
Modifier.padding(twResponsive(base = TwSpacing.scale4, md = TwSpacing.scale6, lg = TwSpacing.scale8))
val columns = twResponsive(base = 2, sm = 3, md = 4, lg = 6, xl = 8)Backed by the actual window/viewport width (currentTwWindowWidth()), matching Tailwind's
exact 640/768/1024/1280/1536dp breakpoint scale (TwBreakpoint).
Android, iOS, Desktop (JVM), Web (JS + WasmJs).
All six live under tailwind/ as tailwind/<name> (e.g. tailwind/core, no redundant
tailwind- prefix on disk) via projectDir in settings.gradle.kts — Gradle project
paths/task names and published artifactIds are still flat, e.g. :tailwind-core:
tailwind-core— design tokens:TwSpacing,TwColors(OKLCH-based, 26 hues × 11 shades),TwFontSize/TwLineHeight/TwFontWeight/TwTracking,TwRadius,TwShadow,TwTransition(duration/easing),TwBreakpointtailwind-layout— spacing, sizing, flex, grid, aspect-ratio, responsive breakpointstailwind-color— background/text color, gradients, dark modetailwind-typography— font-size, line-height, font-weight, tracking, font-familytailwind-effects— border, box-shadow, opacity, filters, transitions, 2D/3D transforms, thetwCard()combinatortailwind-compose— public facade module; depends ontailwind-coreand all four utility modules above, so most consumers only need this one dependencyshowcase— internal demo app rendering every utility category, used for visual verification via Roborazzi (not published)
Looking for icons? Heroicons compiled to
Compose ImageVector now lives in its own repo,
heroicons-compose — split out
since Heroicons is a separate upstream product from Tailwind CSS itself.
tailwind/style-experimental ports every utility above to
Compose Foundation's real Style/StyleScope API (@ExperimentalFoundationStyleApi) —
e.g. Style.ringStyle() instead of Modifier.ring(). It's a separate Gradle composite
build (own settings.gradle.kts), not one of the modules above, and isn't published.
It's not ready to fold into tailwind-compose, blocked on three things upstream (see
androidx.compose.ui release notes):
- Pinned to Compose Multiplatform
1.12.0-beta01(released June 17, 2026), a pre-release line. - Every function is
@ExperimentalFoundationStyleApi— genuinely unstable, not just labeled that way:StyleScope's helper functions moved from members to extension functions as a breaking change between 1.12.0-alpha01 and alpha03. - Android is unreachable today: 1.12.0-alpha01 bumped Compose's
compileSdkto API 37, requiring AGP 9.2.0+. Android SDK Platform 37 isn't published yet (checked Google's stable/beta/dev/canary repository channels directly, and each Android artifact's ownaar-metadata.properties— none have it). Sostyle-experimentalhas no Android target at all, by design.
JVM/Desktop, iOS, and Web (JS/WasmJs) all work fine against 1.12.0-beta01 today. A live,
web-only demo of every Style extension lives at
tailwind/style-experimental/.../style/demo/App.kt —
run it with ./gradlew :style-experimental:wasmJsBrowserDevelopmentRun. It's kept
separate from the main showcase app on purpose: mixing a Compose 1.11.1 binary
(showcase) with 1.12.0-beta01-compiled code in the same WebAssembly bundle crashes at
runtime with a Skiko LinkError, confirmed empirically.
Revisit migrating tailwind-compose to the Style API once Android API 37 ships publicly
and the API comes off @ExperimentalFoundationStyleApi.
See docs/style-api-comparison.md for a side-by-side
Modifier API vs Style API reference — a quick-lookup table plus real, working code for
every category (ring, shadow, border, color, opacity, filters, transform, transition,
spacing, sizing, typography).
./gradlew help # verify the build resolves
./gradlew ktlintCheck detekt # lint + static analysis
./gradlew jvmTest # unit tests + Roborazzi screenshot verification
./gradlew :showcase:androidApp:assembleDebug # showcase Android APK
./gradlew :showcase:desktopApp:run # showcase Desktop appMost token and Modifier files are code-generated from Tailwind's own scale/palette data —
see scripts/codegen/ and each generated file's // GENERATED CODE header before editing.
Published to Maven Central under io.github.ronjunevaldoz. See
.github/workflows/release.yml.
Apache-2.0 — see LICENSE.