
Wraps Google's Material Symbols variable fonts into a single composable with a generated, compile-time-checked icon catalog and tunable axes (weight, grade, optical size, fill, style).
Google's Material Symbols, packaged as a Compose library. Drop in any of the 3905 icons by name — no downloading SVGs one by one, no per-platform asset folders.
Every icon, filterable by weight, grade, optical size, style and category — running in the browser
Android, Desktop and Web, running the exact same Kotlin — light and dark, side by side.
| Light | Dark | |
|---|---|---|
|
Desktop JVM, native window |
![]() |
![]() |
|
Web wasmJs, in the browser |
![]() |
![]() |
| Android | ![]() |
![]() |
Filters — fill, weight, grade, optical size, style and category, all live:
![]() |
![]() |
![]() |
![]() |
Preview — size, a hex-validated color field and a Google-Fonts-style color picker:
![]() |
![]() |
![]() |
![]() |
Web has the same two panels — see them live instead of a screenshot.
Google's Material Symbols aren't a fixed set of vector drawables — they're variable fonts. A single glyph carries 4 tunable axes (fill, weight, grade, optical size), and Google ships 3 separate font families (Outlined, Rounded, Sharp) on top of that. Wiring this up by hand in Compose means juggling FontFamilys, FontVariation.Settings, raw icon-name strings that silently render blank on a typo, and — on web specifically — a font that loads asynchronously, which without care means a flash of unstyled text before the glyph appears.
This library collapses all of that into one composable and one generated, compile-time-checked catalog.
MaterialSymbolsRenderingScope {
MaterialSymbol(
iconName = MaterialSymbols.SETTINGS,
contentDescription = "Settings",
style = MaterialSymbolStyle.ROUNDED,
filled = true
)
}MaterialSymbolsRenderingScope loads the 3 icon fonts once and provides them to every MaterialSymbol composed underneath it — wrap the root of your UI tree in it, once.MaterialSymbols is generated from Google's live icon catalog — MaterialSymbols.SETTINGS, MaterialSymbols.HOME, and so on for every one of the 3905 icons Material Symbols publishes across all 3 families. Typo a name and the build fails, instead of silently rendering a blank glyph at runtime.Weight, grade and optical size are real OpenType variable-font axes (wght, GRAD, opsz), not cosmetic props — they're the same mechanism variable text fonts use, applied to icons instead of letterforms. MaterialSymbolFontsConfig exposes them:
MaterialSymbolsRenderingScope(
config = MaterialSymbolFontsConfig(
weight = FontWeight(500), // 100 (thin) .. 700 (bold) — stroke thickness
grade = 200, // -25 .. 200 — fine weight trim that doesn't reflow layout
opticalSize = 24.sp // 20 .. 48 — redraws detail for the size it's shown at
)
) {
MaterialSymbol(
iconName = MaterialSymbols.FAVORITE,
contentDescription = "Favorite",
filled = true
)
}| Axis | Range | What it actually does |
|---|---|---|
| weight | 100 – 700 | Stroke thickness, like a text font's weight. |
| grade | -25 – 200 | A weight trim that keeps the glyph's box size fixed — use it to compensate contrast (e.g. slightly bolder on a dark background) without anything else in the layout shifting. |
| optical size | 20 – 48 | The glyph is redrawn for the size it renders at, not just scaled — small icons stay legible, large icons pick up detail a naive scale-up would blur. |
| filled | on / off | Toggles the FILL axis — outlined vs. solid glyph. |
| style | Outlined / Rounded / Sharp | Which of the 3 font families to draw from — this one is a separate font, not an axis. |
Play with all 5 live in the showcase — the sliders there map 1:1 to this config.
// settings.gradle.kts
dependencyResolutionManagement {
repositories {
mavenCentral()
}
}// build.gradle.kts
dependencies {
implementation("dev.catbit:material-symbols:1.0.1")
}Targets Android, iOS (iosArm64/iosSimulatorArm64), JVM/Desktop and Web (wasmJs) — pick whichever of those your Compose Multiplatform module already targets, nothing extra to configure per-platform.
Two things in this repo that go a bit further than "wrap a font in a composable":
build-logic/material-symbols-codegen) fetches Google's live icon metadata endpoint at build time, filters it down to the exactly 3905 icons available in all 3 font families, and emits MaterialSymbols as plain const val Strings (an enum blew past the JVM's 64KB-per-method bytecode limit at this size). A bundled snapshot is the fallback if the network call fails, so the build never breaks offline.Font() returns synchronously on every target, including Wasm/JS, where the returned FontFamily resolves its actual typeface asynchronously behind the scenes — Compose's own FontFamily.Resolver re-triggers the affected draw once it's ready, so no per-platform handling is needed. An earlier version used Compose's preloadFont() on web to track loading progress explicitly and avoid a brief flash of fallback text, but once these fonts moved into this published library that API's state never resolved past null on web, freezing every glyph on the fallback font. Plain Font() trades that one-time flash for correctness.material-symbols-sample is the icon browser behind the live showcase — search, filter by weight/grade/optical size/style/category, and preview any icon, built as a small MVI app (State/Event/Effect + a ViewModel) on top of the library.
# Desktop
./gradlew :material-symbols-sample:desktopApp:run
# Web (wasmJs)
./gradlew :material-symbols-sample:webApp:wasmJsBrowserDevelopmentRun
# Android — open the project in Android Studio and run the androidApp configuration, or:
./gradlew :material-symbols-sample:androidApp:installDebugApache License 2.0 — see LICENSE.
Google's Material Symbols, packaged as a Compose library. Drop in any of the 3905 icons by name — no downloading SVGs one by one, no per-platform asset folders.
Every icon, filterable by weight, grade, optical size, style and category — running in the browser
Android, Desktop and Web, running the exact same Kotlin — light and dark, side by side.
| Light | Dark | |
|---|---|---|
|
Desktop JVM, native window |
![]() |
![]() |
|
Web wasmJs, in the browser |
![]() |
![]() |
| Android | ![]() |
![]() |
Filters — fill, weight, grade, optical size, style and category, all live:
![]() |
![]() |
![]() |
![]() |
Preview — size, a hex-validated color field and a Google-Fonts-style color picker:
![]() |
![]() |
![]() |
![]() |
Web has the same two panels — see them live instead of a screenshot.
Google's Material Symbols aren't a fixed set of vector drawables — they're variable fonts. A single glyph carries 4 tunable axes (fill, weight, grade, optical size), and Google ships 3 separate font families (Outlined, Rounded, Sharp) on top of that. Wiring this up by hand in Compose means juggling FontFamilys, FontVariation.Settings, raw icon-name strings that silently render blank on a typo, and — on web specifically — a font that loads asynchronously, which without care means a flash of unstyled text before the glyph appears.
This library collapses all of that into one composable and one generated, compile-time-checked catalog.
MaterialSymbolsRenderingScope {
MaterialSymbol(
iconName = MaterialSymbols.SETTINGS,
contentDescription = "Settings",
style = MaterialSymbolStyle.ROUNDED,
filled = true
)
}MaterialSymbolsRenderingScope loads the 3 icon fonts once and provides them to every MaterialSymbol composed underneath it — wrap the root of your UI tree in it, once.MaterialSymbols is generated from Google's live icon catalog — MaterialSymbols.SETTINGS, MaterialSymbols.HOME, and so on for every one of the 3905 icons Material Symbols publishes across all 3 families. Typo a name and the build fails, instead of silently rendering a blank glyph at runtime.Weight, grade and optical size are real OpenType variable-font axes (wght, GRAD, opsz), not cosmetic props — they're the same mechanism variable text fonts use, applied to icons instead of letterforms. MaterialSymbolFontsConfig exposes them:
MaterialSymbolsRenderingScope(
config = MaterialSymbolFontsConfig(
weight = FontWeight(500), // 100 (thin) .. 700 (bold) — stroke thickness
grade = 200, // -25 .. 200 — fine weight trim that doesn't reflow layout
opticalSize = 24.sp // 20 .. 48 — redraws detail for the size it's shown at
)
) {
MaterialSymbol(
iconName = MaterialSymbols.FAVORITE,
contentDescription = "Favorite",
filled = true
)
}| Axis | Range | What it actually does |
|---|---|---|
| weight | 100 – 700 | Stroke thickness, like a text font's weight. |
| grade | -25 – 200 | A weight trim that keeps the glyph's box size fixed — use it to compensate contrast (e.g. slightly bolder on a dark background) without anything else in the layout shifting. |
| optical size | 20 – 48 | The glyph is redrawn for the size it renders at, not just scaled — small icons stay legible, large icons pick up detail a naive scale-up would blur. |
| filled | on / off | Toggles the FILL axis — outlined vs. solid glyph. |
| style | Outlined / Rounded / Sharp | Which of the 3 font families to draw from — this one is a separate font, not an axis. |
Play with all 5 live in the showcase — the sliders there map 1:1 to this config.
// settings.gradle.kts
dependencyResolutionManagement {
repositories {
mavenCentral()
}
}// build.gradle.kts
dependencies {
implementation("dev.catbit:material-symbols:1.0.1")
}Targets Android, iOS (iosArm64/iosSimulatorArm64), JVM/Desktop and Web (wasmJs) — pick whichever of those your Compose Multiplatform module already targets, nothing extra to configure per-platform.
Two things in this repo that go a bit further than "wrap a font in a composable":
build-logic/material-symbols-codegen) fetches Google's live icon metadata endpoint at build time, filters it down to the exactly 3905 icons available in all 3 font families, and emits MaterialSymbols as plain const val Strings (an enum blew past the JVM's 64KB-per-method bytecode limit at this size). A bundled snapshot is the fallback if the network call fails, so the build never breaks offline.Font() returns synchronously on every target, including Wasm/JS, where the returned FontFamily resolves its actual typeface asynchronously behind the scenes — Compose's own FontFamily.Resolver re-triggers the affected draw once it's ready, so no per-platform handling is needed. An earlier version used Compose's preloadFont() on web to track loading progress explicitly and avoid a brief flash of fallback text, but once these fonts moved into this published library that API's state never resolved past null on web, freezing every glyph on the fallback font. Plain Font() trades that one-time flash for correctness.material-symbols-sample is the icon browser behind the live showcase — search, filter by weight/grade/optical size/style/category, and preview any icon, built as a small MVI app (State/Event/Effect + a ViewModel) on top of the library.
# Desktop
./gradlew :material-symbols-sample:desktopApp:run
# Web (wasmJs)
./gradlew :material-symbols-sample:webApp:wasmJsBrowserDevelopmentRun
# Android — open the project in Android Studio and run the androidApp configuration, or:
./gradlew :material-symbols-sample:androidApp:installDebugApache License 2.0 — see LICENSE.