The biggest problem building apps with Compose UI is how inflexible Material Compose is. Material Compose isn't customizable enough to build your own design system on top of, so you end up hacking around its components. Compose Foundation, on the other hand, is too "primitive" - it has Rows and Columns but no buttons or bottom sheets. And since theming is tied to Material, you can't even theme your app without going all-in on Material's design decisions.
You could build everything from scratch, but who has time for that? A single component like a bottom sheet can take 3-4 weeks to get right when you factor in different states, accessibility, and edge cases. This problem gets worse with Compose Multiplatform - Material looks awkward on iOS and out of proportion on desktop.
I needed a flexible solution I could use across any platform without Material's constraints. So I pulled up my sleeves and built my own:
Compose Unstyled is an API on top of Compose Foundation for building any design system effortlessly. It provides unstyled, accessible components with flexible theming APIsβall the hard work of proper UX and accessibility handled for you.
Components in Unstyled are fully renderless and show nothing on the screen by default. You can think of them as 'component patterns' that bring the concept of 'bottom sheets' or 'progress bars' to your app without you having to worry about the UX, keyboard navigation or accessibility implementation. Just bring in the styling.
Simple API with the styling of your choice
Compose Unstyled does not provide any special styling API. Everything is done via Modifier
s. If you know how to style a Box()
you know how to style every component in Compose Unstyled.
Here is a quick example of building Modal Bottom Sheets with Compose Unstyled with custom detents (where the sheet 'rests' on the screen) and styling of your choice:
val Peek = SheetDetent("peek") { containerHeight, sheetHeight ->
containerHeight * 0.6f
}
BoxWithConstraints(modifier = Modifier.fillMaxSize().background(Brush.linearGradient(listOf(Color(0xFF800080), Color(0xFFDA70D6))))) {
val modalSheetState = rememberModalBottomSheetState(
initialDetent = Hidden,
detents = listOf(Hidden, Peek, FullyExpanded)
)
LaunchedEffect(Unit) {
delay(50)
modalSheetState.targetDetent = Peek
}
Button(onClick = { modalSheetState.targetDetent = Peek }, modifier = Modifier.align(Alignment.Center).padding(WindowInsets.navigationBars.only(WindowInsetsSides.Horizontal).asPaddingValues()), shape = RoundedCornerShape(6.dp), contentPadding = PaddingValues(horizontal = 14.dp, vertical = 10.dp), backgroundColor = Color.White) {
Text("Show Sheet", fontWeight = FontWeight(500))
}
val isCompact = maxWidth < 600.dp
ModalBottomSheet(state = modalSheetState) {
Scrim(
scrimColor = Color.Black.copy(0.3f),
enter = fadeIn(),
exit = fadeOut()
)
Box(Modifier.fillMaxSize().padding(top = 12.dp).let { if (isCompact) it else it.padding(horizontal = 56.dp) }.displayCutoutPadding().statusBarsPadding().padding(WindowInsets.navigationBars.only(WindowInsetsSides.Horizontal).asPaddingValues())) {
Sheet(
modifier = Modifier
.shadow(4.dp, RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp))
.widthIn(max = 640.dp)
.fillMaxWidth(),
shape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp),
backgroundColor = Color.White,
contentColor = Color.Black
) {
Box(Modifier.fillMaxWidth().height(600.dp), contentAlignment = Alignment.TopCenter) {
DragIndication(
modifier = Modifier.padding(top = 22.dp)
.background(Color.Black.copy(0.4f), RoundedCornerShape(100)).width(32.dp).height(4.dp)
)
}
}
}
}
}
Now, I know what you might be thinking. "Alex! What a weird API. Why do I need a ModalBottomSheet
AND a Sheet
? Why
not just use Slots instead?"
This design choice was very intentional:
Design Philosophy
Compose Unstyled does not take any design decision for you and gives you full control of your layout. In fact, if you are forced into a styling that you cannot change to your needs, that is considered a bug (kindly open a GitHub issue so that I can look into it).
You might want for example to place your bottom sheet on the left or the right side of the screen. Slot based APIs are excellent when you want to ask the developer for a component that has a strict placement on the layout.
In this particular case, you can think of the ModalBottomSheet
component as the area in which the sheet can move in.
The Sheet
is the actual sheet the user can interact with. By providing components like this, it gives the developer a
clear API to work with, with clear affordances of what the component can do. For example the Scrim()
component has
enter and exit transition parameters. Compose Unstyled will animate the scrim in and out on just the right moment
for the best possible UX. You just need to specify the 'how'.
Because of how unopinionated Compose Unstyled is in terms of looks, it comes with zero platform constraints as opposed to the original Compose Foundation components. Dialog
s in Foundation have a fixed max size which make them difficult to work in cases such as working with full screen dialogs.
All components in Compose Unstyled work exactly the same on all platforms. This is intentional, as such decisions should be part of the design system layer. Even though it puts more burden on the developer, it dramatically reduces development time as there are no 'gotchas'. The components are styled exactly as you described them.
This does not make Compose Unstyled foreign to the underlying platform. Styling the system windows is an important part of styling Android when using modals such as dialogs and modal bottom sheets. In such cases, Compose Unstyled provides a LocalModalWindow
composition local, which gives you access to the Window
in which your modal is rendered in. Please note that such API is only available on the Android target and is not part of the common target API.
Lastly, each component's code is self-contained in its own file and the code is simple to understand even if you are not a Compose expert. There is no lock-in from your side. Do you need to change something right here right now and can't wait to file a bug and fix it on the library level? You can simply copy and paste the single file of the component into your code base and move on. Saved you 4 weeks of work you would have normally done yourself.
Slider
Here is how to build a slider with the styling of your choice.
It integrates with Compose's InteractionState
so that polish your components any way you want. Keyboard interactions are handled out of the box so your users can increase or decrease the values by pressing Up
or Down
on their keyboard:
Box(modifier = Modifier.fillMaxSize().background(Brush.linearGradient(listOf(Color(0xFFED213A), Color(0xFF93291E)))), contentAlignment = Alignment.Center) {
val interactionSource = remember { MutableInteractionSource() }
val isFocused by interactionSource.collectIsFocusedAsState()
val isPressed by interactionSource.collectIsPressedAsState()
val state = rememberSliderState(initialValue = 0.7f)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.padding(horizontal = 16.dp).widthIn(max = 480.dp).fillMaxWidth()) {
Button(onClick = { state.value -= 0.1f }, modifier = Modifier.shadow(4.dp, CircleShape), shape = CircleShape, backgroundColor = Color.White, contentPadding = PaddingValues(8.dp),) {
Icon(VolumeDown, "Decrease")
}
Slider(
interactionSource = interactionSource,
state = state,
modifier = Modifier.weight(1f),
track = {
Box(Modifier.fillMaxWidth().height(8.dp).padding(horizontal = 16.dp).clip(RoundedCornerShape(100.dp))) {
// the 'not yet completed' part of the track
Box(Modifier.fillMaxHeight().fillMaxWidth().background(Color(0xFF93291E)))
// the 'completed' part of the track
Box(Modifier.fillMaxHeight().fillMaxWidth(state.value).background(Color.White))
}
},
thumb = {
val thumbSize by animateDpAsState(targetValue = if (isPressed) 22.dp else 18.dp)
val thumbInteractionSource = remember { MutableInteractionSource() }
val isHovered by thumbInteractionSource.collectIsHoveredAsState()
val glowColor by animateColorAsState(
if (isFocused || isHovered) Color.White.copy(0.33f) else Color.Transparent
)
// keep the size fixed to ensure that the resizing animation is always centered
Box(
modifier = Modifier.size(36.dp).clip(CircleShape).background(glowColor),
contentAlignment = Alignment.Center
) {
Thumb(
color = Color.White,
modifier = Modifier.size(thumbSize).shadow(4.dp, CircleShape).hoverable(thumbInteractionSource),
shape = CircleShape,
)
}
}
)
Button(onClick = { state.value += 0.1f }, modifier = Modifier.shadow(4.dp, CircleShape), shape = CircleShape, backgroundColor = Color.White, contentPadding = PaddingValues(8.dp),) {
Icon(VolumeUp, "Increase")
}
}
}
Dropdown Menu
Dropdown menus are notoriously complex to implement correctly, especially when it comes to keyboard navigation and focus management.
To fully appreciate this component, try it on desktop: click the Options button to focus the demo, then use your keyboard's up and down arrow keys to navigate:
class DropdownOption(val text: String, val icon: ImageVector, val enabled: Boolean = true, val dangerous: Boolean = false)
val options = listOf(
DropdownOption("Select All", Maximize),
DropdownOption("Copy", Copy),
DropdownOption("Cut", Scissors, enabled = false),
DropdownOption("Paste", Clipboard),
DropdownOption("Delete", Trash2, dangerous = true),
)
var expanded by remember { mutableStateOf(true) }
DropdownMenu(onExpandRequest = { expanded = true }) {
Button(shape = RoundedCornerShape(6.dp), backgroundColor = Color.White, onClick = { expanded = true }, contentPadding = PaddingValues(horizontal = 14.dp, vertical = 10.dp),) {
Text("Options", fontWeight = FontWeight(500))
Spacer(Modifier.width(8.dp))
Icon(ChevronDown, null)
}
DropdownMenuPanel(
expanded = expanded,
onDismissRequest = { expanded = false },
backgroundColor = Color.White,
shape = RoundedCornerShape(8.dp),
modifier = Modifier
.padding(vertical = 4.dp)
.width(240.dp)
.shadow(4.dp, RoundedCornerShape(8.dp)),
enter = scaleIn(
animationSpec = tween(durationMillis = 120, easing = LinearOutSlowInEasing),
initialScale = 0.8f,
transformOrigin = TransformOrigin(0f, 0f)
) + fadeIn(tween(durationMillis = 30)),
exit = scaleOut(animationSpec = tween(durationMillis = 1, delayMillis = 75), targetScale = 1f) + fadeOut(tween(durationMillis = 75))
) {
options.forEachIndexed { index, option ->
if (index == 1 || index == options.lastIndex) {
Separator(color = Color(0xFFBDBDBD))
}
Button(onClick = { expanded = false }, enabled = option.enabled, modifier = Modifier.padding(4.dp), contentPadding = PaddingValues(horizontal = 8.dp, vertical = 2.dp), contentColor = (if (option.dangerous) Color(0xFFC62828) else LocalContentColor.current).copy(alpha = if (option.enabled) 1f else 0.5f), shape = RoundedCornerShape(8.dp),) {
Icon(option.icon, null)
Spacer(Modifier.width(4.dp))
Text(text = option.text, modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp, horizontal = 4.dp))
}
}
}
}
And many more unstyled components such as:
- TextField with full accessibility support for screen readers
- Button
- Dialog
- Radio Group
- TabGroup for building tabbed navigation such as bottom app bars or tabs on desktop
- Checkbox
- TriStateCheckbox
- Toggle Switch
- Scrollbars (Yes. Scrollbars.)
Every component is built with accessibility in mind, including proper semantics and full keyboard navigation support.
You can find the full list of components in the documentation ->.
Custom Compose themes from your design token
Compose Unstyled includes a flexible theming system that works with any design system tokens.
Here is how to create fully custom themes with the design tokens of your choice:
Here is an example of creating your Compose Theme function:
// define your theme properties
private val colors = ThemeProperty<Color>("colors")
private val typography = ThemeProperty<TextStyle>("typography")
private val shapes = ThemeProperty<Shape>("shapes")
private val elevation = ThemeProperty<Dp>("elevation")
// define your theme tokens.
// those are the potential values of your theme properties
private val background = ThemeToken<Color>("background")
private val card = ThemeToken<Color>("surface")
private val onCard = ThemeToken<Color>("onCard")
private val outline = ThemeToken<Color>("outline")
private val accent = ThemeToken<Color>("accent")
private val primary = ThemeToken<Color>("primary")
private val onPrimary = ThemeToken<Color>("onPrimary")
private val onSecondary = ThemeToken<Color>("onSecondary")
private val secondary = ThemeToken<Color>("secondary")
private val subtle = ThemeToken<Dp>("subtle")
private val titleMedium = ThemeToken<TextStyle>("titleMedium")
private val bodyMedium = ThemeToken<TextStyle>("bodyMedium")
private val cardShape = ThemeToken<Shape>("cardShape")
private val albumCoverShape = ThemeToken<Shape>("albumCoverShape")
private val buttonShape = ThemeToken<Shape>("buttonShape")
// create your Compose Theme and assign values to each token
private val LightTheme = buildTheme {
name = "LightTheme"
properties[colors] = mapOf(
accent to Color(0xFF3B82F6),
card to Color.White,
onCard to Color(0xFF1E293B),
outline to Color(0xFFE2E8F0),
primary to Color(0xFF2563EB),
onPrimary to Color.White,
secondary to Color(0xFFE2E8F0),
onSecondary to Color(0xFF64748B),
background to Color(0xFFF8F9FA),
)
properties[typography] = mapOf(
titleMedium to TextStyle(
fontSize = 18.sp,
fontWeight = FontWeight.SemiBold,
fontFamily = loadInterFont(),
),
bodyMedium to TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Normal,
fontFamily = loadInterFont(),
)
)
properties[shapes] = mapOf(
cardShape to RoundedCornerShape(16.dp),
albumCoverShape to RoundedCornerShape(12.dp),
buttonShape to CircleShape
)
properties[elevation] = mapOf(
subtle to 8.dp
)
}
You can then use the new theme function to wrap your app:
@Composable
fun MusicPlayerApp() {
LightTheme {
MusicPlayerCard()
}
}
which then grants access to the theme to its children using the Theme
object:
@Composable
fun MusicPlayerCard(modifier: Modifier = Modifier) {
val sliderState = rememberSliderState(initialValue = 0.3f)
Box(
modifier = modifier
.outline(1.dp, Theme[colors][outline], Theme[shapes][cardShape])
.shadow(Theme[elevation][subtle], Theme[shapes][cardShape])
.background(Theme[colors][card], Theme[shapes][cardShape])
.padding(24.dp)
) {
ProvideContentColor(Theme[colors][onCard]) {
Column(verticalArrangement = Arrangement.spacedBy(20.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp)) {
Image(
painter = painterResource(Res.drawable.just_hoist_it_cover),
modifier = Modifier
.clip(Theme[shapes][albumCoverShape])
.background(Theme[colors][primary])
.size(80.dp),
contentDescription = "Album Cover",
contentScale = ContentScale.Crop
)
Column(modifier = Modifier.weight(1f)) {
Text("Just hoist it!", style = Theme[typography][titleMedium])
Spacer(Modifier.height(4.dp))
Text(
"The Deprecated",
style = Theme[typography][bodyMedium],
color = Theme[colors][onSecondary]
)
}
}
Slider(
state = sliderState,
modifier = Modifier.fillMaxWidth(),
track = {
Box(Modifier.fillMaxWidth().height(4.dp).clip(RoundedCornerShape(2.dp))) {
// the empty part of the track
Box(Modifier.fillMaxSize().background(Theme[colors][secondary]))
// the filled part of the track
Box(
Modifier.fillMaxWidth(sliderState.value).fillMaxSize().background(Theme[colors][accent])
)
}
},
thumb = {
Thumb(
color = Theme[colors][accent],
modifier = Modifier.size(16.dp),
shape = Theme[shapes][buttonShape]
)
}
)
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly, verticalAlignment = Alignment.CenterVertically) {
Button(onClick = { }, contentPadding = PaddingValues(12.dp), shape = Theme[shapes][buttonShape]) {
Icon(imageVector = Lucide.SkipBack, contentDescription = "Previous", modifier = Modifier.size(20.dp))
}
Button(onClick = { }, backgroundColor = Theme[colors][primary], contentColor = Theme[colors][onPrimary], contentPadding = PaddingValues(16.dp), shape = Theme[shapes][buttonShape]) {
Icon(imageVector = Lucide.Pause, contentDescription = "Pause", modifier = Modifier.size(24.dp))
}
Button(onClick = { }, contentPadding = PaddingValues(12.dp), shape = Theme[shapes][buttonShape]) {
Icon(imageVector = Lucide.SkipForward, contentDescription = "Next", modifier = Modifier.size(20.dp))
}
}
}
}
}
}
Outline modifiers
Last but not least, Compose Unstyled brings in some styling Modifier
s that are missing from Compose Foundation, but
are a must-have for building visually rich interfaces:
Outline
As opposed to Compose Foundation's border()
modifier, this modifier does not affect layout. It also draws around the component instead of inside. This is handy for when you need a semi-transparent outline that blends nicely with shadows:
SimpleButton(
shape = RectangleShape,
modifier = Modifier.outline(2.dp, Color(0xFF3B82F6), shape = RectangleShape)
)
SimpleButton(
shape = RoundedCornerShape(8.dp),
modifier = Modifier.outline(2.dp, Color(0xFF3B82F6), shape = RoundedCornerShape(8.dp))
)
SimpleButton(
shape = CircleShape,
modifier = Modifier.outline(2.dp, Color(0xFF3B82F6), shape = CircleShape)
)
Focus Ring
Focus rings are important when dealing with keyboard navigation and focus. They render an outline only when focused.
val interactionSource = remember { MutableInteractionSource() }
SimpleButton(
modifier = Modifier.focusRing(
interactionSource = interactionSource,
width = 2.dp,
color = Color(0xFF3B82F6),
shape = RoundedCornerShape(8.dp),
offset = 2.dp
),
interactionSource = interactionSource
)
What's next
Upcoming components include:
- Side Sheets
- Tooltips
- Context Menus
And more.
There is also a UI Kit in the making if you would like to financially support the project. The UI Kit is a full implementation of a design system for both touch and pointer apps.
There is a lot of work and expertise into getting these APIs just right. By financially supporting the project means that you get to have more stuff for years to come, while I can continue working on open source, while paying rent.
Want to stay in the loop? Make sure to follow Unstyled on Github.
Want to talk about this post? Discuss this on GitHub β